diff --git a/packages/@react-spectrum/s2/src/TableView.tsx b/packages/@react-spectrum/s2/src/TableView.tsx index 5bbaecf343b..b7af9870fc1 100644 --- a/packages/@react-spectrum/s2/src/TableView.tsx +++ b/packages/@react-spectrum/s2/src/TableView.tsx @@ -50,12 +50,14 @@ import { useTableOptions, Virtualizer } from 'react-aria-components'; +import {ButtonGroup} from './ButtonGroup'; import {centerPadding, colorScheme, controlFont, getAllowedOverrides, StylesPropWithHeight, UnsafeStyles} from './style-utils' with {type: 'macro'}; import {Checkbox} from './Checkbox'; import Checkmark from '../s2wf-icons/S2_Icon_Checkmark_20_N.svg'; import Chevron from '../ui-icons/Chevron'; import Close from '../s2wf-icons/S2_Icon_Close_20_N.svg'; import {ColumnSize} from '@react-types/table'; +import {CustomDialog, DialogContainer} from '..'; import {DOMRef, DOMRefValue, forwardRefType, GlobalDOMAttributes, LoadingState, Node} from '@react-types/shared'; import {getActiveElement, getOwnerDocument, useLayoutEffect, useObjectRef} from '@react-aria/utils'; import {GridNode} from '@react-types/grid'; @@ -67,11 +69,12 @@ import {Menu, MenuItem, MenuSection, MenuTrigger} from './Menu'; import Nubbin from '../ui-icons/S2_MoveHorizontalTableWidget.svg'; import {ProgressCircle} from './ProgressCircle'; import {raw} from '../style/style-macro' with {type: 'macro'}; -import React, {createContext, CSSProperties, ForwardedRef, forwardRef, ReactElement, ReactNode, RefObject, useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; +import React, {createContext, CSSProperties, FormEvent, FormHTMLAttributes, ForwardedRef, forwardRef, ReactElement, ReactNode, RefObject, useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; import SortDownArrow from '../s2wf-icons/S2_Icon_SortDown_20_N.svg'; import SortUpArrow from '../s2wf-icons/S2_Icon_SortUp_20_N.svg'; +import {Button as SpectrumButton} from './Button'; import {useActionBarContainer} from './ActionBar'; -import {useDOMRef} from '@react-spectrum/utils'; +import {useDOMRef, useMediaQuery} from '@react-spectrum/utils'; import {useLocalizedStringFormatter} from '@react-aria/i18n'; import {useScale} from './utils'; import {useSpectrumContextProps} from './useSpectrumContextProps'; @@ -1081,17 +1084,6 @@ const editableCell = style { /** Whether the cell is currently being saved. */ isSaving?: boolean, /** Handler that is called when the value has been changed and is ready to be saved. */ - onSubmit: () => void + onSubmit?: (e: FormEvent) => void, + /** Handler that is called when the user cancels the edit. */ + onCancel?: () => void, + /** The action to submit the form to. Only available in React 19+. */ + action?: string | FormHTMLAttributes['action'] } /** @@ -1173,7 +1169,7 @@ const nonTextInputTypes = new Set([ ]); function EditableCellInner(props: EditableCellProps & {isFocusVisible: boolean, cellRef: RefObject}) { - let {children, align, renderEditing, isSaving, onSubmit, isFocusVisible, cellRef} = props; + let {children, align, renderEditing, isSaving, onSubmit, isFocusVisible, cellRef, action, onCancel} = props; let [isOpen, setIsOpen] = useState(false); let popoverRef = useRef(null); let formRef = useRef(null); @@ -1182,6 +1178,7 @@ function EditableCellInner(props: EditableCellProps & {isFocusVisible: boolean, let [verticalOffset, setVerticalOffset] = useState(0); let tableVisualOptions = useContext(InternalTableContext); let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/s2'); + let dialogRef = useRef>(null); let {density} = useContext(InternalTableContext); let size: 'XS' | 'S' | 'M' | 'L' | 'XL' | undefined = 'M'; @@ -1225,9 +1222,32 @@ function EditableCellInner(props: EditableCellProps & {isFocusVisible: boolean, } }, [isOpen]); - let cancel = () => { + let cancel = useCallback(() => { setIsOpen(false); - }; + onCancel?.(); + }, [onCancel]); + + let isMobile = !useMediaQuery('(hover: hover) and (pointer: fine)'); + // Can't differentiate between Dialog click outside dismissal and Escape key dismissal + let prevIsOpen = useRef(isOpen); + useEffect(() => { + let dialog = dialogRef.current?.UNSAFE_getDOMNode(); + if (isOpen && dialog && !prevIsOpen.current) { + let handler = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + cancel(); + e.stopPropagation(); + e.preventDefault(); + } + }; + dialog.addEventListener('keydown', handler); + prevIsOpen.current = isOpen; + return () => { + dialog.removeEventListener('keydown', handler); + }; + } + prevIsOpen.current = isOpen; + }, [isOpen, cancel]); return ( - { - if (!popoverRef.current?.contains(document.activeElement)) { + {!isMobile && ( + { + if (!popoverRef.current?.contains(document.activeElement)) { + return false; + } + formRef.current?.requestSubmit(); return false; - } - formRef.current?.requestSubmit(); - return false; - }} - triggerRef={cellRef} - aria-label={stringFormatter.format('table.editCell')} - offset={verticalOffset} - placement="bottom start" - style={{ - minWidth: `min(${triggerWidth}px, ${tableWidth}px)`, - maxWidth: `${tableWidth}px`, - // Override default z-index from useOverlayPosition. We use isolation: isolate instead. - zIndex: undefined - }} - className={editPopover}> - -
{ - e.preventDefault(); - onSubmit(); - setIsOpen(false); - }} - className={style({width: 'full', display: 'flex', alignItems: 'start', gap: 16})} - style={{'--input-width': `calc(${triggerWidth}px - 32px)`} as CSSProperties}> - {renderEditing()} -
- - -
-
-
-
+ }} + triggerRef={cellRef} + aria-label={props['aria-label'] ?? stringFormatter.format('table.editCell')} + offset={verticalOffset} + placement="bottom start" + style={{ + minWidth: `min(${triggerWidth}px, ${tableWidth}px)`, + maxWidth: `${tableWidth}px`, + // Override default z-index from useOverlayPosition. We use isolation: isolate instead. + zIndex: undefined + }} + className={editPopover}> + +
{ + onSubmit?.(e); + setIsOpen(false); + }} + className={style({width: 'full', display: 'flex', alignItems: 'start', gap: 16})} + style={{'--input-width': `calc(${triggerWidth}px - 32px)`} as CSSProperties}> + {renderEditing()} +
+ + +
+
+
+
+ )} + {isMobile && ( + formRef.current?.requestSubmit()}> + {isOpen && ( + +
{ + onSubmit?.(e); + setIsOpen(false); + }} + className={style({width: 'full', display: 'flex', flexDirection: 'column', alignItems: 'start', gap: 16})}> + {renderEditing()} + + Cancel + Save + +
+
+ )} +
+ )}
); -} +}; // Use color-mix instead of transparency so sticky cells work correctly. const selectedBackground = lightDark(colorMix('gray-25', 'informative-900', 10), colorMix('gray-25', 'informative-700', 10)); diff --git a/packages/@react-spectrum/s2/stories/TableView.stories.tsx b/packages/@react-spectrum/s2/stories/TableView.stories.tsx index 2fd5b40815b..c637a16981f 100644 --- a/packages/@react-spectrum/s2/stories/TableView.stories.tsx +++ b/packages/@react-spectrum/s2/stories/TableView.stories.tsx @@ -40,10 +40,11 @@ import Filter from '../s2wf-icons/S2_Icon_Filter_20_N.svg'; import FolderOpen from '../spectrum-illustrations/linear/FolderOpen'; import {Key} from '@react-types/shared'; import type {Meta, StoryObj} from '@storybook/react'; -import {ReactElement, useCallback, useRef, useState} from 'react'; +import React, {ReactElement, useCallback, useEffect, useRef, useState} from 'react'; import {SortDescriptor} from 'react-aria-components'; import {style} from '../style/spectrum-theme' with {type: 'macro'}; -import {useAsyncList} from '@react-stately/data'; +import {useAsyncList, useListData} from '@react-stately/data'; +import {useEffectEvent} from '@react-aria/utils'; import User from '../s2wf-icons/S2_Icon_User_20_N.svg'; let onActionFunc = action('onAction'); @@ -1460,24 +1461,15 @@ interface EditableTableProps extends TableViewProps {} export const EditableTable: StoryObj = { render: function EditableTable(props) { let columns = editableColumns; - let [editableItems, setEditableItems] = useState(defaultItems); - let intermediateValue = useRef(null); + let data = useListData({initialItems: defaultItems}); - let onChange = useCallback((id: Key, columnId: Key) => { - let value = intermediateValue.current; + let onChange = useCallback((id: Key, columnId: Key, values: any) => { + let value = values[columnId]; if (value === null) { return; } - intermediateValue.current = null; - setEditableItems(prev => { - let newItems = prev.map(i => i.id === id && i[columnId] !== value ? {...i, [columnId]: value} : i); - return newItems; - }); - }, []); - - let onIntermediateChange = useCallback((value: any) => { - intermediateValue.current = value; - }, []); + data.update(id, (prevItem) => ({...prevItem, [columnId]: value})); + }, [data]); return (
@@ -1487,25 +1479,31 @@ export const EditableTable: StoryObj = { {column.name} )} - + {item => ( {(column) => { if (column.id === 'fruits') { return ( onChange(item.id, column.id!)} + onSubmit={(e) => { + e.preventDefault(); + let formData = new FormData(e.target as HTMLFormElement); + let values = Object.fromEntries(formData.entries()); + onChange(item.id, column.id!, values); + }} isSaving={item.isSaving[column.id!]} renderEditing={() => ( value.length > 0 ? null : 'Fruit name is required'} styles={style({flexGrow: 1, flexShrink: 1, minWidth: 0})} - defaultValue={item[column.id!]} - onChange={value => onIntermediateChange(value)} /> + defaultValue={item[column.id!]} /> )}>
{item[column.id]} @@ -1520,7 +1518,12 @@ export const EditableTable: StoryObj = { onChange(item.id, column.id!)} + onSubmit={(e) => { + e.preventDefault(); + let formData = new FormData(e.target as HTMLFormElement); + let values = Object.fromEntries(formData.entries()); + onChange(item.id, column.id!, values); + }} isSaving={item.isSaving[column.id!]} renderEditing={() => ( = { autoFocus styles={style({flexGrow: 1, flexShrink: 1, minWidth: 0})} defaultValue={item[column.id!]} - onChange={value => onIntermediateChange(value)}> + name={column.id! as string}> Eva Steven Michael @@ -1565,48 +1568,44 @@ export const EditableTable: StoryObj = { export const EditableTableWithAsyncSaving: StoryObj = { render: function EditableTable(props) { + let delay = 5000; let columns = editableColumns; - let [editableItems, setEditableItems] = useState(defaultItems); - let intermediateValue = useRef(null); - - // Replace all of this with real API calls, this is purely demonstrative. - let saveItem = useCallback((id: Key, columnId: Key, prevValue: any) => { - let succeeds = Math.random() > 0.5; - if (succeeds) { - setEditableItems(prev => prev.map(i => i.id === id ? {...i, isSaving: {...i.isSaving, [columnId]: false}} : i)); - } else { - setEditableItems(prev => prev.map(i => i.id === id ? {...i, [columnId]: prevValue, isSaving: {...i.isSaving, [columnId]: false}} : i)); - } + let data = useListData({initialItems: defaultItems}); + + let saveItem = useEffectEvent((id: Key, columnId: Key) => { + let prevItem = data.getItem(id)!; + data.update(id, {...prevItem, isSaving: {...prevItem.isSaving, [columnId]: false}}); currentRequests.current.delete(id); - }, []); - let currentRequests = useRef, prevValue: any}>>(new Map()); - let onChange = useCallback((id: Key, columnId: Key) => { - let value = intermediateValue.current; + }); + let currentRequests = useRef}>>(new Map()); + let onChange = useCallback((id: Key, columnId: Key, values: any) => { + let value = values[columnId]; if (value === null) { return; } - intermediateValue.current = null; let alreadySaving = currentRequests.current.get(id); if (alreadySaving) { // remove and cancel the previous request currentRequests.current.delete(id); clearTimeout(alreadySaving.request); } - setEditableItems(prev => { - let prevValue = prev.find(i => i.id === id)?.[columnId]; - let newItems = prev.map(i => i.id === id && i[columnId] !== value ? {...i, [columnId]: value, isSaving: {...i.isSaving, [columnId]: true}} : i); - // set a timeout between 0 and 10s - let timeout = setTimeout(() => { - saveItem(id, columnId, alreadySaving?.prevValue ?? prevValue); - }, Math.random() * 10000); - currentRequests.current.set(id, {request: timeout, prevValue}); - return newItems; - }); - }, [saveItem]); - - let onIntermediateChange = useCallback((value: any) => { - intermediateValue.current = value; - }, []); + let prevItem = data.getItem(id)!; + data.update(id, {...prevItem, [columnId]: value, isSaving: {...prevItem.isSaving, [columnId]: true}}); + }, [data]); + + useEffect(() => { + // if any item is saving and we don't have a request for it, start a timer to commit it + for (const item of data.items) { + for (const columnId in item.isSaving) { + if (item.isSaving[columnId] && !currentRequests.current.has(item.id)) { + let timeout = setTimeout(() => { + saveItem(item.id, columnId); + }, delay); + currentRequests.current.set(item.id, {request: timeout}); + } + } + } + }, [data, delay]); return (
@@ -1616,16 +1615,22 @@ export const EditableTableWithAsyncSaving: StoryObj = { {column.name} )} - + {item => ( {(column) => { if (column.id === 'fruits') { return ( onChange(item.id, column.id!)} + onSubmit={(e) => { + e.preventDefault(); + let formData = new FormData(e.target as HTMLFormElement); + let values = Object.fromEntries(formData.entries()); + onChange(item.id, column.id!, values); + }} isSaving={item.isSaving[column.id!]} renderEditing={() => ( = { validate={value => value.length > 0 ? null : 'Fruit name is required'} styles={style({flexGrow: 1, flexShrink: 1, minWidth: 0})} defaultValue={item[column.id!]} - onChange={value => onIntermediateChange(value)} /> + name={column.id! as string} /> )}>
{item[column.id]}
@@ -1645,7 +1650,12 @@ export const EditableTableWithAsyncSaving: StoryObj = { onChange(item.id, column.id!)} + onSubmit={(e) => { + e.preventDefault(); + let formData = new FormData(e.target as HTMLFormElement); + let values = Object.fromEntries(formData.entries()); + onChange(item.id, column.id!, values); + }} isSaving={item.isSaving[column.id!]} renderEditing={() => ( = { autoFocus styles={style({flexGrow: 1, flexShrink: 1, minWidth: 0})} defaultValue={item[column.id!]} - onChange={value => onIntermediateChange(value)}> + name={column.id! as string}> Eva Steven Michael diff --git a/packages/@react-spectrum/s2/test/EditableTableView.test.tsx b/packages/@react-spectrum/s2/test/EditableTableView.test.tsx index e102ac19515..560fdd4a338 100644 --- a/packages/@react-spectrum/s2/test/EditableTableView.test.tsx +++ b/packages/@react-spectrum/s2/test/EditableTableView.test.tsx @@ -33,7 +33,9 @@ import { import Edit from '../s2wf-icons/S2_Icon_Edit_20_N.svg'; import {installPointerEvent, pointerMap, User} from '@react-aria/test-utils'; import {Key} from '@react-types/shared'; -import React, {useCallback, useRef, useState} from 'react'; +import React, {useCallback, useEffect, useRef} from 'react'; +import {useEffectEvent} from '@react-aria/utils'; +import {useListData} from '@react-stately/data'; import userEvent from '@testing-library/user-event'; // @ts-ignore @@ -65,53 +67,43 @@ describe('TableView', () => { let defaultItems = [ {id: 1, fruits: 'Apples', task: 'Collect', status: 'Pending', farmer: 'Eva', - isSaving: {}, - intermediateValue: {} + isSaving: {} }, {id: 2, fruits: 'Oranges', task: 'Collect', status: 'Pending', farmer: 'Steven', - isSaving: {}, - intermediateValue: {} + isSaving: {} }, {id: 3, fruits: 'Pears', task: 'Collect', status: 'Pending', farmer: 'Michael', - isSaving: {}, - intermediateValue: {} + isSaving: {} }, {id: 4, fruits: 'Cherries', task: 'Collect', status: 'Pending', farmer: 'Sara', - isSaving: {}, - intermediateValue: {} + isSaving: {} }, {id: 5, fruits: 'Dates', task: 'Collect', status: 'Pending', farmer: 'Karina', - isSaving: {}, - intermediateValue: {} + isSaving: {} }, {id: 6, fruits: 'Bananas', task: 'Collect', status: 'Pending', farmer: 'Otto', - isSaving: {}, - intermediateValue: {} + isSaving: {} }, {id: 7, fruits: 'Melons', task: 'Collect', status: 'Pending', farmer: 'Matt', - isSaving: {}, - intermediateValue: {} + isSaving: {} }, {id: 8, fruits: 'Figs', task: 'Collect', status: 'Pending', farmer: 'Emily', - isSaving: {}, - intermediateValue: {} + isSaving: {} }, {id: 9, fruits: 'Blueberries', task: 'Collect', status: 'Pending', farmer: 'Amelia', - isSaving: {}, - intermediateValue: {} + isSaving: {} }, {id: 10, fruits: 'Blackberries', task: 'Collect', status: 'Pending', farmer: 'Isla', - isSaving: {}, - intermediateValue: {} + isSaving: {} } ]; @@ -124,42 +116,43 @@ describe('TableView', () => { interface EditableTableProps extends TableViewProps {} - function EditableTable(props: EditableTableProps & {delay?: number}) { - let {delay = 0} = props; + function EditableTable(props: EditableTableProps & {delay?: number, onCancel?: () => void}) { + let {delay = 0, onCancel} = props; let columns = editableColumns; - let [editableItems, setEditableItems] = useState(defaultItems); - let intermediateValue = useRef(null); + let data = useListData({initialItems: defaultItems}); - let saveItem = useCallback((id: Key, columnId: Key) => { - setEditableItems(prev => prev.map(i => i.id === id ? {...i, isSaving: {...i.isSaving, [columnId]: false}} : i)); + let saveItem = useEffectEvent((id: Key, columnId: Key) => { + data.update(id, (prevItem) => ({...prevItem, isSaving: {...prevItem.isSaving, [columnId]: false}})); currentRequests.current.delete(id); - }, []); + }); let currentRequests = useRef}>>(new Map()); - let onChange = useCallback((id: Key, columnId: Key) => { - let value = intermediateValue.current; + let onChange = useCallback((id: Key, columnId: Key, values: any) => { + let value = values[columnId]; if (value === null) { return; } - intermediateValue.current = null; let alreadySaving = currentRequests.current.get(id); if (alreadySaving) { // remove and cancel the previous request currentRequests.current.delete(id); clearTimeout(alreadySaving.request); } - setEditableItems(prev => { - let newItems = prev.map(i => i.id === id && i[columnId] !== value ? {...i, [columnId]: value, isSaving: {...i.isSaving, [columnId]: true}} : i); - let timeout = setTimeout(() => { - saveItem(id, columnId); - }, delay); - currentRequests.current.set(id, {request: timeout}); - return newItems; - }); - }, [saveItem, delay]); - - let onIntermediateChange = useCallback((value: any) => { - intermediateValue.current = value; - }, []); + data.update(id, (prevItem) => ({...prevItem, [columnId]: value, isSaving: {...prevItem.isSaving, [columnId]: true}})); + }, [data]); + + useEffect(() => { + // if any item is saving and we don't have a request for it, start a timer to commit it + for (const item of data.items) { + for (const columnId in item.isSaving) { + if (item.isSaving[columnId] && !currentRequests.current.has(item.id)) { + let timeout = setTimeout(() => { + saveItem(item.id, columnId); + }, delay); + currentRequests.current.set(item.id, {request: timeout}); + } + } + } + }, [data, delay]); return (
@@ -169,7 +162,7 @@ describe('TableView', () => { {column.name} )} - + {item => ( {(column) => { @@ -178,7 +171,13 @@ describe('TableView', () => { onChange(item.id, column.id!)} + onSubmit={(e) => { + e.preventDefault(); + let formData = new FormData(e.target as HTMLFormElement); + let values = Object.fromEntries(formData.entries()); + onChange(item.id, column.id!, values); + }} + onCancel={onCancel} isSaving={item.isSaving[column.id!]} renderEditing={() => ( { autoFocus validate={value => value.length > 0 ? null : 'Fruit name is required'} defaultValue={item[column.id!]} - onChange={value => onIntermediateChange(value)} /> + name={column.id! as string} /> )}>
{item[column.id]}
@@ -197,14 +196,20 @@ describe('TableView', () => { onChange(item.id, column.id!)} + onSubmit={(e) => { + e.preventDefault(); + let formData = new FormData(e.target as HTMLFormElement); + let values = Object.fromEntries(formData.entries()); + onChange(item.id, column.id!, values); + }} + onCancel={onCancel} isSaving={item.isSaving[column.id!]} renderEditing={() => ( onIntermediateChange(value)}> + name={column.id! as string}> Eva Steven Michael @@ -336,8 +341,9 @@ describe('TableView', () => { }); it('should be cancellable through the buttons in the dialog', async () => { + let onCancel = jest.fn(); let {getByRole} = render( - + ); let tableTester = testUtilUser.createTester('Table', {root: getByRole('grid')}); @@ -360,11 +366,13 @@ describe('TableView', () => { expect(dialog).not.toBeInTheDocument(); expect(tableTester.findRow({rowIndexOrText: 'Apples'})).toBeInTheDocument(); + expect(onCancel).toHaveBeenCalled(); }); it('should be cancellable through Escape key', async () => { + let onCancel = jest.fn(); let {getByRole} = render( - + ); let tableTester = testUtilUser.createTester('Table', {root: getByRole('grid')}); @@ -385,6 +393,7 @@ describe('TableView', () => { expect(dialog).not.toBeInTheDocument(); expect(tableTester.findRow({rowIndexOrText: 'Apples'})).toBeInTheDocument(); + expect(onCancel).toHaveBeenCalled(); }); }); @@ -482,4 +491,378 @@ describe('TableView', () => { expect(button).not.toHaveAttribute('aria-disabled'); }); }); + + if (parseInt(React.version, 10) >= 19) { + describe('using action instead of onSubmit', () => { + function ActionEditableTable(props: EditableTableProps & {delay?: number, onCancel?: () => void}) { + let {delay = 0, onCancel} = props; + let columns = editableColumns; + let data = useListData({initialItems: defaultItems}); + + let saveItem = useEffectEvent((id: Key, columnId: Key) => { + data.update(id, (prevItem) => ({...prevItem, isSaving: {...prevItem.isSaving, [columnId]: false}})); + currentRequests.current.delete(id); + }); + let currentRequests = useRef}>>(new Map()); + let onChange = useCallback((id: Key, columnId: Key, values: any) => { + let value = values.get(columnId); + if (value === null) { + return; + } + let alreadySaving = currentRequests.current.get(id); + if (alreadySaving) { + // remove and cancel the previous request + currentRequests.current.delete(id); + clearTimeout(alreadySaving.request); + } + data.update(id, (prevItem) => ({...prevItem, [columnId]: value, isSaving: {...prevItem.isSaving, [columnId]: true}})); + }, [data]); + + useEffect(() => { + // if any item is saving and we don't have a request for it, start a timer to commit it + for (const item of data.items) { + for (const columnId in item.isSaving) { + if (item.isSaving[columnId] && !currentRequests.current.has(item.id)) { + let timeout = setTimeout(() => { + saveItem(item.id, columnId); + }, delay); + currentRequests.current.set(item.id, {request: timeout}); + } + } + } + }, [data, delay]); + + return ( +
+ + + {(column) => ( + {column.name} + )} + + + {item => ( + + {(column) => { + if (column.id === 'fruits') { + return ( + { + onChange(item.id, column.id!, e); + }} + onCancel={onCancel} + isSaving={item.isSaving[column.id!]} + renderEditing={() => ( + value.length > 0 ? null : 'Fruit name is required'} + defaultValue={item[column.id!]} + name={column.id! as string} /> + )}> +
{item[column.id]}
+
+ ); + } + if (column.id === 'farmer') { + return ( + { + onChange(item.id, column.id!, e); + }} + onCancel={onCancel} + isSaving={item.isSaving[column.id!]} + renderEditing={() => ( + + Eva + Steven + Michael + Sara + Karina + Otto + Matt + Emily + Amelia + Isla + + )}> +
{item[column.id]}
+
+ ); + } + if (column.id === 'status') { + return ( + + {item[column.id]} + + ); + } + return {item[column.id!]}; + }} +
+ )} +
+
+ +
+ ); + } + + describe('keyboard', () => { + it('should edit text in a cell either through a TextField or a Picker', async () => { + let {getByRole} = render( + + ); + + let tableTester = testUtilUser.createTester('Table', {root: getByRole('grid')}); + await user.tab(); + await user.keyboard('{ArrowRight}'); + let dialogTrigger = document.activeElement! as HTMLElement; + let dialogTester = testUtilUser.createTester('Dialog', {root: dialogTrigger, interactionType: 'keyboard', overlayType: 'modal'}); + await dialogTester.open(); + let dialog = dialogTester.dialog; + expect(dialog).toBeVisible(); + + let input = within(dialog!).getByRole('textbox'); + expect(input).toHaveFocus(); + + await user.keyboard('Apples Crisp'); + await user.keyboard('{Enter}'); // implicitly submit through form + + act(() => {jest.runAllTimers();}); + + expect(dialog).not.toBeInTheDocument(); + + expect(tableTester.findRow({rowIndexOrText: 'Apples Crisp'})).toBeInTheDocument(); + + // navigate to Farmer column + await user.keyboard('{ArrowRight}'); + await user.keyboard('{ArrowRight}'); + await user.keyboard('{ArrowRight}'); + dialogTrigger = document.activeElement! as HTMLElement; + dialogTester = testUtilUser.createTester('Dialog', {root: dialogTrigger, interactionType: 'keyboard', overlayType: 'modal'}); + await dialogTester.open(); + dialog = dialogTester.dialog; + // TODO: also weird that it is dialog.dialog? + expect(dialog).toBeVisible(); + + let selectTester = testUtilUser.createTester('Select', {root: dialog!}); + expect(selectTester.trigger).toHaveFocus(); + await selectTester.selectOption({option: 'Steven'}); + act(() => {jest.runAllTimers();}); + await user.tab(); + await user.tab(); + expect(within(dialog!).getByRole('button', {name: 'Save'})).toHaveFocus(); + await user.keyboard('{Enter}'); + + act(() => {jest.runAllTimers();}); + + expect(dialog).not.toBeInTheDocument(); + expect(within(tableTester.findRow({rowIndexOrText: 'Apples Crisp'})).getByText('Steven')).toBeInTheDocument(); + + await user.tab(); + expect(getByRole('button', {name: 'After'})).toHaveFocus(); + + await user.tab({shift: true}); + expect(within(tableTester.findRow({rowIndexOrText: 'Apples Crisp'})).getByRole('button', {name: 'Edit farmer'})).toHaveFocus(); + }); + + it('should perform validation when editing text in a cell', async () => { + let {getByRole} = render( + + ); + + let tableTester = testUtilUser.createTester('Table', {root: getByRole('grid')}); + await user.tab(); + await user.keyboard('{ArrowRight}'); + await user.keyboard('{Enter}'); + + let dialog = getByRole('dialog'); + expect(dialog).toBeVisible(); + + let input = within(dialog).getByRole('textbox'); + expect(input).toHaveFocus(); + + await user.clear(input); + await user.keyboard('{Enter}'); + + act(() => {jest.runAllTimers();}); + + expect(dialog).toBeInTheDocument(); + expect(input).toHaveFocus(); + expect(document.getElementById(input.getAttribute('aria-describedby')!)).toHaveTextContent('Fruit name is required'); + + await user.keyboard('Peaches'); + await user.tab(); + await user.tab(); + await user.keyboard('{Enter}'); + + act(() => {jest.runAllTimers();}); + + expect(dialog).not.toBeInTheDocument(); + + expect(tableTester.findRow({rowIndexOrText: 'Peaches'})).toBeInTheDocument(); + }); + + it('should be cancellable through the buttons in the dialog', async () => { + let onCancel = jest.fn(); + let {getByRole} = render( + + ); + + let tableTester = testUtilUser.createTester('Table', {root: getByRole('grid')}); + await user.tab(); + await user.keyboard('{ArrowRight}'); + await user.keyboard('{Enter}'); + + let dialog = getByRole('dialog'); + expect(dialog).toBeVisible(); + + let input = within(dialog).getByRole('textbox'); + expect(input).toHaveFocus(); + + await user.keyboard(' Crisp'); + await user.tab(); + await user.keyboard('{Enter}'); + + act(() => {jest.runAllTimers();}); + + expect(dialog).not.toBeInTheDocument(); + + expect(tableTester.findRow({rowIndexOrText: 'Apples'})).toBeInTheDocument(); + expect(onCancel).toHaveBeenCalled(); + }); + + it('should be cancellable through Escape key', async () => { + let onCancel = jest.fn(); + let {getByRole} = render( + + ); + + let tableTester = testUtilUser.createTester('Table', {root: getByRole('grid')}); + await user.tab(); + await user.keyboard('{ArrowRight}'); + await user.keyboard('{Enter}'); + + let dialog = getByRole('dialog'); + expect(dialog).toBeVisible(); + + let input = within(dialog).getByRole('textbox'); + expect(input).toHaveFocus(); + + await user.keyboard(' Crisp'); + await user.keyboard('{Escape}'); + + act(() => {jest.runAllTimers();}); + + expect(dialog).not.toBeInTheDocument(); + expect(tableTester.findRow({rowIndexOrText: 'Apples'})).toBeInTheDocument(); + expect(onCancel).toHaveBeenCalled(); + }); + }); + + describe('pointer', () => { + installPointerEvent(); + + it('should edit text in a cell', async () => { + let {getByRole} = render( + + ); + + let tableTester = testUtilUser.createTester('Table', {root: getByRole('grid')}); + await user.click(within(tableTester.findCell({text: 'Apples'})).getByRole('button')); + + let dialog = getByRole('dialog'); + expect(dialog).toBeVisible(); + + await user.click(within(dialog).getByRole('textbox')); + await user.keyboard(' Crisp'); + await user.click(document.body); + + act(() => {jest.runAllTimers();}); + + expect(dialog).not.toBeInTheDocument(); + expect(tableTester.findRow({rowIndexOrText: 'Apples Crisp'})).toBeInTheDocument(); + }); + }); + + describe('pending', () => { + it('should display a pending state when editing a cell', async () => { + let {getByRole} = render( + + ); + + let tableTester = testUtilUser.createTester('Table', {root: getByRole('grid')}); + await user.tab(); + await user.keyboard('{ArrowRight}'); + await user.keyboard('{Enter}'); + + let dialog = getByRole('dialog'); + expect(dialog).toBeVisible(); + + let input = within(dialog).getByRole('textbox'); + expect(input).toHaveFocus(); + + await user.keyboard('Apples Crisp'); + await user.keyboard('{Enter}'); // implicitly submit through form + + act(() => {jest.advanceTimersByTime(5000);}); + + expect(dialog).not.toBeInTheDocument(); + expect(tableTester.findRow({rowIndexOrText: 'Apples Crisp'})).toBeInTheDocument(); + let button = within(tableTester.findCell({text: 'Apples Crisp'})).getByRole('button'); + expect(button).toHaveAttribute('aria-disabled', 'true'); + expect(button).toHaveFocus(); + + act(() => {jest.runAllTimers();}); + + expect(button).not.toHaveAttribute('aria-disabled'); + expect(button).toHaveFocus(); + }); + + it('should allow tabbing off a pending button', async () => { + let {getByRole} = render( + + ); + + let tableTester = testUtilUser.createTester('Table', {root: getByRole('grid')}); + await user.tab(); + await user.keyboard('{ArrowRight}'); + await user.keyboard('{Enter}'); + + let dialog = getByRole('dialog'); + expect(dialog).toBeVisible(); + + let input = within(dialog).getByRole('textbox'); + expect(input).toHaveFocus(); + + await user.keyboard('Apples Crisp'); + await user.keyboard('{Enter}'); // implicitly submit through form + + act(() => {jest.advanceTimersByTime(5000);}); + + expect(dialog).not.toBeInTheDocument(); + expect(tableTester.findRow({rowIndexOrText: 'Apples Crisp'})).toBeInTheDocument(); + let button = within(tableTester.findCell({text: 'Apples Crisp'})).getByRole('button'); + expect(button).toHaveAttribute('aria-disabled', 'true'); + expect(button).toHaveFocus(); + + await user.tab(); + expect(getByRole('button', {name: 'After'})).toHaveFocus(); + + act(() => {jest.runAllTimers();}); + + expect(button).not.toHaveAttribute('aria-disabled'); + }); + }); + }); + } }); diff --git a/packages/@react-stately/data/src/useListData.ts b/packages/@react-stately/data/src/useListData.ts index a705b413d19..82cd9063f63 100644 --- a/packages/@react-stately/data/src/useListData.ts +++ b/packages/@react-stately/data/src/useListData.ts @@ -123,9 +123,9 @@ export interface ListData { /** * Updates an item in the list. * @param key - The key of the item to update. - * @param newValue - The new value for the item. + * @param newValue - The new value for the item, or a function that returns the new value based on the previous value. */ - update(key: Key, newValue: T): void + update(key: Key, newValue: T | ((prev: T) => T)): void } export interface ListState { @@ -344,18 +344,25 @@ export function createListActions(opts: CreateListOptions, dispatch: return move(state, indices, toIndex + 1); }); }, - update(key: Key, newValue: T) { + update(key: Key, newValue: T | ((prev: T) => T)) { dispatch(state => { let index = state.items.findIndex(item => getKey!(item) === key); if (index === -1) { return state; } + let updatedValue: T; + if (typeof newValue === 'function') { + updatedValue = (newValue as (prev: T) => T)(state.items[index]); + } else { + updatedValue = newValue; + } + return { ...state, items: [ ...state.items.slice(0, index), - newValue, + updatedValue, ...state.items.slice(index + 1) ] }; diff --git a/packages/dev/s2-docs/pages/react-aria/FocusRing.mdx b/packages/dev/s2-docs/pages/react-aria/FocusRing.mdx index 2cc6456bcf8..e357743a85a 100644 --- a/packages/dev/s2-docs/pages/react-aria/FocusRing.mdx +++ b/packages/dev/s2-docs/pages/react-aria/FocusRing.mdx @@ -45,4 +45,6 @@ import './FocusRingExample.css'; ## API +### FocusRing + \ No newline at end of file diff --git a/packages/dev/s2-docs/pages/react-aria/FocusScope.mdx b/packages/dev/s2-docs/pages/react-aria/FocusScope.mdx index dd7a7e6b82a..e687fffb72d 100644 --- a/packages/dev/s2-docs/pages/react-aria/FocusScope.mdx +++ b/packages/dev/s2-docs/pages/react-aria/FocusScope.mdx @@ -120,7 +120,7 @@ function ToolbarButton(props) { ## API -### Props +### FocusScope diff --git a/packages/dev/s2-docs/pages/react-aria/I18nProvider.mdx b/packages/dev/s2-docs/pages/react-aria/I18nProvider.mdx index 15fb260507d..b0c5a8f9d0b 100644 --- a/packages/dev/s2-docs/pages/react-aria/I18nProvider.mdx +++ b/packages/dev/s2-docs/pages/react-aria/I18nProvider.mdx @@ -35,6 +35,8 @@ import {I18nProvider} from '@react-aria/i18n'; ``` -## Props +## API + +### I18nProvider diff --git a/packages/dev/s2-docs/pages/react-aria/PortalProvider.mdx b/packages/dev/s2-docs/pages/react-aria/PortalProvider.mdx index 55838cf9965..d6d45cb6115 100644 --- a/packages/dev/s2-docs/pages/react-aria/PortalProvider.mdx +++ b/packages/dev/s2-docs/pages/react-aria/PortalProvider.mdx @@ -166,7 +166,9 @@ function MyOverlay(props) { } ``` -## Props +## API + +### PortalProvider diff --git a/packages/dev/s2-docs/pages/react-aria/SSRProvider.mdx b/packages/dev/s2-docs/pages/react-aria/SSRProvider.mdx index 262f22460b7..11d26466884 100644 --- a/packages/dev/s2-docs/pages/react-aria/SSRProvider.mdx +++ b/packages/dev/s2-docs/pages/react-aria/SSRProvider.mdx @@ -36,6 +36,8 @@ import {SSRProvider} from '@react-aria/ssr'; ``` -## Props +## API + +### SSRProvider diff --git a/packages/dev/s2-docs/pages/react-aria/VisuallyHidden.mdx b/packages/dev/s2-docs/pages/react-aria/VisuallyHidden.mdx index e99bf515747..1c59d28d698 100644 --- a/packages/dev/s2-docs/pages/react-aria/VisuallyHidden.mdx +++ b/packages/dev/s2-docs/pages/react-aria/VisuallyHidden.mdx @@ -51,6 +51,8 @@ let {visuallyHiddenProps} = useVisuallyHidden(); ## API +### VisuallyHidden + {/* not implemented yet */} diff --git a/packages/dev/s2-docs/pages/react-aria/blog/ColorEditorExample.tsx b/packages/dev/s2-docs/pages/react-aria/blog/ColorEditorExample.tsx new file mode 100644 index 00000000000..2794f68a93d --- /dev/null +++ b/packages/dev/s2-docs/pages/react-aria/blog/ColorEditorExample.tsx @@ -0,0 +1,93 @@ +/* + * Copyright 2025 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +'use client'; +import {ColorPicker} from 'react-aria-components'; +import {ColorArea, ColorSlider, ColorField, ColorSwatch, Picker, PickerItem} from '@react-spectrum/s2'; +import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; +import {getColorChannels} from '@react-stately/color'; +import {useState} from 'react'; +import type {ColorSpace} from 'react-aria-components'; +// @ts-ignore +import intlMessages from './intl/*.json'; +import {useLocalizedStringFormatter} from '@react-aria/i18n'; + +interface ColorEditorProps { + hideAlphaChannel?: boolean; +} + +function ColorEditor({hideAlphaChannel = false}: ColorEditorProps) { + let [format, setFormat] = useState('hex'); + let formatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/color'); + + return ( +
+
+ + + {!hideAlphaChannel && ( + + )} +
+
+ setFormat(key as typeof format)}> + {formatter.format('hex')} + {formatter.format('rgb')} + {formatter.format('hsl')} + {formatter.format('hsb')} + + {format === 'hex' + ? + : getColorChannels(format).map(channel => ( + + ))} + {!hideAlphaChannel && ( + + )} +
+
+ ); +} + +export function ColorEditorExample() { + return ( +
+ + {({color}) => ( +
+ +
+ + {color.getColorName(navigator.language || 'en-US')} +
+
+ )} +
+
+ ); +} diff --git a/packages/dev/s2-docs/pages/react-aria/blog/DragBetweenListsExample.tsx b/packages/dev/s2-docs/pages/react-aria/blog/DragBetweenListsExample.tsx index 389bb92f226..1d5287652f2 100644 --- a/packages/dev/s2-docs/pages/react-aria/blog/DragBetweenListsExample.tsx +++ b/packages/dev/s2-docs/pages/react-aria/blog/DragBetweenListsExample.tsx @@ -85,7 +85,7 @@ function BidirectionalDnDListBox(props) { style={{width: 300, height: 300, overflow: 'auto'}}> {item => ( - {item.type === 'folder' ? : } + {item.type === 'folder' ? : } {item.name} )} diff --git a/packages/dev/s2-docs/pages/react-aria/blog/SubmenuAnimation.tsx b/packages/dev/s2-docs/pages/react-aria/blog/SubmenuAnimation.tsx index b4fb3b27dfa..2114063e38d 100644 --- a/packages/dev/s2-docs/pages/react-aria/blog/SubmenuAnimation.tsx +++ b/packages/dev/s2-docs/pages/react-aria/blog/SubmenuAnimation.tsx @@ -114,21 +114,33 @@ export function SubmenuAnimation(): JSX.Element { }, []); return ( -
- +
+
+ +
); } diff --git a/packages/dev/s2-docs/pages/react-aria/blog/accessible-color-descriptions.mdx b/packages/dev/s2-docs/pages/react-aria/blog/accessible-color-descriptions.mdx index 9f959fbb21b..27eceaf3178 100644 --- a/packages/dev/s2-docs/pages/react-aria/blog/accessible-color-descriptions.mdx +++ b/packages/dev/s2-docs/pages/react-aria/blog/accessible-color-descriptions.mdx @@ -17,6 +17,7 @@ export default Layout; import docs from 'docs:@react-spectrum/s2'; import React from 'react'; import {Byline} from '../../../src/BlogList'; +import {ColorEditorExample} from './ColorEditorExample'; export const tags = ['color picker', 'color', 'internationalization', 'localization', 'components', 'accessibility', 'react spectrum', 'react']; export const description = 'Recently, we released a suite of color picker components in React Aria and React Spectrum. Since colors are inherently visual, ensuring these components are accessible to users with visual impairments presented a significant challenge. In this post, we\'ll discuss how we developed an algorithm that generates clear color descriptions for screen readers in multiple languages, while minimizing bundle size.'; @@ -37,7 +38,7 @@ Accessibility is at the core of all of our work on the React Spectrum team, and Our initial implementation followed the typical ARIA patterns such as [slider](https://www.w3.org/WAI/ARIA/apg/patterns/slider/) to implement ColorArea, ColorSlider, and ColorWheel, and [listbox](https://www.w3.org/WAI/ARIA/apg/patterns/listbox/) to implement ColorSwatchPicker. This provided good support for mouse, touch, and keyboard input, but the screen reader experience left something to be desired. Out of the box, screen readers would only announce raw channel values like “Red: 182, Green: 96, Blue: 38”. I don’t know about you, but I can’t imagine what color that is just by hearing those numbers! -
@@ -127,18 +127,18 @@ After fixing the formatting, we also needed to update the keyboard navigation. P 3 . 11 - . + . 2020 , 8 - : + : 45
``` -Below is an example of a date field in Hebrew with the correct date format but incorrect keyboard navigation. Pressing the left arrow key should navigate you to the segment to the immediate left, while the right arrow key should navigate you to the segment to the immediate right. +Below is an example of a date field in Hebrew with the correct date format but incorrect keyboard navigation. Pressing the left arrow key should navigate you to the segment to the immediate left, while the right arrow key should navigate you to the segment to the immediate right. -
- + + {}} /> +
); @@ -186,7 +204,7 @@ function MobileTabPanel(props: Omit & {children: R ); } -export function MobileSearchMenu({pages, currentPage}) { +export function MobileSearchMenu({pages, currentPage}: {pages: Page[], currentPage: Page}) { return ( @@ -208,11 +226,10 @@ const MobileCustomDialog = function MobileCustomDialog(props: MobileDialogProps) ); }; -function MobileNav({pages, currentPage}: PageProps) { +function MobileNav({pages, currentPage}: {pages: Page[], currentPage: Page}) { let overlayTriggerState = useContext(OverlayTriggerStateContext); let [searchFocused, setSearchFocused] = useState(false); let [searchValue, setSearchValue] = useState(''); - let prevSearchWasEmptyRef = useRef(true); let scrollContainerRef = useRef(null); let [selectedLibrary, setSelectedLibrary] = useState(getLibraryFromPage(currentPage)); @@ -228,45 +245,8 @@ function MobileNav({pages, currentPage}: PageProps) { return sectionsMap; }, [pages]); - let currentLibrarySectionArray = useMemo(() => { - let librarySections = getSectionsForLibrary(selectedLibrary); - let sectionArray = [...librarySections.keys()]; - // Ensure order matches TagGroup: 'Components' first, then alphabetical - sectionArray.sort((a, b) => { - if (a === 'Components') { - return -1; - } - if (b === 'Components') { - return 1; - } - return a.localeCompare(b); - }); - return sectionArray; - }, [getSectionsForLibrary, selectedLibrary]); - - let [selectedSection, setSelectedSection] = useState(() => currentPage.exports?.section || currentLibrarySectionArray[0]); - - // Ensure selected section is valid for the current library - const sectionIds = searchValue.trim().length > 0 ? ['all', ...currentLibrarySectionArray] : currentLibrarySectionArray; - if (!selectedSection || !sectionIds.includes(selectedSection)) { - setSelectedSection(currentLibrarySectionArray[0] || 'Components'); - } - - let getOrderedLibraries = () => { - let allLibraries = (Object.keys(TAB_DEFS) as Library[]).map(id => ({id, label: TAB_DEFS[id].label, icon: TAB_DEFS[id].icon})); - let currentLibId = getLibraryFromPage(currentPage); - // Move current library to first position - let currentLibraryIndex = allLibraries.findIndex(lib => lib.id === currentLibId); - if (currentLibraryIndex > 0) { - let currentLib = allLibraries.splice(currentLibraryIndex, 1)[0]; - allLibraries.unshift(currentLib); - } - - return allLibraries; - }; - - let libraries = getOrderedLibraries(); + let libraries = useMemo(() => getOrderedLibraries(currentPage), [currentPage]); let handleSearchFocus = () => { setSearchFocused(true); @@ -285,36 +265,15 @@ function MobileNav({pages, currentPage}: PageProps) { } }; - let filterPages = (pages: any[], searchValue: string) => { - if (!searchValue.trim()) { - return pages; - } - - let searchLower = searchValue.toLowerCase(); - - // Filter items where name or tags start with search value - let matchedPages = pages.filter(page => { - let pageTitle = title(page).toLowerCase(); - let nameMatch = pageTitle.startsWith(searchLower); - let tags: string[] = page.exports?.tags || []; - let tagMatch = tags.some(tag => tag.toLowerCase().startsWith(searchLower)); - return nameMatch || tagMatch; - }); - - // Sort to prioritize name matches over tag matches - return matchedPages.sort((a, b) => { - let aNameMatch = title(a).toLowerCase().startsWith(searchLower); - let bNameMatch = title(b).toLowerCase().startsWith(searchLower); - - if (aNameMatch && !bNameMatch) { - return -1; + let filterPages = (pages: Page[], searchValue: string) => { + return filterAndSortSearchItems(pages, searchValue, { + getName: (page: Page) => getPageTitle(page), + getTags: (page: Page) => page.exports?.tags || [], + getDate: (page: Page) => page.exports?.date, + shouldUseDateSort: (page: Page) => { + const section = page.exports?.section; + return section === 'Blog' || section === 'Releases'; } - if (!aNameMatch && bNameMatch) { - return 1; - } - - // If both match by name or both match by tag, maintain original order - return 0; }); }; @@ -325,8 +284,8 @@ function MobileNav({pages, currentPage}: PageProps) { let filteredPages = filterPages(pages, searchValue); return filteredPages - .sort((a, b) => title(a).localeCompare(title(b))) - .map(page => ({id: page.url.replace(/^\//, ''), name: title(page), href: page.url, description: page.exports?.description})); + .sort((a, b) => getPageTitle(a).localeCompare(getPageTitle(b))) + .map(page => ({id: page.url.replace(/^\//, ''), name: getPageTitle(page), href: page.url, description: stripMarkdown(page.exports?.description)})); }; let getAllContent = (libraryId: string, searchValue: string = ''): ComponentCardItem[] => { @@ -334,8 +293,8 @@ function MobileNav({pages, currentPage}: PageProps) { let allPages = Array.from(librarySections.values()).flat(); let filteredPages = filterPages(allPages, searchValue); return filteredPages - .sort((a, b) => title(a).localeCompare(title(b))) - .map(page => ({id: page.url.replace(/^\//, ''), name: title(page), href: page.url, description: page.exports?.description})); + .sort((a, b) => getPageTitle(a).localeCompare(getPageTitle(b))) + .map(page => ({id: page.url.replace(/^\//, ''), name: getPageTitle(page), href: page.url, description: stripMarkdown(page.exports?.description)})); }; let getItemsForSelection = (section: string | undefined, libraryId: string, searchValue: string = ''): ComponentCardItem[] => { @@ -346,24 +305,20 @@ function MobileNav({pages, currentPage}: PageProps) { if (section === 'all') { items = getAllContent(libraryId, searchValue); } else { - items = getSectionContent(section, libraryId, searchValue); + // Check if this is a resource tag (e.g., icons) + const libraryResourceTags = getResourceTags(libraryId as Library); + const libraryResourceTagIds = libraryResourceTags.map(t => t.id); + if (libraryResourceTagIds.includes(section)) { + // Resources are handled separately, return empty for now + return []; + } + // Convert lowercase ID back to section name for getSectionContent + const librarySections = getSectionNamesForLibrary(libraryId); + const sectionName = librarySections.find(s => s.toLowerCase() === section) || section; + items = getSectionContent(sectionName, libraryId, searchValue); } - // Sort to show "Introduction" first when search is empty - if (searchValue.trim().length === 0) { - items = [...items].sort((a, b) => { - const aIsIntro = a.name === 'Introduction'; - const bIsIntro = b.name === 'Introduction'; - - if (aIsIntro && !bIsIntro) { - return -1; - } - if (!aIsIntro && bIsIntro) { - return 1; - } - return 0; - }); - } + items = sortItemsForDisplay(items, searchValue); return items; }; @@ -388,28 +343,52 @@ function MobileNav({pages, currentPage}: PageProps) { let currentLibrarySections = getSectionNamesForLibrary(selectedLibrary); - let tags = useMemo(() => { - let base = currentLibrarySections.map(name => ({id: name, name})); - if (searchValue.trim().length > 0) { - return [{id: 'all', name: 'All'}, ...base]; - } - return base; - }, [currentLibrarySections, searchValue]); + const sectionsForDisplay: Section[] = useMemo(() => { + return currentLibrarySections.map(name => ({ + id: name.toLowerCase(), + name, + children: [] + })); + }, [currentLibrarySections]); + + const initialSelectedSection = useMemo(() => { + const section = currentPage.exports?.section; + const firstSection = currentLibrarySections[0]?.toLowerCase() || 'components'; + return section ? section.toLowerCase() : firstSection; + }, [currentPage, currentLibrarySections]); + + const resourceTags = useMemo(() => getResourceTags(selectedLibrary), [selectedLibrary]); + + const [selectedSection, setSelectedSection] = useSearchTagSelection( + searchValue, + sectionsForDisplay.map(s => ({id: s.id, name: s.name})), + resourceTags, + initialSelectedSection + ); - // Auto-select All when search starts - useEffect(() => { - let isEmpty = searchValue.trim().length === 0; - if (prevSearchWasEmptyRef.current && !isEmpty) { - setSelectedSection('all'); - } - prevSearchWasEmptyRef.current = isEmpty; - }, [searchValue]); + const sectionTags = useSectionTagsForDisplay( + sectionsForDisplay, + searchValue, + selectedSection, + resourceTags.map(t => t.id) + ); + const filteredIcons = useFilteredIcons(searchValue); + const iconFilter = useIconFilter(); - let handleTagSelection = (keys: any) => { - let key = [...keys][0] as string; - setSelectedSection(key); - }; + let handleSectionSelectionChange = useCallback((keys: Iterable) => { + const firstKey = Array.from(keys)[0] as string; + if (firstKey) { + setSelectedSection(firstKey); + } + }, [setSelectedSection]); + + let handleResourceSelectionChange = useCallback((keys: Iterable) => { + const firstKey = Array.from(keys)[0] as string; + if (firstKey) { + setSelectedSection(firstKey); + } + }, [setSelectedSection]); useEffect(() => { if (scrollContainerRef.current) { @@ -430,7 +409,7 @@ function MobileNav({pages, currentPage}: PageProps) { if (!searchFocused) { let nextSections = getSectionNamesForLibrary(newLib); if (nextSections.length > 0) { - setSelectedSection(nextSections[0]); + setSelectedSection(nextSections[0].toLowerCase()); } } }}> @@ -446,62 +425,57 @@ function MobileNav({pages, currentPage}: PageProps) { ))}
- {libraries.map(library => ( - -
- -
- - {tag => {tag.name}} - -
-
-
- { - setSearchValue(''); - overlayTriggerState?.close(); - }} - items={getItemsForSelection(selectedSection, library.id, searchValue)} - ariaLabel="Pages" - size="S" - renderEmptyState={() => ( - - - No results - {searchValue.trim().length > 0 ? ( - - No results found for {searchValue} in {selectedLibrary}. - - ) : ( - - No results found in {selectedLibrary}. - - )} - - )} /> -
-
- ))} + {libraries.map(library => { + const isIconsSelected = selectedSection === 'icons' && library.id === 'react-spectrum'; + return ( + + +
+ +
+ +
+
+
+ {isIconsSelected ? ( + }> + + + ) : ( + { + setSearchValue(''); + overlayTriggerState?.close(); + }} + items={getItemsForSelection(selectedSection, library.id, searchValue)} + ariaLabel="Pages" + size="S" + renderEmptyState={() => } /> + )} +
+
+
+ ); + })} ); } -function title(page) { - return page.exports?.title ?? page.tableOfContents?.[0]?.title ?? page.name; -} diff --git a/packages/dev/s2-docs/src/Nav.tsx b/packages/dev/s2-docs/src/Nav.tsx index 6bfdc39b5c9..cd1c00c64d1 100644 --- a/packages/dev/s2-docs/src/Nav.tsx +++ b/packages/dev/s2-docs/src/Nav.tsx @@ -3,9 +3,35 @@ import {focusRing, size, style} from '@react-spectrum/s2/style' with {type: 'macro'}; import {getLibraryFromPage} from './library'; import {Link} from 'react-aria-components'; -import type {PageProps} from '@parcel/rsc'; +import type {Page, PageProps} from '@parcel/rsc'; import {Picker, pressScale} from '@react-spectrum/s2'; -import React, {createContext, useContext, useEffect, useRef, useState} from 'react'; +import React, {createContext, startTransition, useContext, useEffect, useOptimistic, useRef, useState} from 'react'; + +export function PendingPageProvider({children, currentPage}: {children: React.ReactNode, currentPage: Page}) { + let [displayPage, setDisplayPage] = useOptimistic( + currentPage, + (_, pendingPage: Page) => pendingPage + ); + + useEffect(() => { + const unsubscribe = subscribeToClearPendingPage(() => { + startTransition(() => { + setDisplayPage(currentPage); + }); + }); + return unsubscribe; + }, [currentPage, setDisplayPage]); + + let pendingPage = displayPage.url !== currentPage.url ? displayPage : null; + + return ( + + + {children} + + + ); +} export function Nav({pages, currentPage}: PageProps) { let currentLibrary = getLibraryFromPage(currentPage); @@ -30,6 +56,8 @@ export function Nav({pages, currentPage}: PageProps) { } let [maskSize, setMaskSize] = useState(0); + let pendingPage = usePendingPage(); + let displayUrl = pendingPage?.url ?? currentPage.url; let sortedSections = [...sections].sort((a, b) => { if (a[0] === 'Getting started') { @@ -54,7 +82,7 @@ export function Nav({pages, currentPage}: PageProps) { maxHeight: 'calc(100vh - 72px)', overflow: 'auto', paddingX: 12, - minWidth: 180, + width: 200, display: { default: 'none', lg: 'block' @@ -77,7 +105,7 @@ export function Nav({pages, currentPage}: PageProps) { }) .filter(page => !page.exports?.isSubpage) .map(page => ( - {title(page)} + {title(page)} ))} @@ -109,8 +137,27 @@ function SideNavSection({title, children}) { } const SideNavContext = createContext(''); +const PendingNavContext = createContext | null>(null); +const PendingPageContext = createContext(null); -export function SideNav({children}) { +let clearPendingPageListeners = new Set<() => void>(); + +function subscribeToClearPendingPage(callback: () => void): () => void { + clearPendingPageListeners.add(callback); + return () => { + void clearPendingPageListeners.delete(callback); + }; +} + +export function clearPendingPage() { + clearPendingPageListeners.forEach(callback => callback()); +} + +export function usePendingPage() { + return useContext(PendingPageContext); +} + +export function SideNav({children, isNested = false}) { return (
    ul)': 16 }, + paddingTop: { + default: 0, + isNested: 8 + }, margin: 0, display: 'flex', flexDirection: 'column', gap: 8, width: 'full', boxSizing: 'border-box' - })}> + })({isNested})}> {children}
); @@ -143,12 +194,22 @@ export function SideNavItem(props) { export function SideNavLink(props) { let linkRef = useRef(null); let selected = useContext(SideNavContext); + let setPendingPage = useContext(PendingNavContext); + let {page, ...linkProps} = props; + return ( { + if (setPendingPage && page) { + startTransition(() => { + setPendingPage(page); + }); + } + }} className={style({ ...focusRing(), minHeight: 32, diff --git a/packages/dev/s2-docs/src/NavigationSuspense.tsx b/packages/dev/s2-docs/src/NavigationSuspense.tsx new file mode 100644 index 00000000000..9cc4461df43 --- /dev/null +++ b/packages/dev/s2-docs/src/NavigationSuspense.tsx @@ -0,0 +1,118 @@ +'use client'; + +import type {Page} from '@parcel/rsc'; +import {PageSkeleton} from './PageSkeleton'; +import React, {Suspense, use, useSyncExternalStore} from 'react'; + +let navigationPromise: Promise | null = null; +let targetPathname: string | null = null; +let listeners = new Set<() => void>(); +let cachedSnapshot: {promise: Promise | null, pathname: string | null} = {promise: null, pathname: null}; + +function subscribe(callback: () => void) { + listeners.add(callback); + return () => listeners.delete(callback); +} + +function getSnapshot() { + if (cachedSnapshot.promise !== navigationPromise || cachedSnapshot.pathname !== targetPathname) { + cachedSnapshot = {promise: navigationPromise, pathname: targetPathname}; + } + return cachedSnapshot; +} + +export function setNavigationPromise(promise: Promise | null, pathname?: string) { + targetPathname = pathname || null; + navigationPromise = promise; + + if (promise) { + promise.finally(() => { + if (navigationPromise === promise) { + navigationPromise = null; + listeners.forEach(callback => callback()); + } + }); + } + + listeners.forEach(callback => callback()); +} + +function normalizePathname(urlOrPathname: string, publicUrlPrefix: string): string { + let pathname: string; + try { + if (urlOrPathname.startsWith('http://') || urlOrPathname.startsWith('https://')) { + pathname = new URL(urlOrPathname).pathname; + } else { + pathname = new URL(urlOrPathname, location.href).pathname; + } + } catch { + const [basePathname] = urlOrPathname.split('?'); + const [cleanPathname] = basePathname.split('#'); + pathname = cleanPathname; + } + + let pathnameWithoutPrefix = pathname; + if (publicUrlPrefix !== '/' && pathname.startsWith(publicUrlPrefix)) { + pathnameWithoutPrefix = pathname.slice(publicUrlPrefix.length); + if (!pathnameWithoutPrefix.startsWith('/')) { + pathnameWithoutPrefix = '/' + pathnameWithoutPrefix; + } + } + + return pathnameWithoutPrefix.startsWith('/') ? pathnameWithoutPrefix : '/' + pathnameWithoutPrefix; +} + +function getPageTitle(page: Page): string { + return page.exports?.title ?? page.tableOfContents?.[0]?.title ?? page.name; +} + +function getPageInfo(pages: Page[], pathname: string | null): {title?: string, section?: string, hasToC?: boolean} { + if (!pathname) { + return {}; + } + + let publicUrl = process.env.PUBLIC_URL || '/'; + let publicUrlPathname = publicUrl.startsWith('http') ? new URL(publicUrl).pathname : publicUrl; + let publicUrlPrefix = publicUrlPathname === '/' ? '/' : publicUrlPathname.replace(/\/$/, ''); + + let normalizedPathname = normalizePathname(pathname, publicUrlPrefix); + + const targetPage = pages.find(p => { + let normalizedPageUrl = normalizePathname(p.url, publicUrlPrefix); + + return normalizedPageUrl === normalizedPathname || + normalizedPageUrl === normalizedPathname.replace(/\.html$/, '') || + normalizedPageUrl === normalizedPathname + '.html'; + }); + + if (!targetPage) { + return {}; + } + + const title = getPageTitle(targetPage); + const section = (targetPage.exports?.section as string) || 'Components'; + const hasToC = !targetPage.exports?.hideNav && targetPage.tableOfContents?.[0]?.children && targetPage.tableOfContents?.[0]?.children?.length > 0; + + return {title, section, hasToC}; +} + +function NavigationContent({children}: {children: React.ReactNode}) { + // Subscribe to navigation promise changes to ensure React re-renders when setNavigationPromise() is called. + const snapshot = useSyncExternalStore(subscribe, getSnapshot, getSnapshot); + if (snapshot.promise) { + use(snapshot.promise); + } + return <>{children}; +} + +export function NavigationSuspense({children, pages}: {children: React.ReactNode, pages: Page[]}) { + // Subscribe to get the latest targetPathname for skeleton page info + const snapshot = useSyncExternalStore(subscribe, getSnapshot, getSnapshot); + const pageInfo = getPageInfo(pages, snapshot.pathname); + + return ( + }> + {children} + + ); +} diff --git a/packages/dev/s2-docs/src/OptimisticToc.tsx b/packages/dev/s2-docs/src/OptimisticToc.tsx new file mode 100644 index 00000000000..6a75d0c2135 --- /dev/null +++ b/packages/dev/s2-docs/src/OptimisticToc.tsx @@ -0,0 +1,77 @@ +'use client'; + +import {Divider, PickerItem} from '@react-spectrum/s2'; +import {MarkdownMenu} from './MarkdownMenu'; +import {MobileOnPageNav, OnPageNav, SideNav, SideNavItem, SideNavLink, usePendingPage} from './Nav'; +import type {Page, TocNode} from '@parcel/rsc'; +import React from 'react'; +import {ScrollableToc} from './ScrollableToc'; +import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; + +function anchorId(children) { + return children.replace(/\s/g, '-').replace(/[^a-zA-Z0-9-_]/g, '').toLowerCase(); +} + +function Toc({toc, isNested = false}: {toc: TocNode[], isNested?: boolean}) { + return ( + + + {toc.map((c, i) => ( + + {c.title} + {c.children.length > 0 && } + + ))} + + + ); +} + +function renderMobileToc(toc: TocNode[], seen = new Map()) { + return toc.map((c) => { + let href = c.level === 1 ? '#top' : '#' + anchorId(c.title); + if (seen.has(href)) { + seen.set(href, seen.get(href) + 1); + href += '-' + seen.get(href); + } else { + seen.set(href, 1); + } + return ( + {c.title} + {c.children.length > 0 && renderMobileToc(c.children, seen)} + ); + }); +} + +export function OptimisticToc({currentPage}: {currentPage: Page}) { + let pendingPage = usePendingPage(); + let displayPage = pendingPage ?? currentPage; + + return ( + <> +
On this page
+ + + +
+ + +
+ + ); +} + +export function OptimisticMobileToc({currentPage}: {currentPage: Page}) { + let pendingPage = usePendingPage(); + let displayPage = pendingPage ?? currentPage; + + if ((displayPage.tableOfContents?.[0]?.children?.length ?? 0) <= 1) { + return null; + } + + return ( + + {renderMobileToc(displayPage.tableOfContents ?? [])} + + ); +} diff --git a/packages/dev/s2-docs/src/PageSkeleton.tsx b/packages/dev/s2-docs/src/PageSkeleton.tsx new file mode 100644 index 00000000000..2c59cff79c6 --- /dev/null +++ b/packages/dev/s2-docs/src/PageSkeleton.tsx @@ -0,0 +1,128 @@ +'use client'; + +import {getTextWidth} from './textWidth'; +import React from 'react'; +import {Skeleton, Text} from '@react-spectrum/s2'; +import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; + +const h1 = style({ + font: 'heading-3xl', + fontSize: { + // On mobile, adjust heading to fit in the viewport, and clamp between a min and max font size. + default: 'clamp(35px, (100vw - 32px) / var(--width-per-em), 55px)', + lg: 'heading-3xl' + }, + marginY: 0 +}); + +const skeletonPageDescription = style({ + font: {default: 'body-lg', lg: 'body-xl'}, + marginY: 24, + width: '100%' +}); + +const skeletonParagraph = style({ + font: {default: 'body', lg: 'body-lg'}, + marginY: 24, + width: '100%' +}); + +const skeletonH2 = style({ + font: 'heading-xl', + marginTop: 48, + marginBottom: 16, + width: '40%' +}); + +const skeletonVisualExample = style({ + backgroundColor: 'layer-1', + padding: { + default: 12, + lg: 24 + }, + marginTop: { + default: 20 + }, + borderRadius: 'xl', + width: 'full', + boxSizing: 'border-box', + minHeight: { + default: 200, + lg: 300 + } +}); + +function SkeletonVisualExample() { + return ( +
+ ); +} + +const skeletonArticle = style({ + maxWidth: { + default: 'none', + isWithToC: 768 + }, + width: 'full', + height: 'fit' +}); + +export function PageSkeleton({title, section, hasToC}: {title?: string, section?: string, hasToC?: boolean}) { + const isComponents = section === 'Components'; + + return ( +
+ {title && ( +

+ {title} +

+ )} + + {!title && ( +

+ Page Title +

+ )} + + {/* PageDescription */} +

+ This is placeholder content for the page description that approximates the typical length of component descriptions. +

+ + {isComponents ? ( + <> + {/* VisualExample */} + + + {/* A few sections with visual examples */} + {[1, 2, 3].map(i => ( + +

+ Section Heading +

+

+ Placeholder content for a section that describes various aspects of the component or feature being documented. +

+ +
+ ))} + + ) : ( + <> + {/* A few sections */} + {[1, 2, 3, 4].map(i => ( + +

+ Section Heading +

+

+ Placeholder content for a section that describes various aspects of the topic being documented. +

+
+ ))} + + )} +
+
+ ); +} diff --git a/packages/dev/s2-docs/src/ReleasesList.tsx b/packages/dev/s2-docs/src/ReleasesList.tsx index 2874614d850..ac43d7829a0 100644 --- a/packages/dev/s2-docs/src/ReleasesList.tsx +++ b/packages/dev/s2-docs/src/ReleasesList.tsx @@ -9,7 +9,7 @@ export function ReleasesList({pages}: {pages: Page[]}) { return new Date(b.exports?.date).getTime() - new Date(a.exports?.date).getTime(); }); return ( -
+
{releases.map(release => (
@@ -19,7 +19,7 @@ export function ReleasesList({pages}: {pages: Page[]}) {

{renderHTMLfromMarkdown(release.exports?.description, {})}

))} -

For all previous releases or React Spectrum v3, see the Archived releases page.

+

For all previous releases of React Spectrum v3, see the Archived releases page.

); } diff --git a/packages/dev/s2-docs/src/ScrollableToc.tsx b/packages/dev/s2-docs/src/ScrollableToc.tsx new file mode 100644 index 00000000000..514c0d0a278 --- /dev/null +++ b/packages/dev/s2-docs/src/ScrollableToc.tsx @@ -0,0 +1,60 @@ +'use client'; + +import React, {useEffect, useRef, useState} from 'react'; +import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; + +export function ScrollableToc({children}) { + let [topMaskSize, setTopMaskSize] = useState(0); + let [bottomMaskSize, setBottomMaskSize] = useState(0); + let scrollRef = useRef(null); + + let updateMasks = (element: HTMLDivElement) => { + let scrollTop = element.scrollTop; + let scrollHeight = element.scrollHeight; + let clientHeight = element.clientHeight; + let distanceFromBottom = scrollHeight - scrollTop - clientHeight; + + setTopMaskSize(Math.min(scrollTop, 32)); + setBottomMaskSize(Math.min(distanceFromBottom, 32)); + }; + + useEffect(() => { + if (scrollRef.current) { + updateMasks(scrollRef.current); + } + }, [children]); + + let maskImage: string | undefined; + if (topMaskSize > 0 || bottomMaskSize > 0) { + let parts: string[] = []; + if (topMaskSize > 0) { + parts.push('transparent 0px'); + parts.push(`black ${topMaskSize}px`); + } else { + parts.push('black 0px'); + } + if (bottomMaskSize > 0) { + parts.push(`black calc(100% - ${bottomMaskSize}px)`); + parts.push('transparent 100%'); + } else { + parts.push('black 100%'); + } + maskImage = `linear-gradient(to bottom, ${parts.join(', ')})`; + } + + return ( +
updateMasks(e.currentTarget)} + style={{ + maskImage + }} + className={style({ + overflowY: 'auto', + flex: 1, + minHeight: 0 + })}> + {children} +
+ ); +} diff --git a/packages/dev/s2-docs/src/SearchMenu.tsx b/packages/dev/s2-docs/src/SearchMenu.tsx index cd388e8ced7..e1674bceb79 100644 --- a/packages/dev/s2-docs/src/SearchMenu.tsx +++ b/packages/dev/s2-docs/src/SearchMenu.tsx @@ -1,22 +1,38 @@ '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 {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 {ComponentCardView} from './ComponentCardView'; +import { + type ComponentItem, + createSearchOptions, + filterAndSortSearchItems, + getOrderedLibraries, + getPageTitle, + getResourceTags, + SearchEmptyState, + sortItemsForDisplay, + sortSearchItems, + useFilteredIcons, + useSearchTagSelection, + useSectionTagsForDisplay +} from './searchUtils'; import {getLibraryFromPage, getLibraryFromUrl} from './library'; -import {iconList, IconSearchSkeleton, useIconFilter} from './IconSearchView'; +import {IconSearchSkeleton, useIconFilter} from './IconSearchView'; import {type Library, TAB_DEFS} from './constants'; -// eslint-disable-next-line monorepo/no-internal-import -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 {SelectableCollectionContext} from '../../../react-aria-components/src/RSPContexts'; +import {SearchTagGroups} from './SearchTagGroups'; import {style} from '@react-spectrum/s2/style' with { type: 'macro' }; import {Tab, TabList, TabPanel, Tabs} from './Tabs'; import {TextFieldRef} from '@react-types/textfield'; +export function stripMarkdown(description: string | undefined) { + return (description || '').replace(/\[(.*?)\]\(.*?\)/g, '$1'); +} + export const divider = style({ marginY: 8, marginStart: -8, @@ -62,15 +78,7 @@ export function SearchMenu(props: SearchMenuProps) { let [selectedLibrary, setSelectedLibrary] = useState(currentLibrary); let [searchValue, setSearchValue] = useState(props.initialSearchValue); - const orderedTabs = useMemo(() => { - const allTabs = (Object.keys(TAB_DEFS) as Library[]).map(id => ({id, ...TAB_DEFS[id]})); - const currentTabIndex = allTabs.findIndex(tab => tab.id === currentLibrary); - if (currentTabIndex > 0) { - const currentTab = allTabs.splice(currentTabIndex, 1)[0]; - allTabs.unshift(currentTab); - } - return allTabs; - }, [currentLibrary]); + const orderedTabs = useMemo(() => getOrderedLibraries(currentPage), [currentPage]); const searchRef = useRef | null>(null); @@ -93,10 +101,10 @@ export function SearchMenu(props: SearchMenuProps) { .filter(page => page.url && page.url.endsWith('.html') && getLibraryFromUrl(page.url) === selectedLibrary && !page.exports?.hideFromSearch) .map(page => { const name = page.url.replace(/^\//, '').replace(/\.html$/, ''); - const title = page.tableOfContents?.[0]?.title || name; + const title = getPageTitle(page); const section: string = (page.exports?.section as string) || 'Components'; const tags: string[] = (page.exports?.tags || page.exports?.keywords as string[]) || []; - const description: string = page.exports?.description; + const description: string = stripMarkdown(page.exports?.description); const date: string | undefined = page.exports?.date; return { id: name, @@ -130,84 +138,27 @@ export function SearchMenu(props: SearchMenuProps) { }); }, [transformedComponents]); - const sectionTags = useMemo(() => sections, [sections]); - const iconTag = useMemo(() => { - if (selectedLibrary === 'react-spectrum') { - return [{id: 'icons', name: 'Icons'}]; - } - return []; - }, [selectedLibrary]); + const sectionTags = useMemo(() => sections.map(s => ({id: s.id, name: s.name})), [sections]); + const resourceTags = useMemo(() => getResourceTags(selectedLibrary), [selectedLibrary]); - const [selectedSectionId, setSelectedSectionId] = useState(() => currentPage.exports?.section?.toLowerCase() || 'components'); - const prevSearchWasEmptyRef = useRef(true); + const [selectedTagId, setSelectedTagId] = useSearchTagSelection( + searchValue, + sectionTags, + resourceTags, + currentPage.exports?.section?.toLowerCase() || 'components' + ); + const filteredIcons = useFilteredIcons(searchValue); const iconFilter = useIconFilter(); - const filteredIcons = useMemo(() => { - if (!searchValue.trim()) { - return iconList; - } - return iconList.filter(item => iconFilter(item.id, searchValue)); - }, [searchValue, iconFilter]); - - // 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; - if (!selectedSectionId || !sectionIds.includes(selectedSectionId)) { - setSelectedSectionId(sectionIds[0] || 'components'); - } - - // When search starts, auto-select the All tag (unless Icons is selected). - useEffect(() => { - const isEmpty = searchValue.trim().length === 0; - if (prevSearchWasEmptyRef.current && !isEmpty && selectedSectionId !== 'icons') { - setSelectedSectionId('all'); - } - prevSearchWasEmptyRef.current = isEmpty; - }, [searchValue, selectedSectionId]); - let filteredComponents = useMemo(() => { if (!searchValue) { return sections; } - const searchLower = searchValue.toLowerCase(); const allItems = sections.flatMap(section => section.children); - // Filter items where name or tags start with search value - const matchedItems = allItems.filter(item => { - const nameMatch = item.name.toLowerCase().startsWith(searchLower); - const tagMatch = item.tags.some(tag => tag.toLowerCase().startsWith(searchLower)); - return nameMatch || tagMatch; - }); - - // Sort to prioritize name matches over tag matches - const sortedItems = matchedItems.sort((a, b) => { - const aNameMatch = a.name.toLowerCase().startsWith(searchLower); - const bNameMatch = b.name.toLowerCase().startsWith(searchLower); - - if (a.date && b.date) { - let aDate = new Date(a.date); - let bDate = new Date(b.date); - return bDate.getTime() - aDate.getTime(); - } else if (a.date && !b.date) { - return 1; - } else if (!a.date && b.date) { - return -1; - } - - if (aNameMatch && !bNameMatch) { - return -1; - } - if (!aNameMatch && bNameMatch) { - return 1; - } - - // If both match by name or both match by tag, maintain original order - return 0; - }); + const sortedItems = filterAndSortSearchItems(allItems, searchValue, createSearchOptions()); const resultsBySection = new Map(); @@ -227,13 +178,12 @@ export function SearchMenu(props: SearchMenuProps) { .filter(section => section.children.length > 0); }, [sections, searchValue]); - const tags = useMemo(() => { - if (searchValue.trim().length > 0 && selectedSectionId !== 'icons') { - // When searching, prepend an All tag (unless Icons is selected) - return [{id: 'all', name: 'All'}, ...sectionTags]; - } - return sectionTags; - }, [searchValue, sectionTags, selectedSectionId]); + const sectionTagsForDisplay = useSectionTagsForDisplay( + sections, + searchValue, + selectedTagId, + resourceTags.map(t => t.id) + ); const handleTabSelectionChange = React.useCallback((key: Key) => { if (searchValue) { @@ -253,62 +203,50 @@ export function SearchMenu(props: SearchMenuProps) { const handleSectionSelectionChange = React.useCallback((keys: Iterable) => { const firstKey = Array.from(keys)[0] as string; if (firstKey) { - setSelectedSectionId(firstKey); + setSelectedTagId(firstKey); } - }, []); + }, [setSelectedTagId]); const handleIconSelectionChange = React.useCallback((keys: Iterable) => { const firstKey = Array.from(keys)[0] as string; if (firstKey) { - setSelectedSectionId(firstKey); + setSelectedTagId(firstKey); } - }, []); + }, [setSelectedTagId]); const selectedItems = useMemo(() => { let items: typeof transformedComponents = []; - if (searchValue.trim().length > 0 && selectedSectionId === 'all') { + if (searchValue.trim().length > 0 && selectedTagId === 'all') { items = filteredComponents.flatMap(s => s.children) || []; + items = sortSearchItems(items, searchValue, createSearchOptions()); } else { - items = (filteredComponents.find(s => s.id === selectedSectionId)?.children) || []; - } - - // Sort to show "Introduction" first when search is empty - if (searchValue.trim().length === 0) { - items = [...items].sort((a, b) => { - const aIsIntro = a.name === 'Introduction'; - const bIsIntro = b.name === 'Introduction'; - - if (a.date && b.date) { - let aDate = new Date(a.date); - let bDate = new Date(b.date); - return bDate.getTime() - aDate.getTime(); - } else if (a.date && !b.date) { - return 1; - } else if (!a.date && b.date) { - return -1; - } - - if (aIsIntro && !bIsIntro) { - return -1; - } - if (!aIsIntro && bIsIntro) { - return 1; - } - return 0; - }); + items = (filteredComponents.find(s => s.id === selectedTagId)?.children) || []; + items = sortItemsForDisplay(items, searchValue); } return items; - }, [filteredComponents, selectedSectionId, searchValue]); + }, [filteredComponents, selectedTagId, searchValue]); const selectedSectionName = useMemo(() => { - if (searchValue.trim().length > 0 && selectedSectionId === 'all') { + if (searchValue.trim().length > 0 && selectedTagId === 'all') { return 'All'; } - return (filteredComponents.find(s => s.id === selectedSectionId)?.name) - || (sections.find(s => s.id === selectedSectionId)?.name) + return (filteredComponents.find(s => s.id === selectedTagId)?.name) + || (sections.find(s => s.id === selectedTagId)?.name) || 'Items'; - }, [filteredComponents, sections, selectedSectionId, searchValue]); + }, [filteredComponents, sections, selectedTagId, searchValue]); + + useEffect(() => { + const handleNavigationStart = () => { + setSearchValue(''); + onClose(); + }; + + window.addEventListener('rsc-navigation-start', handleNavigationStart); + return () => { + window.removeEventListener('rsc-navigation-start', handleNavigationStart); + }; + }, [onClose]); return ( @@ -336,10 +274,10 @@ export function SearchMenu(props: SearchMenuProps) { ))} {orderedTabs.map((tab, i) => { - const tabIconTag = tab.id === 'react-spectrum' ? [{id: 'icons', name: 'Icons'}] : []; + const tabResourceTags = getResourceTags(tab.id); return ( - +
- {(tags.length > 0 || tabIconTag.length > 0) && ( -
- -
- {tags.length > 0 && ( - - {(tag) => ( - - {tag.name} - - )} - - )} - {tabIconTag.length > 0 && tags.length > 0 && ( - - )} - {tabIconTag.length > 0 && ( - - {(tag) => ( - - {tag.name} - - )} - - )} -
-
+ + {selectedTagId === 'icons' ? ( +
+ }> + +
- )} - {selectedSectionId === 'icons' ? ( - }> - - ) : ( ( - - - No results - {searchValue.trim().length > 0 ? ( - - No results found for {searchValue} in {tab.label}. - - ) : ( - - No results found in {tab.label}. - - )} - - )} /> + renderEmptyState={() => } /> )}
diff --git a/packages/dev/s2-docs/src/SearchTagGroups.tsx b/packages/dev/s2-docs/src/SearchTagGroups.tsx new file mode 100644 index 00000000000..54c8f6d05ea --- /dev/null +++ b/packages/dev/s2-docs/src/SearchTagGroups.tsx @@ -0,0 +1,91 @@ +import {divider} from './SearchMenu'; +import {Key, Separator as RACSeparator} from 'react-aria-components'; +import React from 'react'; +import {SelectableCollectionContext} from '../../../react-aria-components/src/RSPContexts'; +import {style} from '@react-spectrum/s2/style' with { type: 'macro' }; +import {Tag, TagGroup} from '@react-spectrum/s2'; + +interface TagItem { + id: string, + name: string +} + +interface SearchTagGroupsProps { + sectionTags: TagItem[], + resourceTags?: TagItem[], + selectedTagId: string | undefined, + onSectionSelectionChange: (keys: Iterable) => void, + onResourceSelectionChange?: (keys: Iterable) => void, + isMobile?: boolean, + wrapperClassName?: string, + contentClassName?: string +} + +export function SearchTagGroups({ + sectionTags, + resourceTags = [], + selectedTagId, + onSectionSelectionChange, + onResourceSelectionChange, + isMobile = false, + wrapperClassName, + contentClassName +}: SearchTagGroupsProps) { + if (sectionTags.length === 0 && resourceTags.length === 0) { + return null; + } + + const defaultWrapperClassName = style({flexShrink: 0, zIndex: 1, paddingTop: 16}); + const defaultContentClassName = style({display: 'flex', flexDirection: 'row', alignItems: 'center', gap: 8, marginX: 16}); + + const resourceTagIds = resourceTags.map(tag => tag.id); + const isResourceSelected = selectedTagId && resourceTagIds.includes(selectedTagId); + + return ( +
+ +
+ {sectionTags.length > 0 && ( +
+ + {(tag) => ( + + {tag.name} + + )} + +
+ )} + {resourceTags.length > 0 && sectionTags.length > 0 && ( + + )} + {resourceTags.length > 0 && ( +
+ + {(tag) => ( + + {tag.name} + + )} + +
+ )} +
+
+
+ ); +} diff --git a/packages/dev/s2-docs/src/client.tsx b/packages/dev/s2-docs/src/client.tsx index a955a049d6f..0fe33508021 100644 --- a/packages/dev/s2-docs/src/client.tsx +++ b/packages/dev/s2-docs/src/client.tsx @@ -1,7 +1,24 @@ 'use client-entry'; +import {clearPendingPage} from './Nav'; import {fetchRSC, hydrate} from '@parcel/rsc/client'; -import type {ReactElement} from 'react'; +import {getPrefetchedPromise, prefetchRoute} from './prefetch'; +import {type ReactElement} from 'react'; +import {setNavigationPromise} from './NavigationSuspense'; +import {UNSTABLE_ToastQueue as ToastQueue} from '@react-spectrum/s2'; + +let isClientLink = (link: HTMLAnchorElement, pathname: string) => { + return ( + link && + link instanceof HTMLAnchorElement && + link.href && + (!link.target || link.target === '_self') && + link.origin === location.origin && + link.pathname !== location.pathname && + !link.hasAttribute('download') && + link.pathname.startsWith(pathname) + ); +}; // Hydrate initial RSC payload embedded in the HTML. let updateRoot = hydrate({ @@ -11,41 +28,181 @@ let updateRoot = hydrate({ } }); +// Track the current navigation to prevent race conditions +let currentNavigationId = 0; +let currentAbortController: AbortController | null = null; + // A very simple router. When we navigate, we'll fetch a new RSC payload from the server, // and in a React transition, stream in the new page. Once complete, we'll pushState to // update the URL in the browser. async function navigate(pathname: string, push = false) { - try { - let res = await fetchRSC(pathname.replace('.html', '.rsc')); - let currentPath = location.pathname; - let [newBasePath, newPathAnchor] = pathname.split('#'); - - updateRoot(res, () => { - if (push) { - history.pushState(null, '', pathname); - push = false; + let [basePath] = pathname.split('#'); + let rscPath = basePath.replace('.html', '.rsc'); + + // Cancel any in-flight navigation + if (currentAbortController) { + currentAbortController.abort('Aborting due to new navigation'); + } + + // Create a new abort controller for this navigation + const abortController = new AbortController(); + currentAbortController = abortController; + const navigationId = ++currentNavigationId; + + const navigationPromise = (async () => { + window.dispatchEvent(new CustomEvent('rsc-navigation-start')); + + // Use prefetched result if available, otherwise fetch + const prefetchedPromise = getPrefetchedPromise(rscPath); + const fetchPromise = prefetchedPromise ?? fetchRSC(rscPath); + + try { + let res = await fetchPromise; + + // Check if this navigation is still current before updating + if (navigationId !== currentNavigationId) { + // A newer navigation has started, ignore this result + return; + } + + // Check if this navigation was aborted + if (abortController.signal.aborted) { + return; } + + let currentPath = location.pathname; + let [newBasePath, newPathAnchor] = pathname.split('#'); - // Reset scroll if navigating to a different page without an anchor - if (currentPath !== newBasePath && !newPathAnchor) { - window.scrollTo(0, 0); - } else if (newPathAnchor) { - let element = document.getElementById(newPathAnchor); - if (element) { - element.scrollIntoView(); - } + // Return a promise that resolves after updateRoot callback completes + await new Promise((resolve) => { + updateRoot(res, () => { + if (push) { + history.pushState(null, '', pathname); + push = false; + } + + // Reset scroll if navigating to a different page without an anchor + if (currentPath !== newBasePath && !newPathAnchor) { + window.scrollTo(0, 0); + } else if (newPathAnchor) { + let element = document.getElementById(newPathAnchor); + if (element) { + element.scrollIntoView(); + } + } + + queueMicrotask(() => { + window.dispatchEvent(new CustomEvent('rsc-navigation')); + resolve(); + }); + }); + }); + } catch (error) { + // Check if this navigation was aborted + if (abortController.signal.aborted) { + return; } - }); - } catch { - let errorRes = await fetchRSC('/error.rsc'); - updateRoot(errorRes, () => { - if (push) { - history.pushState(null, '', '/error.html'); + + // Check if this navigation is still current + if (navigationId !== currentNavigationId) { + return; + } + + clearPendingPage(); + try { + let errorRes = await fetchRSC('/error.rsc'); + + // Check again if still current after error fetch + if (navigationId !== currentNavigationId || abortController.signal.aborted) { + return; + } + + await new Promise((resolve) => { + updateRoot(errorRes, () => { + if (push) { + history.pushState(null, '', '/error.html'); + } + resolve(); + }); + }); + } catch { + // Only show error toast if this is still the current navigation + if (navigationId === currentNavigationId && !abortController.signal.aborted) { + ToastQueue.negative('Failed to load page. Check your connection and try again.'); + } + throw error; // Re-throw to keep promise rejected } - }); + } + })(); + + // Store the promise for NavigationSuspense to use + setNavigationPromise(navigationPromise, pathname); +} + +// Prefetch routes on pointerover +// Use a delay to avoid prefetching when quickly moving over multiple links. +const PREFETCH_DELAY_MS = 100; +let prefetchTimeout: ReturnType | null = null; +let currentPrefetchLink: HTMLAnchorElement | null = null; + +function clearPrefetchTimeout() { + if (prefetchTimeout) { + clearTimeout(prefetchTimeout); + prefetchTimeout = null; } + currentPrefetchLink = null; } +document.addEventListener('pointerover', e => { + let link = (e.target as Element).closest('a'); + let publicUrl = process.env.PUBLIC_URL || '/'; + let publicUrlPathname = publicUrl.startsWith('http') ? new URL(publicUrl).pathname : publicUrl; + + // Clear any pending prefetch + clearPrefetchTimeout(); + + if (link && isClientLink(link, publicUrlPathname)) { + currentPrefetchLink = link; + prefetchTimeout = setTimeout(() => { + prefetchRoute(link.pathname + link.search + link.hash); + prefetchTimeout = null; + }, PREFETCH_DELAY_MS); + } +}, true); + +// Clear prefetch timeout when pointer leaves a link +document.addEventListener('pointerout', e => { + let link = (e.target as Element).closest('a'); + if (link && link === currentPrefetchLink) { + clearPrefetchTimeout(); + } +}, true); + +document.addEventListener('focus', e => { + let link = (e.target as Element).closest('a'); + let publicUrl = process.env.PUBLIC_URL || '/'; + let publicUrlPathname = publicUrl.startsWith('http') ? new URL(publicUrl).pathname : publicUrl; + + // Clear any pending prefetch + clearPrefetchTimeout(); + + if (link && isClientLink(link, publicUrlPathname)) { + currentPrefetchLink = link; + prefetchTimeout = setTimeout(() => { + prefetchRoute(link.pathname + link.search + link.hash); + prefetchTimeout = null; + }, PREFETCH_DELAY_MS); + } +}, true); + +// Clear prefetch timeout when focus leaves a link +document.addEventListener('blur', e => { + let link = (e.target as Element).closest('a'); + if (link && link === currentPrefetchLink) { + clearPrefetchTimeout(); + } +}, true); + // Intercept link clicks to perform RSC navigation. document.addEventListener('click', e => { let link = (e.target as Element).closest('a'); @@ -53,13 +210,7 @@ document.addEventListener('click', e => { let publicUrlPathname = publicUrl.startsWith('http') ? new URL(publicUrl).pathname : publicUrl; if ( link && - link instanceof HTMLAnchorElement && - link.href && - (!link.target || link.target === '_self') && - link.origin === location.origin && - link.pathname !== location.pathname && - !link.hasAttribute('download') && - link.pathname.startsWith(publicUrlPathname) && + isClientLink(link, publicUrlPathname) && e.button === 0 && // left clicks only !e.metaKey && // open in new tab (mac) !e.ctrlKey && // open in new tab (windows) diff --git a/packages/dev/s2-docs/src/footer.css b/packages/dev/s2-docs/src/footer.css new file mode 100644 index 00000000000..f279bcafd6b --- /dev/null +++ b/packages/dev/s2-docs/src/footer.css @@ -0,0 +1,8 @@ +footer li:after { + margin: 0 8px; + content: '/'; +} + +footer li:last-child:after { + content: ''; +} diff --git a/packages/dev/s2-docs/src/prefetch.ts b/packages/dev/s2-docs/src/prefetch.ts new file mode 100644 index 00000000000..fe3425e5e31 --- /dev/null +++ b/packages/dev/s2-docs/src/prefetch.ts @@ -0,0 +1,34 @@ +'use client'; + +import {fetchRSC} from '@parcel/rsc/client'; +import {type ReactElement} from 'react'; + +const prefetchPromises = new Map>(); + +export function prefetchRoute(pathname: string) { + let [basePath] = pathname.split('#'); + let rscPath = basePath.replace('.html', '.rsc'); + + // Skip if currently prefetching + if (prefetchPromises.has(rscPath)) { + return; + } + + // Start prefetch and cache the promise + const prefetchPromise = fetchRSC(rscPath) + .then(res => { + // Remove from cache once resolved (rely on browser cache for subsequent requests) + prefetchPromises.delete(rscPath); + return res; + }) + .catch(() => { + prefetchPromises.delete(rscPath); + return Promise.reject(new Error('Prefetch failed')); + }); + + prefetchPromises.set(rscPath, prefetchPromise); +} + +export function getPrefetchedPromise(rscPath: string): Promise | undefined { + return prefetchPromises.get(rscPath); +} diff --git a/packages/dev/s2-docs/src/searchUtils.tsx b/packages/dev/s2-docs/src/searchUtils.tsx new file mode 100644 index 00000000000..5374e4af6f1 --- /dev/null +++ b/packages/dev/s2-docs/src/searchUtils.tsx @@ -0,0 +1,315 @@ +'use client'; + +import {Content, Heading, IllustratedMessage} from '@react-spectrum/s2'; +import {getLibraryFromPage} from './library'; +// @ts-ignore +import {iconList, useIconFilter} from './IconSearchView'; +import {type Library, TAB_DEFS} from './constants'; +// eslint-disable-next-line monorepo/no-internal-import +import NoSearchResults from '@react-spectrum/s2/illustrations/linear/NoSearchResults'; +// @ts-ignore +import {Page} from '@parcel/rsc'; +import React, {useEffect, useMemo, useRef, useState} from 'react'; +import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; + +export interface SearchableItem { + name: string, + tags: string[], + date?: string +} + +export interface ComponentItem { + id: string, + name: string, + href: string, + section: string, + tags: string[], + description?: string, + date?: string +} + +export interface Section { + id: string, + name: string, + children: ComponentItem[] +} + +export interface Tag { + id: string, + name: string +} + +export interface SearchOptions { + /** + * Function to extract the name from an item. + */ + getName: (item: T) => string, + /** + * Function to extract tags from an item. + */ + getTags: (item: T) => string[], + /** + * Optional function to extract date from an item for date-based sorting. + */ + getDate?: (item: T) => string | undefined, + /** + * Optional function to determine if an item should use date-based sorting. + * If not provided, all items use alphabetical sorting. + */ + shouldUseDateSort?: (item: T) => boolean +} + +export function filterSearchItems( + items: T[], + searchValue: string, + options: SearchOptions +): T[] { + if (!searchValue.trim()) { + return items; + } + + const searchLower = searchValue.toLowerCase(); + const {getName, getTags} = options; + + return items.filter(item => { + const name = getName(item).toLowerCase(); + const tags = getTags(item); + const nameMatch = name.includes(searchLower); + const tagMatch = tags.some(tag => tag.toLowerCase().includes(searchLower)); + return nameMatch || tagMatch; + }); +} + +/** + * Sorts items to prioritize `startsWith` matches, then `includes` matches, then tag matches. + */ +export function sortSearchItems( + items: T[], + searchValue: string, + options: SearchOptions +): T[] { + if (!searchValue.trim()) { + return items; + } + + const searchLower = searchValue.toLowerCase(); + const {getName, getDate, shouldUseDateSort} = options; + + return [...items].sort((a, b) => { + const aName = getName(a).toLowerCase(); + const bName = getName(b).toLowerCase(); + const aNameStartsWith = aName.startsWith(searchLower); + const bNameStartsWith = bName.startsWith(searchLower); + const aNameIncludes = aName.includes(searchLower); + const bNameIncludes = bName.includes(searchLower); + + // Check if either item should use date sorting + const aUseDateSort = shouldUseDateSort ? shouldUseDateSort(a) : false; + const bUseDateSort = shouldUseDateSort ? shouldUseDateSort(b) : false; + const bothUseDateSort = aUseDateSort && bUseDateSort; + + // Prioritize startsWith matches + if (aNameStartsWith && !bNameStartsWith) { + return -1; + } + if (!aNameStartsWith && bNameStartsWith) { + return 1; + } + + // If both start with, sort by date (if both use date sort) or alphabetically + if (aNameStartsWith && bNameStartsWith) { + if (bothUseDateSort && getDate) { + const aDate = getDate(a); + const bDate = getDate(b); + if (aDate && bDate) { + return new Date(bDate).getTime() - new Date(aDate).getTime(); + } else if (aDate && !bDate) { + return 1; + } else if (!aDate && bDate) { + return -1; + } + } + return aName.localeCompare(bName); + } + + // Prioritize includes matches over tag matches + if (aNameIncludes && !bNameIncludes) { + return -1; + } + if (!aNameIncludes && bNameIncludes) { + return 1; + } + + // If both match by name (includes), sort by date (if both use date sort) or alphabetically + if (aNameIncludes && bNameIncludes) { + if (bothUseDateSort && getDate) { + const aDate = getDate(a); + const bDate = getDate(b); + if (aDate && bDate) { + return new Date(bDate).getTime() - new Date(aDate).getTime(); + } else if (aDate && !bDate) { + return 1; + } else if (!aDate && bDate) { + return -1; + } + } + return aName.localeCompare(bName); + } + + // If both match by tag, maintain original order + return 0; + }); +} + +export function filterAndSortSearchItems( + items: T[], + searchValue: string, + options: SearchOptions +): T[] { + const filtered = filterSearchItems(items, searchValue, options); + return sortSearchItems(filtered, searchValue, options); +} + +export function getPageTitle(page: Page): string { + return page.exports?.title ?? page.tableOfContents?.[0]?.title ?? page.name; +} + +export function getOrderedLibraries(currentPage: Page) { + const allLibraries = (Object.keys(TAB_DEFS) as Library[]).map(id => ({id, ...TAB_DEFS[id]})); + const currentLibId = getLibraryFromPage(currentPage); + const currentIndex = allLibraries.findIndex(lib => lib.id === currentLibId); + if (currentIndex > 0) { + const currentLib = allLibraries.splice(currentIndex, 1)[0]; + allLibraries.unshift(currentLib); + } + return allLibraries; +} + + +export function getResourceTags(library: Library): Tag[] { + if (library === 'react-spectrum') { + return [{id: 'icons', name: 'Icons'}]; + } + return []; +} + +export function useFilteredIcons(searchValue: string) { + const iconFilter = useIconFilter(); + return useMemo(() => { + if (!searchValue.trim()) { + return iconList; + } + return iconList.filter(item => iconFilter(item.id, searchValue)); + }, [searchValue, iconFilter]); +} + +export function useSearchTagSelection( + searchValue: string, + sectionTags: Tag[], + resourceTags: Tag[], + initialTagId: string +) { + const [selectedTagId, setSelectedTagId] = useState(initialTagId); + const prevSearchWasEmptyRef = useRef(true); + + // Ensure selected tag is valid for the current library + const baseSectionIds = sectionTags.map(s => s.id); + const resourceTagIds = resourceTags.map(t => t.id); + const allBaseIds = useMemo(() => [...baseSectionIds, ...resourceTagIds], [baseSectionIds, resourceTagIds]); + const isResourceSelected = selectedTagId && resourceTagIds.includes(selectedTagId); + const sectionIds = useMemo(() => { + return searchValue.trim().length > 0 && !isResourceSelected ? ['all', ...allBaseIds] : allBaseIds; + }, [searchValue, isResourceSelected, allBaseIds]); + + useEffect(() => { + if (!selectedTagId || !sectionIds.includes(selectedTagId)) { + setSelectedTagId(sectionIds[0] || 'components'); + } + }, [selectedTagId, sectionIds, setSelectedTagId]); + + // Auto-select "All" when search starts (unless resource is selected) + useEffect(() => { + const isEmpty = searchValue.trim().length === 0; + if (prevSearchWasEmptyRef.current && !isEmpty && !isResourceSelected) { + setSelectedTagId('all'); + } + prevSearchWasEmptyRef.current = isEmpty; + }, [searchValue, isResourceSelected]); + + return [selectedTagId, setSelectedTagId] as const; +} + +export function useSectionTagsForDisplay( + sections: Section[], + searchValue: string, + selectedTagId: string, + resourceTagIds: string[] +): Tag[] { + return useMemo(() => { + const base = sections.map(s => ({id: s.id, name: s.name})); + if (searchValue.trim().length > 0 && !resourceTagIds.includes(selectedTagId)) { + return [{id: 'all', name: 'All'}, ...base]; + } + return base; + }, [sections, searchValue, selectedTagId, resourceTagIds]); +} + +export function sortItemsForDisplay(items: T[], searchValue: string): T[] { + if (searchValue.trim().length === 0) { + return [...items].sort((a, b) => { + const aIsIntro = a.name === 'Introduction'; + const bIsIntro = b.name === 'Introduction'; + + // Date sorting for Blog/Releases + if (a.date && b.date) { + const aDate = new Date(a.date); + const bDate = new Date(b.date); + return bDate.getTime() - aDate.getTime(); + } else if (a.date && !b.date) { + return 1; + } else if (!a.date && b.date) { + return -1; + } + + // Introduction first + if (aIsIntro && !bIsIntro) { + return -1; + } + if (!aIsIntro && bIsIntro) { + return 1; + } + return 0; + }); + } + return items; +} + +export function createSearchOptions() { + return { + getName: (item: T) => item.name, + getTags: (item: T) => item.tags, + getDate: (item: T) => item.date, + shouldUseDateSort: (item: T) => { + const section = item.section; + return section === 'Blog' || section === 'Releases'; + } + }; +} + +export function SearchEmptyState({searchValue, libraryLabel}: {searchValue: string, libraryLabel: string}) { + return ( + + + No results + {searchValue.trim().length > 0 ? ( + + No results found for {searchValue} in {libraryLabel}. + + ) : ( + + No results found in {libraryLabel}. + + )} + + ); +}