From 00c2da8ac7e66efb8c603939322d8ba7dacafee5 Mon Sep 17 00:00:00 2001 From: Emma Hamilton Date: Wed, 5 Aug 2026 13:40:20 +1000 Subject: [PATCH] Use public react-aria-components API for virtualization --- .changeset/modern-collections.md | 7 + .../docs/components/theme-switcher.tsx | 6 +- design-system/pkg/package.json | 8 +- .../pkg/src/action-bar/ActionBar.tsx | 12 +- design-system/pkg/src/action-bar/index.ts | 2 +- .../src/action-bar/stories/ListExample.tsx | 25 +- design-system/pkg/src/action-bar/types.ts | 11 +- .../pkg/src/action-group/ActionGroup.tsx | 788 +- .../pkg/src/action-group/docs/index.mdoc | 331 +- design-system/pkg/src/action-group/index.ts | 6 +- .../stories/ActionGroup.stories.tsx | 256 +- .../action-group/test/ActionGroup.test.tsx | 48 +- design-system/pkg/src/action-group/types.ts | 67 +- .../pkg/src/breadcrumbs/BreadcrumbItem.tsx | 200 +- .../pkg/src/breadcrumbs/Breadcrumbs.tsx | 277 +- design-system/pkg/src/breadcrumbs/context.ts | 3 + .../pkg/src/breadcrumbs/docs/index.mdoc | 134 +- design-system/pkg/src/breadcrumbs/index.ts | 5 +- .../stories/Breadcrumbs.stories.tsx | 68 +- .../src/breadcrumbs/test/Breadcrumbs.test.tsx | 241 +- design-system/pkg/src/breadcrumbs/types.ts | 42 +- design-system/pkg/src/checkbox/Checkbox.tsx | 47 +- design-system/pkg/src/checkbox/index.ts | 2 +- design-system/pkg/src/combobox/Combobox.tsx | 361 +- .../pkg/src/combobox/ComboboxBase.tsx | 108 + .../pkg/src/combobox/ComboboxMulti.tsx | 147 +- .../pkg/src/combobox/MobileCombobox.tsx | 523 - .../pkg/src/combobox/MobileComboboxMulti.tsx | 518 - .../pkg/src/combobox/docs/index.mdoc | 129 +- design-system/pkg/src/combobox/index.ts | 9 +- design-system/pkg/src/combobox/l10n.json | 274 - .../src/combobox/stories/Combobox.stories.tsx | 128 +- .../stories/ComboboxMulti.stories.tsx | 133 +- .../pkg/src/combobox/test/Combobox.test.tsx | 32 +- design-system/pkg/src/combobox/types.ts | 102 +- .../pkg/src/combobox/useComboboxMulti.ts | 286 - .../pkg/src/combobox/useComboboxMultiState.ts | 272 - design-system/pkg/src/core/docs/index.mdoc | 6 +- .../date-time/stories/DateField.stories.tsx | 33 +- .../pkg/src/dialog/DialogTrigger.tsx | 31 +- design-system/pkg/src/dialog/docs/index.mdoc | 4 +- .../pkg/src/drag-and-drop/docs/index.mdoc | 4 +- .../stories/DropZone.stories.tsx | 6 +- design-system/pkg/src/drag-and-drop/types.ts | 94 +- .../pkg/src/drag-and-drop/useDragAndDrop.ts | 103 +- .../pkg/src/editor/EditorAutocomplete.tsx | 29 + .../pkg/src/editor/EditorListbox.tsx | 99 +- design-system/pkg/src/editor/index.ts | 14 +- .../editor/stories/EditorListbox.stories.tsx | 104 +- .../src/editor/test/EditorListbox.test.tsx | 49 + .../pkg/src/list-view/DragPreview.tsx | 149 - .../pkg/src/list-view/InsertionIndicator.tsx | 57 - design-system/pkg/src/list-view/ListView.tsx | 499 +- .../pkg/src/list-view/ListViewItem.tsx | 699 +- .../pkg/src/list-view/ListViewLayout.tsx | 75 - .../pkg/src/list-view/RootDropIndicator.tsx | 43 - design-system/pkg/src/list-view/context.tsx | 38 - .../pkg/src/list-view/docs/index.mdoc | 84 +- design-system/pkg/src/list-view/index.ts | 12 +- .../list-view/stories/ListView.stories.tsx | 97 +- .../pkg/src/list-view/test/ListView.test.tsx | 130 +- design-system/pkg/src/list-view/types.ts | 62 +- design-system/pkg/src/listbox/ListBox.tsx | 277 +- design-system/pkg/src/listbox/ListBoxBase.tsx | 194 - .../pkg/src/listbox/ListBoxLayout.tsx | 93 - .../pkg/src/listbox/ListBoxOption.tsx | 90 - .../pkg/src/listbox/ListBoxSection.tsx | 81 - design-system/pkg/src/listbox/context.ts | 17 - design-system/pkg/src/listbox/index.ts | 20 +- .../src/listbox/stories/ListBox.stories.tsx | 376 +- .../pkg/src/listbox/test/ListBox.test.tsx | 49 +- design-system/pkg/src/listbox/types.ts | 50 - design-system/pkg/src/menu/ActionMenu.tsx | 1 + design-system/pkg/src/menu/Menu.tsx | 81 +- design-system/pkg/src/menu/MenuItem.tsx | 145 +- design-system/pkg/src/menu/MenuSection.tsx | 90 +- design-system/pkg/src/menu/docs/index.mdoc | 155 +- design-system/pkg/src/menu/index.ts | 11 +- .../src/menu/stories/ActionMenu.stories.tsx | 32 +- .../pkg/src/menu/stories/Menu.stories.tsx | 301 +- design-system/pkg/src/menu/test/Menu.test.tsx | 58 +- .../pkg/src/menu/test/MenuTrigger.test.tsx | 90 +- design-system/pkg/src/menu/types.ts | 16 +- design-system/pkg/src/nav-tree/NavTree.tsx | 646 +- .../pkg/src/nav-tree/TreeKeyboardDelegate.tsx | 105 - design-system/pkg/src/nav-tree/index.ts | 18 +- .../src/nav-tree/stories/NavTree.stories.tsx | 329 +- .../pkg/src/nav-tree/test/NavTree.test.tsx | 172 +- design-system/pkg/src/nav-tree/types.ts | 54 +- design-system/pkg/src/picker/Picker.tsx | 295 +- design-system/pkg/src/picker/docs/index.mdoc | 135 +- design-system/pkg/src/picker/index.ts | 9 +- .../pkg/src/picker/stories/Picker.stories.tsx | 242 +- .../pkg/src/picker/test/Picker.test.tsx | 2108 +- design-system/pkg/src/picker/types.ts | 49 +- design-system/pkg/src/table/DragPreview.tsx | 46 - .../pkg/src/table/InsertionIndicator.tsx | 69 - design-system/pkg/src/table/Resizer.tsx | 168 - design-system/pkg/src/table/TableView.tsx | 1734 +- .../pkg/src/table/TableViewLayout.tsx | 108 - design-system/pkg/src/table/context.tsx | 80 - design-system/pkg/src/table/docs/index.mdoc | 17 +- design-system/pkg/src/table/index.ts | 34 +- .../pkg/src/table/stories/ReorderExample.tsx | 2 +- .../pkg/src/table/stories/Table.stories.tsx | 77 +- .../pkg/src/table/test/TableView.test.tsx | 205 + design-system/pkg/src/table/types.ts | 150 +- design-system/pkg/src/tabs/Tabs.tsx | 814 +- design-system/pkg/src/tabs/context.tsx | 30 - design-system/pkg/src/tabs/docs/index.mdoc | 168 +- design-system/pkg/src/tabs/index.ts | 12 +- .../pkg/src/tabs/stories/Tabs.stories.tsx | 84 +- design-system/pkg/src/tabs/test/Tabs.test.tsx | 87 +- design-system/pkg/src/tabs/types.ts | 77 +- design-system/pkg/src/tag/Tag.tsx | 168 +- design-system/pkg/src/tag/TagGroup.tsx | 392 +- design-system/pkg/src/tag/docs/index.mdoc | 180 +- design-system/pkg/src/tag/index.ts | 6 +- design-system/pkg/src/tag/l10n.json | 206 - .../pkg/src/tag/stories/TagGroup.stories.tsx | 320 +- .../pkg/src/tag/test/TagGroup.test.tsx | 130 +- .../pkg/src/tooltip/TooltipTrigger.tsx | 27 +- dev-projects/next-app/note.tsx | 6 +- package.json | 4 +- packages/keystatic/package.json | 8 +- packages/keystatic/src/app/CollectionPage.tsx | 18 +- packages/keystatic/src/app/ItemPage.tsx | 8 +- packages/keystatic/src/app/SingletonPage.tsx | 8 +- .../keystatic/src/app/branch-selection.tsx | 30 +- packages/keystatic/src/app/create-item.tsx | 6 +- .../keystatic/src/app/shell/BatchCommits.tsx | 63 +- .../src/app/shell/HeaderBreadcrumbs.tsx | 9 +- .../src/app/shell/sidebar/components.tsx | 53 +- .../keystatic/src/form/fields/array/ui.tsx | 6 +- .../keystatic/src/form/fields/blocks/ui.tsx | 4 +- .../document/DocumentEditor/Toolbar.tsx | 14 +- .../document/DocumentEditor/alignment.tsx | 7 +- .../code-block/code-block-ui.tsx | 7 +- .../fields/document/DocumentEditor/index.tsx | 35 +- .../document/DocumentEditor/insert-menu.tsx | 118 +- .../DocumentEditor/layouts/layouts-ui.tsx | 6 +- .../document/DocumentEditor/lists/lists.tsx | 10 +- .../DocumentEditor/table/table-ui.tsx | 4 +- .../form/fields/markdoc/editor/Toolbar.tsx | 4 +- .../editor/autocomplete/EditorListbox.tsx | 137 +- .../editor/autocomplete/autocomplete.tsx | 31 +- .../editor/autocomplete/decoration.tsx | 3 +- .../editor/autocomplete/insert-menu.tsx | 11 +- .../markdoc/editor/custom-components.tsx | 6 +- .../fields/markdoc/editor/editor-view.tsx | 26 +- .../fields/markdoc/editor/popovers/table.tsx | 5 +- .../src/form/fields/multiRelationship/ui.tsx | 21 +- .../src/form/fields/pathReference/ui.tsx | 4 +- .../src/form/fields/relationship/ui.tsx | 5 +- .../keystatic/src/form/fields/select/ui.tsx | 4 +- pnpm-lock.yaml | 21664 +++++----------- pnpm-workspace.yaml | 12 +- 157 files changed, 11690 insertions(+), 30576 deletions(-) create mode 100644 .changeset/modern-collections.md create mode 100644 design-system/pkg/src/breadcrumbs/context.ts create mode 100644 design-system/pkg/src/combobox/ComboboxBase.tsx delete mode 100644 design-system/pkg/src/combobox/MobileCombobox.tsx delete mode 100644 design-system/pkg/src/combobox/MobileComboboxMulti.tsx delete mode 100644 design-system/pkg/src/combobox/l10n.json delete mode 100644 design-system/pkg/src/combobox/useComboboxMulti.ts delete mode 100644 design-system/pkg/src/combobox/useComboboxMultiState.ts create mode 100644 design-system/pkg/src/editor/EditorAutocomplete.tsx create mode 100644 design-system/pkg/src/editor/test/EditorListbox.test.tsx delete mode 100644 design-system/pkg/src/list-view/DragPreview.tsx delete mode 100644 design-system/pkg/src/list-view/InsertionIndicator.tsx delete mode 100644 design-system/pkg/src/list-view/ListViewLayout.tsx delete mode 100644 design-system/pkg/src/list-view/RootDropIndicator.tsx delete mode 100644 design-system/pkg/src/list-view/context.tsx delete mode 100644 design-system/pkg/src/listbox/ListBoxBase.tsx delete mode 100644 design-system/pkg/src/listbox/ListBoxLayout.tsx delete mode 100644 design-system/pkg/src/listbox/ListBoxOption.tsx delete mode 100644 design-system/pkg/src/listbox/ListBoxSection.tsx delete mode 100644 design-system/pkg/src/listbox/context.ts delete mode 100644 design-system/pkg/src/listbox/types.ts delete mode 100644 design-system/pkg/src/nav-tree/TreeKeyboardDelegate.tsx delete mode 100644 design-system/pkg/src/table/DragPreview.tsx delete mode 100644 design-system/pkg/src/table/InsertionIndicator.tsx delete mode 100644 design-system/pkg/src/table/Resizer.tsx delete mode 100644 design-system/pkg/src/table/TableViewLayout.tsx delete mode 100644 design-system/pkg/src/table/context.tsx create mode 100644 design-system/pkg/src/table/test/TableView.test.tsx delete mode 100644 design-system/pkg/src/tabs/context.tsx delete mode 100644 design-system/pkg/src/tag/l10n.json diff --git a/.changeset/modern-collections.md b/.changeset/modern-collections.md new file mode 100644 index 000000000..beea5d8f3 --- /dev/null +++ b/.changeset/modern-collections.md @@ -0,0 +1,7 @@ +--- +'@keystar/ui': minor +--- + +Replace the legacy collection compatibility APIs with explicit React Aria Components collection composition across list boxes, list views, tables, menus, tabs, tags, pickers, combo boxes, navigation trees, action groups, and editor autocomplete. + +Collection children now use component-specific names such as `MenuItem`, `ListBoxItem`, `TableRow`, `Tab`, and `Tag`, with explicit collection, section, header, and load-more components where applicable. diff --git a/design-system/docs/components/theme-switcher.tsx b/design-system/docs/components/theme-switcher.tsx index 2314c8222..2097a1034 100644 --- a/design-system/docs/components/theme-switcher.tsx +++ b/design-system/docs/components/theme-switcher.tsx @@ -5,7 +5,7 @@ import { monitorIcon } from '@keystar/ui/icon/icons/monitorIcon'; import { moonIcon } from '@keystar/ui/icon/icons/moonIcon'; import { sunIcon } from '@keystar/ui/icon/icons/sunIcon'; import { Icon } from '@keystar/ui/icon'; -import { MenuTrigger, Menu, Item } from '@keystar/ui/menu'; +import { MenuTrigger, Menu, MenuItem } from '@keystar/ui/menu'; import { useRootColorScheme } from '@keystar/ui/next'; import { SCHEME_AUTO, SCHEME_DARK, SCHEME_LIGHT } from '@keystar/ui/primitives'; @@ -48,10 +48,10 @@ export function ColorSchemeMenu() { selectionMode="single" > {item => ( - + {item.label} - + )} diff --git a/design-system/pkg/package.json b/design-system/pkg/package.json index 1f59f1c74..52b6c7656 100644 --- a/design-system/pkg/package.json +++ b/design-system/pkg/package.json @@ -1479,14 +1479,15 @@ "@emotion/sheet": "^1.4.0", "@emotion/utils": "^1.4.2", "@floating-ui/react": "^0.24.0", - "@internationalized/date": "^3.12.2", + "@internationalized/date": "^3.12.3", "@internationalized/number": "^3.6.7", - "@internationalized/string": "^3.2.9", - "@react-types/shared": "^3.36.0", + "@internationalized/string": "^3.2.10", + "@react-types/shared": "^3.36.1", "@types/react": "^19.0.8", "emery": "^1.4.1", "facepaint": "^1.2.1", "react-aria": "catalog:", + "react-aria-components": "catalog:", "react-stately": "catalog:" }, "devDependencies": { @@ -1513,6 +1514,7 @@ "next": ">=14", "react": "^18.2.0 || ^19.0.0", "react-aria": "catalog:", + "react-aria-components": "catalog:", "react-dom": "^18.2.0 || ^19.0.0", "react-stately": "catalog:" }, diff --git a/design-system/pkg/src/action-bar/ActionBar.tsx b/design-system/pkg/src/action-bar/ActionBar.tsx index a14c7c126..d0ca36bd2 100644 --- a/design-system/pkg/src/action-bar/ActionBar.tsx +++ b/design-system/pkg/src/action-bar/ActionBar.tsx @@ -46,11 +46,11 @@ function ActionBar( ); } -interface ActionBarInnerProps extends ActionBarProps { +interface ActionBarInnerProps extends ActionBarProps { isOpen?: boolean; } -function ActionBarInner( +function ActionBarInner( props: ActionBarInnerProps, ref: Ref ) { @@ -158,7 +158,7 @@ function ActionBarInner( items={items} aria-label={stringFormatter.format('actions')} prominence="low" - overflowMode="collapse" + overflowMode={items ? 'collapse' : 'wrap'} buttonLabelBehavior={buttonLabelBehavior} onAction={onAction} gridArea="actiongroup" @@ -189,7 +189,9 @@ function ActionBarInner( ); } -const ActionBarInnerWithRef = React.forwardRef(ActionBarInner) as ( +const ActionBarInnerWithRef = React.forwardRef(ActionBarInner) as < + T extends object, +>( props: ActionBarInnerProps & { ref?: Ref } ) => ReactElement; @@ -197,7 +199,7 @@ const ActionBarInnerWithRef = React.forwardRef(ActionBarInner) as ( * Action bars are used for single and bulk selection patterns when a user needs * to perform actions on one or more items at the same time. */ -const _ActionBar: ( +const _ActionBar: ( props: ActionBarProps & { ref?: ForwardedRef } ) => ReactElement = React.forwardRef(ActionBar as any) as any; diff --git a/design-system/pkg/src/action-bar/index.ts b/design-system/pkg/src/action-bar/index.ts index 03f1980d3..e418b5213 100644 --- a/design-system/pkg/src/action-bar/index.ts +++ b/design-system/pkg/src/action-bar/index.ts @@ -1,6 +1,6 @@ 'use client'; -export { Item } from 'react-stately/Item'; +export { ActionGroupItem as ActionBarItem } from '@keystar/ui/action-group'; export { ActionBar } from './ActionBar'; export { ActionBarContainer } from './ActionBarContainer'; diff --git a/design-system/pkg/src/action-bar/stories/ListExample.tsx b/design-system/pkg/src/action-bar/stories/ListExample.tsx index 94e03c239..659a5fd82 100644 --- a/design-system/pkg/src/action-bar/stories/ListExample.tsx +++ b/design-system/pkg/src/action-bar/stories/ListExample.tsx @@ -2,12 +2,17 @@ import { Icon } from '@keystar/ui/icon'; import { copyIcon } from '@keystar/ui/icon/icons/copyIcon'; import { pencilIcon } from '@keystar/ui/icon/icons/pencilIcon'; import { trash2Icon } from '@keystar/ui/icon/icons/trash2Icon'; -import { Item, ListView } from '@keystar/ui/list-view'; +import { ListViewItem, ListView } from '@keystar/ui/list-view'; import { Text } from '@keystar/ui/typography'; import { Selection } from '@react-types/shared'; import React, { useState } from 'react'; -import { ActionBar, ActionBarContainer, ActionBarProps } from '../index'; +import { + ActionBar, + ActionBarContainer, + ActionBarItem, + ActionBarProps, +} from '../index'; export const ListExample = (props: Partial>) => { let [selectedKeys, setSelectedKeys] = useState(new Set()); @@ -22,9 +27,9 @@ export const ListExample = (props: Partial>) => { selectedKeys={selectedKeys} > {(item: any) => ( - + {item.name} - + )} >) => { onClearSelection={() => setSelectedKeys(new Set())} {...props} > - + Edit - - + + Copy - - + + Delete - + ); diff --git a/design-system/pkg/src/action-bar/types.ts b/design-system/pkg/src/action-bar/types.ts index 2cc29912b..575e0b739 100644 --- a/design-system/pkg/src/action-bar/types.ts +++ b/design-system/pkg/src/action-bar/types.ts @@ -1,18 +1,19 @@ import { BaseStyleProps } from '@keystar/ui/style'; -import { DOMProps, ItemProps, Key } from '@react-types/shared'; +import { DOMProps, Key } from '@react-types/shared'; import { ReactElement, ReactNode } from 'react'; +import type { ActionGroupItemProps } from '@keystar/ui/action-group'; -type ItemElement = ReactElement> | null; +type ItemElement = ReactElement | null; -export type ActionBarProps = { - children: ItemElement | ItemElement[] | ((item: T) => ItemElement); +export type ActionBarProps = { + children: ItemElement | ItemElement[] | ((item: T) => ItemElement); items?: Iterable; disabledKeys?: Iterable; selectedItemCount: number | 'all'; onClearSelection: () => void; isEmphasized?: boolean; onAction?: (key: Key) => void; - buttonLabelBehavior?: 'show' | 'collapse' | 'hide'; + buttonLabelBehavior?: 'show' | 'hide'; } & DOMProps & BaseStyleProps; diff --git a/design-system/pkg/src/action-group/ActionGroup.tsx b/design-system/pkg/src/action-group/ActionGroup.tsx index 943a0ada4..7868c985f 100644 --- a/design-system/pkg/src/action-group/ActionGroup.tsx +++ b/design-system/pkg/src/action-group/ActionGroup.tsx @@ -1,643 +1,281 @@ -import { useActionGroup } from 'react-aria/private/actiongroup/useActionGroup'; -import { useActionGroupItem } from 'react-aria/private/actiongroup/useActionGroupItem'; -import { FocusScope } from 'react-aria/FocusScope'; -import { PressResponder } from 'react-aria/private/interactions/PressResponder'; -import { filterDOMProps } from 'react-aria/filterDOMProps'; -import { mergeProps } from 'react-aria/mergeProps'; -import { useId } from 'react-aria/useId'; import { - useLayoutEffect, - ForwardedRef, - ReactElement, - ReactNode, + Children, + createContext, + type ForwardedRef, + type ReactElement, forwardRef, - useCallback, + isValidElement, + useContext, + useEffect, useMemo, useRef, useState, - RefObject, } from 'react'; -import { useObjectRef } from 'react-aria/useObjectRef'; -import { useResizeObserver } from 'react-aria/private/utils/useResizeObserver'; -import { useValueEffect } from 'react-aria/private/utils/useValueEffect'; -import { ListState, useListState } from 'react-stately/useListState'; -import { AriaLabelingProps, DOMProps, Node } from '@react-types/shared'; +import type { Key } from '@react-types/shared'; -import { ActionButton, actionButtonClassList } from '@keystar/ui/button'; -import { KeystarProvider, useProviderProps } from '@keystar/ui/core'; -import { chevronDownIcon } from '@keystar/ui/icon/icons/chevronDownIcon'; -import { moreHorizontalIcon } from '@keystar/ui/icon/icons/moreHorizontalIcon'; +import { + ActionButton, + actionButtonClassList, + ToggleButton, +} from '@keystar/ui/button'; import { Icon } from '@keystar/ui/icon'; -import { Item, Menu, MenuTrigger } from '@keystar/ui/menu'; -import { Kbd, Text } from '@keystar/ui/typography'; -import { Tooltip, TooltipTrigger } from '@keystar/ui/tooltip'; -import { ClearSlots, SlotProvider, useSlotProps } from '@keystar/ui/slots'; +import { moreHorizontalIcon } from '@keystar/ui/icon/icons/moreHorizontalIcon'; +import { Menu, MenuItem, MenuTrigger } from '@keystar/ui/menu'; import { - BaseStyleProps, classNames, css, toDataAttributes, tokenSchema, useStyleProps, } from '@keystar/ui/style'; -import { isReactText } from '@keystar/ui/utils'; +import { Tooltip, TooltipTrigger } from '@keystar/ui/tooltip'; + +import type { ActionGroupItemProps, ActionGroupProps } from './types'; -import { ActionGroupProps } from './types'; +const ActionGroupContext = createContext<{ + isDisabled: boolean; + disabledKeys: Set; + onAction?: (key: Key) => void; + hideButtonText: boolean; + prominence: 'low' | 'default'; + selectionMode: 'none' | 'single' | 'multiple'; + selectedKeys: Set; + toggle(key: Key): void; +} | null>(null); function ActionGroup( props: ActionGroupProps, forwardedRef: ForwardedRef ) { - props = useProviderProps(props); - props = useSlotProps(props, 'actionGroup'); - let { + children, + items, density, - prominence, isJustified, - isDisabled, + isDisabled = false, + disabledKeys: disabledKeysProp, orientation = 'horizontal', overflowMode = 'wrap', + buttonLabelBehavior = 'show', onAction, - buttonLabelBehavior, + prominence = 'default', + selectionMode = 'none', + selectedKeys: controlledSelectedKeys, + defaultSelectedKeys, + disallowEmptySelection = false, + onSelectionChange, summaryIcon, - ...otherProps } = props; - - // High prominence buttons should be used sparingly and in isolation, so they - // are not supported in groups. - prominence = prominence === 'low' ? 'low' : 'default'; - - let domRef = useObjectRef(forwardedRef); - let wrapperRef = useRef(null); - let state = useListState({ ...props, suppressTextValueWarning: true }); - let { actionGroupProps } = useActionGroup(props, state, domRef); - let providerProps = { isDisabled }; let styleProps = useStyleProps(props); - - // Only hide button text if every item contains more than just plain text (we assume an icon). - let isIconCollapsible = useMemo( - () => - [...state.collection].every(item => typeof item.rendered !== 'string'), - [state.collection] + if ( + overflowMode === 'collapse' && + (!items || typeof children !== 'function') + ) { + throw new Error( + 'ActionGroup requires items and a render function when overflowMode is "collapse".' + ); + } + let elements = useMemo( + () => materializeItems(items, children), + [children, items] ); - let [{ visibleItems, hideButtonText, isMeasuring }, setVisibleItems] = - useValueEffect({ - visibleItems: state.collection.size, - hideButtonText: buttonLabelBehavior === 'hide' && isIconCollapsible, - isMeasuring: false, - }); - - let selectionMode = state.selectionManager.selectionMode; - let updateOverflow = useCallback(() => { - if (overflowMode === 'wrap') { - return; - } - - if (orientation === 'vertical' && selectionMode !== 'none') { - // Collapsing vertical action groups with selection is currently unsupported. + let [uncontrolledKeys, setUncontrolledKeys] = useState( + () => new Set(defaultSelectedKeys) + ); + let selectedKeys = controlledSelectedKeys + ? new Set(controlledSelectedKeys) + : uncontrolledKeys; + let disabledKeys = new Set(disabledKeysProp); + let [visibleCount, setVisibleCount] = useState(elements.length); + let groupRef = useRef(null); + + useEffect(() => { + let element = groupRef.current; + if (!element || overflowMode !== 'collapse') { + setVisibleCount(elements.length); return; } - let computeVisibleItems = (visibleItems: number) => { - if (domRef.current && wrapperRef.current) { - let listItems = Array.from(domRef.current.children) as HTMLLIElement[]; - let containerSize = - orientation === 'horizontal' - ? wrapperRef.current.getBoundingClientRect().width - : wrapperRef.current.getBoundingClientRect().height; - - let isShowingMenu = visibleItems < state.collection.size; - let calculatedSize = 0; - let newVisibleItems = 0; - - if (isShowingMenu) { - let item = listItems.pop(); - if (item) { - calculatedSize += - orientation === 'horizontal' - ? outerWidth(item, false, true) - : outerHeight(item, false, true); - } - } - - for (let [i, item] of listItems.entries()) { - calculatedSize += - orientation === 'horizontal' - ? outerWidth(item, i === 0, i === listItems.length - 1) - : outerHeight(item, i === 0, i === listItems.length - 1); - if (Math.round(calculatedSize) <= Math.round(containerSize)) { - newVisibleItems++; - } else { - break; - } - } - - // If selection is enabled, and not all of the items fit, collapse all of them into a dropdown - // immediately rather than having some visible and some not. - if ( - selectionMode !== 'none' && - newVisibleItems < state.collection.size - ) { - return 0; - } - - return newVisibleItems; + let update = () => { + let available = element.getBoundingClientRect().width; + let children = Array.from(element.children) as HTMLElement[]; + let overflowButtonWidth = 40; + let used = 0; + let count = 0; + for (let child of children.slice(0, elements.length)) { + let width = child.getBoundingClientRect().width; + if (used + width + overflowButtonWidth > available) break; + used += width; + count++; } - return visibleItems; + if (selectionMode !== 'none' && count < elements.length) count = 0; + setVisibleCount(count < elements.length ? count : elements.length); }; - setVisibleItems(function* () { - let hideButtonText = buttonLabelBehavior === 'hide' && isIconCollapsible; - - // Update to show all items. - yield { - visibleItems: state.collection.size, - hideButtonText, - isMeasuring: true, - }; - - // Measure, and update to show the items that fit. - let newVisibleItems = computeVisibleItems(state.collection.size); - let isMeasuring = - newVisibleItems < state.collection.size && newVisibleItems > 0; - - // If not all of the buttons fit, and buttonLabelBehavior is 'collapse', then first try hiding - // the button text and only showing icons. Only if that still doesn't fit collapse into a menu. - if ( - newVisibleItems < state.collection.size && - buttonLabelBehavior === 'collapse' && - isIconCollapsible - ) { - yield { - visibleItems: state.collection.size, - hideButtonText: true, - isMeasuring: true, - }; + let observer = new ResizeObserver(update); + observer.observe(element); + update(); + return () => observer.disconnect(); + }, [elements.length, overflowMode, selectionMode]); - newVisibleItems = computeVisibleItems(state.collection.size); - isMeasuring = - newVisibleItems < state.collection.size && newVisibleItems > 0; - hideButtonText = true; - } - - yield { - visibleItems: newVisibleItems, - hideButtonText, - isMeasuring, - }; - - // If the number of items is less than the number of children, - // then update again to ensure that the menu fits. - if (isMeasuring) { - yield { - visibleItems: computeVisibleItems(newVisibleItems), - hideButtonText, - isMeasuring: false, - }; - } - }); - }, [ - domRef, - state.collection, - setVisibleItems, - overflowMode, - selectionMode, - buttonLabelBehavior, - isIconCollapsible, - orientation, - ]); - - // Watch the parent element for size changes. Watching only the action group itself may not work - // in all scenarios because it may not shrink when available space is reduced. - let parentRef = useMemo( - () => ({ - get current() { - return wrapperRef.current?.parentElement; - }, - }), - [wrapperRef] - ); - useResizeObserver({ - ref: overflowMode !== 'wrap' ? parentRef : undefined, - onResize: updateOverflow, - }); - useLayoutEffect(updateOverflow, [updateOverflow, state.collection]); - - let children = [...state.collection]; - let menuItem: ReactElement | null = null; - let menuProps = {}; - - // If there are no visible items, don't apply any props to the action group container - // and pass all aria labeling props through to the menu button. - if (overflowMode === 'collapse' && visibleItems === 0) { - menuProps = filterDOMProps(props, { labelable: true }); - actionGroupProps = {}; - } - - if (overflowMode === 'collapse' && visibleItems < state.collection.size) { - let menuChildren = children.slice(visibleItems); - children = children.slice(0, visibleItems); - menuItem = ( - - ); - } - - let style = { - ...styleProps.style, - // While measuring, take up as much space as possible. - flexBasis: isMeasuring ? '100%' : undefined, + let toggle = (key: Key) => { + if (selectionMode === 'none') { + onAction?.(key); + return; + } + let next = new Set(selectedKeys); + if (selectionMode === 'single') { + next = + next.has(key) && !disallowEmptySelection ? new Set() : new Set([key]); + } else if (next.has(key)) { + next.delete(key); + } else { + next.add(key); + } + if (!controlledSelectedKeys) setUncontrolledKeys(next); + onSelectionChange?.(next); + onAction?.(key); }; + let visibleItems = elements.slice(0, visibleCount); + let overflowItems = elements.slice(visibleCount); return ( - +
*': { flexGrow: 1 }, })} - ref={domRef} - className={classNames( - css({ - display: 'flex', - // NOTE: prefer `gap` but it breaks the measurement/collapse logic, so we use margin instead. - margin: `calc(var(--action-item-gap) / -2)`, - minWidth: 0, - width: 'calc(100% + var(--action-item-gap) + 1px)', - - '--action-item-gap': tokenSchema.size.space.regular, - - [actionButtonClassList.selector('root', 'child')]: { - margin: `calc(var(--action-item-gap) / 2)`, - }, - - // wrap - '&[data-overflow=wrap]': { - flexWrap: 'wrap', - }, - - // justified - '&[data-justified]': { - [actionButtonClassList.selector('root', 'child')]: { - flexGrow: 1, - }, - }, - - // compact - '&[data-compact]:not([data-prominence=low])': { - '--action-item-gap': 0, - // gap: 0, - - [actionButtonClassList.selector('root', 'child')]: { - borderRadius: 0, - - '&:first-of-type': { - borderTopLeftRadius: tokenSchema.size.radius.regular, - borderBottomLeftRadius: tokenSchema.size.radius.regular, - }, - '&:last-of-type': { - borderTopRightRadius: tokenSchema.size.radius.regular, - borderBottomRightRadius: tokenSchema.size.radius.regular, - }, - - '&:not(:last-of-type)': { - marginRight: `calc(${tokenSchema.size.border.regular} * -1)`, - }, - '&[data-interaction=hover], &[data-focus=visible], &[data-interaction=press]': - { - zIndex: 1, - }, - '&[data-selected]': { - zIndex: 2, - }, - }, - }, - '&[data-compact][data-prominence=low]': { - '--action-item-gap': tokenSchema.size.space.small, - }, - }), - otherProps.UNSAFE_className - )} > - - {children.map(item => ( - - ))} - {menuItem} - + {visibleItems} + {overflowItems.length > 0 && ( + + + {summaryIcon ?? } + + toggle(key)}> + {overflowItems.map(item => ( + + {item.props.children} + + ))} + + + )}
-
+ ); } -// forwardRef doesn't support generic parameters, so cast the result to the correct type -// https://stackoverflow.com/questions/58469229/react-with-typescript-generics-while-using-react-forwardref - -/** Group related action buttons together. */ -const _ActionGroup: ( - props: ActionGroupProps & { ref?: RefObject } -) => ReactElement = forwardRef(ActionGroup) as any; +const _ActionGroup = forwardRef(ActionGroup) as ( + props: ActionGroupProps & { ref?: ForwardedRef } +) => ReactElement; export { _ActionGroup as ActionGroup }; -interface ActionGroupItemProps extends DOMProps, BaseStyleProps { - item: Node; - state: ListState; - isDisabled?: boolean; - hideButtonText?: boolean; - orientation?: 'horizontal' | 'vertical'; - prominence?: 'low' | 'default'; - onAction?: (key: string | number) => void; -} - -function ActionGroupItem({ - item, - state, - isDisabled, - onAction, - hideButtonText, - orientation, - prominence, -}: ActionGroupItemProps) { - let ref = useRef(null); - let { buttonProps } = useActionGroupItem({ key: item.key }, state); - isDisabled = isDisabled || state.disabledKeys.has(item.key); - let isSelected = state.selectionManager.isSelected(item.key); - let domProps = filterDOMProps(item.props); - - if (onAction && !isDisabled) { - buttonProps = mergeProps(buttonProps, { - onPress: () => onAction(item.key), - }); - } - - // If button text is hidden, we need to show it as a tooltip instead, so - // go find the text element in the DOM after rendering. - let textId = useId(); - let kbdId = useId(); - let [textContent, setTextContent] = useState(''); - let [kbdContent, setKbdContent] = useState(''); - useLayoutEffect(() => { - if (hideButtonText) { - setTextContent(document.getElementById(textId)?.textContent); - setKbdContent(document.getElementById(kbdId)?.textContent); - } - }, [hideButtonText, item.rendered, textId, kbdId]); - - let button = ( - // Use a PressResponder to send DOM props through. - // Button doesn't allow overriding the role by default. - - - - - {item.rendered} - - - - - ); - - if (hideButtonText && textContent) { - button = ( - - {button} - - {kbdContent ? ( - <> - {textContent} - {kbdContent} - - ) : ( - textContent - )} - - - ); - } - - if (item.wrapper) { - button = item.wrapper(button); - } - - return button; -} - -interface ActionGroupMenuProps extends AriaLabelingProps { - hideButtonText?: boolean; - isDisabled?: boolean; - isOnlyItem?: boolean; - items: Node[]; - onAction?: (key: string | number) => void; - orientation?: 'horizontal' | 'vertical'; - prominence?: 'low' | 'default'; - state: ListState; - summaryIcon?: ReactNode; -} - -function ActionGroupMenu({ - hideButtonText, - isDisabled, - isOnlyItem, - items, - onAction, - orientation, - prominence, - state, - summaryIcon, - ...otherProps -}: ActionGroupMenuProps) { - // Use the key of the first item within the menu as the key of the button. - // The key must actually exist in the collection for focus to work correctly. - let key = items[0].key; - let { buttonProps } = useActionGroupItem({ key }, state); - - // The menu button shouldn't act like an actual action group item. - delete buttonProps.onPress; - delete buttonProps.role; - delete buttonProps['aria-checked']; - - // If no aria-label or aria-labelledby is given, provide a default one. - let ariaLabel = - otherProps['aria-label'] || - (otherProps['aria-labelledby'] ? undefined : '…'); - let ariaLabelledby = otherProps['aria-labelledby']; - let textId = useId(); - let id = useId(); - - // Summary icon only applies when selection is enabled. - if (state.selectionManager.selectionMode === 'none') { - summaryIcon = null; - } - - // If there is a selection, show the selected state on the menu button. - let isSelected = - state.selectionManager.selectionMode !== 'none' && - items.some(i => state.selectionManager.isSelected(i.key)); - - // If single selection and empty selection is not allowed, swap the contents of the button to the selected item (like a Picker). - if ( - !summaryIcon && - state.selectionManager.selectionMode === 'single' && - state.selectionManager.disallowEmptySelection && - state.selectionManager.firstSelectedKey != null - ) { - let selectedItem = state.collection.getItem( - state.selectionManager.firstSelectedKey - ); - if (selectedItem) { - summaryIcon = selectedItem.rendered; - if (isReactText(summaryIcon)) { - summaryIcon = {summaryIcon}; - } - ariaLabelledby = `${ariaLabelledby ?? id} ${textId}`; - } - } - - if (summaryIcon) { - // If there's a custom summary icon, also add a chevron. - summaryIcon = ( - <> - {summaryIcon} - - - ); - } - - return ( - // Use a PressResponder to send DOM props through. - - context.toggle(props.id)} > - - - {summaryIcon || } - - - - state.selectionManager.setSelectedKeys(keys)} - onAction={onAction} + {props.children} + + ) : ( + context.toggle(props.id)} > - {node => ( - - {node.rendered} - - )} - - - ); -} - -function outerWidth( - element: HTMLElement, - ignoreLeftMargin: boolean, - ignoreRightMargin: boolean -) { - let style = window.getComputedStyle(element); - return ( - element.offsetWidth + - (ignoreLeftMargin ? 0 : toNumber(style.marginLeft)) + - (ignoreRightMargin ? 0 : toNumber(style.marginRight)) + {props.children} + + ); + return context.hideButtonText && props.textValue ? ( + + {button} + {props.textValue} + + ) : ( + button ); } -function outerHeight( - element: HTMLElement, - ignoreTopMargin: boolean, - ignoreBottomMargin: boolean -) { - let style = window.getComputedStyle(element); - return ( - element.offsetHeight + - (ignoreTopMargin ? 0 : toNumber(style.marginTop)) + - (ignoreBottomMargin ? 0 : toNumber(style.marginBottom)) +function materializeItems( + items: Iterable | undefined, + children: ActionGroupProps['children'] +): ReactElement[] { + if (items && typeof children === 'function') { + return Array.from(items, item => children(item)).filter( + (child): child is ReactElement => child !== null + ); + } + if (typeof children === 'function') return []; + return Children.toArray(children).filter( + (child): child is ReactElement => + isValidElement(child) ); } - -function toNumber(value: string) { - let parsed = parseInt(value, 10); - return isNaN(parsed) ? 0 : parsed; -} diff --git a/design-system/pkg/src/action-group/docs/index.mdoc b/design-system/pkg/src/action-group/docs/index.mdoc index 30a913587..817f62572 100644 --- a/design-system/pkg/src/action-group/docs/index.mdoc +++ b/design-system/pkg/src/action-group/docs/index.mdoc @@ -1,335 +1,122 @@ --- title: ActionGroup description: - Groups multiple related actions in a stack or row to help with arrangement, - spacing, and selection. + Groups related actions in a row or stack and optionally supports selection. category: Buttons --- ## Example -On mobile devices, the `Menu` automatically displays in a tray instead of a -popover to improve usability. - ```jsx {% live=true %} let [action, setAction] = React.useState(); return ( - - Add - Edit - Delete + + Add + Edit + Delete Action: {action} ); ``` -## Patterns - -### Collections - -`ActionGroup` implements the `react-stately` -[collection component](https://react-spectrum.adobe.com/react-stately/collections.html), -``. +## Collections -Static collections, seen in the example above, can be used when the full list of -options is known ahead of time. - -Dynamic collections, as shown below, provide an iterable list of `items` to the -component and a render function that returns `Item` elements. +`ActionGroup` uses explicit `ActionGroupItem` children. Dynamic data is +provided with `items` and a render function. ```jsx {% live=true %} let items = [ - { key: 'add', label: 'Add' }, - { key: 'edit', label: 'Edit' }, - { key: 'delete', label: 'Delete' }, + { id: 'add', label: 'Add' }, + { id: 'edit', label: 'Edit' }, + { id: 'delete', label: 'Delete' }, ]; -return ( - alert(key)}> - {item => {item.label}} - -); -``` - -### Slots - -Icons can be added as `children` of an item, to better communicate the intent of -each option. - -```jsx {% live=true %} - - - - Add - - - - Edit - - - - Delete - - -``` - -When the `buttonLabelBehavior` is set to `"hide"`, the label is hidden and -automatically shown in a tooltip. - -```jsx {% live=true %} - - - - Add - - - - Edit - - - - Delete - - -``` - -### Selection - -Enable the selection of items with the `selectionMode` prop. See the -`react-stately` -[selection docs](https://react-spectrum.adobe.com/react-stately/selection.html#selected-key-data-type) -for more information about selection. - -```jsx {% live=true %} - - Bold - Italic - Underline - Strikethrough - -``` - -Use the `selectedKeys` prop and the `onSelectionChange` handler to control the -selected state of items within the action group. - -```jsx {% live=true %} -let [selected, setSelected] = React.useState(new Set(['italic'])); -return ( - - Bold - Italic - Underline - Strikethrough - -); -``` - -Setting the `selectionMode` to `"multiple"` allows more than one selection. - -```jsx {% live=true %} -let [selected, setSelected] = React.useState(new Set(['bold', 'italic'])); return ( alert(key)} > - Bold - Italic - Underline - Strikethrough + {item => {item.label}} ); ``` -### Collapsing - -By default, items wrap to form a new line when horizontal space is limited. -However, this can cause content to shift below the group. If items should always -appear in a single line, the `overflowMode` can be set to `"collapse"`. In this -mode, when horizontal space is limited, items will collapse into a -[menu](/package/menu). The exact behavior depends on the -[selection mode](#selection). - -#### Standard - -When selection is not enabled, `ActionGroup` displays as many items as possible -and collapses the remaining items into a “more actions” menu. +## Icons ```jsx {% live=true %} - - + + Add - - + + Edit - - - - Copy - - + + Delete - + ``` -#### Multi-select +## Selection -When [selection is enabled](#selection) and space is limited, _all items_ are -collapsed into a menu together. The menu button indicates when one of the -options within it is selected by showing a highlighted state. - -A `summaryIcon` should be specified to visually communicate the purpose of the -`ActionGroup` when collapsed, and an `aria-label` should be provided to describe -the group to assistive technology. +Use `selectionMode` for toggle-button groups. Selection can be controlled +with `selectedKeys` and `onSelectionChange`, or initialized with +`defaultSelectedKeys`. ```jsx {% live=true %} } - maxWidth="scale.1200" + defaultSelectedKeys={['bold']} > - - - Bold - - - - Italic - - - - Underline - - - - Strikethrough - - -``` - -#### Single-select - -A special case where a `summaryIcon` is not needed is a single selectable -`ActionGroup` (`selectionMode="single"`) which enforces that an item is always -selected (`disallowEmptySelection`). In this case, the selected item is -displayed inside the menu button when collapsed. - -```jsx {% live=true %} - - - - Align Left - - - - Align Center - - - Align Right - - - - - Justify - - -``` - -## Props - -### Disabled - -To disable the entire group, use the `isDisabled` prop. - -```jsx {% live=true %} - - Add - Edit - Delete - -``` - -To disable individual items, a list of `disabledKeys` can be provided. - -```jsx {% live=true %} - - Add - Edit - Delete - -``` - -### Prominence - -By default, buttons have a background and border, which works well to separate -buttons from surrounding content. Low `prominence` buttons have no “chrome” -until they’re interacted with. - -```jsx {% live=true %} - - Add - Edit - Delete + Bold + Italic + Underline ``` -High prominence buttons should be used sparingly and in isolation, so they are -not supported in groups. - -### Density +## Overflow -The `density` prop affects the gap between items. In the default case the -borders of neighbouring buttons are merged, making them contiguous. +`overflowMode="wrap"` wraps actions. With `overflowMode="collapse"`, actions +that do not fit move into an explicit overflow menu. Provide a data/render +source when items need to be transformed responsively. ```jsx {% live=true %} - - Add - Edit - Delete - -``` - -Low `prominence` groups have a reduced gap between each button. +let actions = [ + { id: 'add', label: 'Add' }, + { id: 'edit', label: 'Edit' }, + { id: 'copy', label: 'Copy' }, + { id: 'delete', label: 'Delete' }, +]; -```jsx {% live=true %} - - Add - Edit - Delete - +return ( + + {item => {item.label}} + +); ``` -### Justified - -The `isJustified` prop will divide the available horizontal space evenly among -the buttons. +## Links ```jsx {% live=true %} - - Add - Edit - Delete + + + Keystatic + + + Keystone + ``` diff --git a/design-system/pkg/src/action-group/index.ts b/design-system/pkg/src/action-group/index.ts index 9f796e09b..ca472df26 100644 --- a/design-system/pkg/src/action-group/index.ts +++ b/design-system/pkg/src/action-group/index.ts @@ -1,7 +1,5 @@ 'use client'; -export { Item } from 'react-stately/Item'; +export { ActionGroup, ActionGroupItem } from './ActionGroup'; -export { ActionGroup } from './ActionGroup'; - -export type { ActionGroupProps } from './types'; +export type { ActionGroupItemProps, ActionGroupProps } from './types'; diff --git a/design-system/pkg/src/action-group/stories/ActionGroup.stories.tsx b/design-system/pkg/src/action-group/stories/ActionGroup.stories.tsx index fbcbee4de..2869afb1d 100644 --- a/design-system/pkg/src/action-group/stories/ActionGroup.stories.tsx +++ b/design-system/pkg/src/action-group/stories/ActionGroup.stories.tsx @@ -21,7 +21,7 @@ import { Tooltip, TooltipTrigger } from '@keystar/ui/tooltip'; import { Kbd, Text } from '@keystar/ui/typography'; import { PropsWithChildren } from 'react'; -import { ActionGroup, ActionGroupProps, Item } from '..'; +import { ActionGroup, ActionGroupProps, ActionGroupItem } from '..'; import { css } from '@keystar/ui/style'; let onSelectionChange = action('onSelectionChange'); @@ -65,9 +65,9 @@ export default { export const Default = () => ( - Add - Delete - Edit + Add + Delete + Edit ); @@ -77,9 +77,9 @@ Default.story = { export const ProminenceLow = () => ( - Add - Delete - Edit + Add + Delete + Edit ); @@ -89,15 +89,19 @@ ProminenceLow.story = { export const Links = () => ( - + Apple - - + + Google - - + + Microsoft - + ); @@ -194,9 +198,9 @@ SelectionModeMultipleCompact.story = { export const DynamicDefault = () => ( {item => ( - + {item.children} - + )} ); @@ -213,9 +217,9 @@ export const DynamicSingleSelection = () => ( items={editItems} > {item => ( - + {item.children} - + )} ); @@ -233,22 +237,22 @@ ManualTooltips.story = { export const OverflowModeWrap = () => ( - + Bold - - + + Italic - - + + Underline - - + + Strikethrough - + ); @@ -258,118 +262,56 @@ OverflowModeWrap.story = { }; export const OverflowModeCollapse = () => ( - - - - - Bold - B - - - - Italic - I - - - - Underline - - - - Strikethrough - - - -); - -OverflowModeCollapse.story = { - name: 'overflowMode: collapse', -}; - -export const CollapseWithLinks = () => ( - - - - Apple - - - Google - - - Microsoft - - - -); - -CollapseWithLinks.story = { - name: 'overflowMode: collapse + links', -}; - -export const ButtonLabelBehaviorHide = () => ( - - - Bold - B - - - - Italic - I - - - - Underline - - - - Strikethrough - + {item => ( + + + {item.children} + + )} ); -ButtonLabelBehaviorHide.story = { - name: 'buttonLabelBehavior: hide', +OverflowModeCollapse.story = { + name: 'overflowMode: collapse', }; -export const ButtonLabelBehaviorCollapse = () => ( +export const CollapseWithLinks = () => ( - - - Bold - B - - - - Italic - I - - - - Underline - - - - Strikethrough - + {item => ( + + {item.label} + + )} ); -ButtonLabelBehaviorCollapse.story = { - name: 'buttonLabelBehavior: collapse', +CollapseWithLinks.story = { + name: 'overflowMode: collapse + links', }; export const OverflowModeCollapseSelection = () => ( @@ -459,7 +401,11 @@ function renderText( {...props} > {items.map(itemProps => ( - + ))} ); @@ -478,14 +424,14 @@ function renderBoth( {items.map(itemProps => { let icon = iconMap[itemProps.children as keyof typeof iconMap]; return ( - {itemProps.children} - + ); })} @@ -505,13 +451,13 @@ function renderIcons( {items.map(itemProps => { let icon = iconMap[itemProps.children as keyof typeof iconMap]; return ( - - + ); })} @@ -532,12 +478,13 @@ function renderTooltips( let icon = iconMap[itemProps.children as keyof typeof iconMap]; return ( - - + {itemProps.children} ); @@ -550,30 +497,18 @@ function renderCollapsibleFormatting(props = {}) { return ( - - - Bold - B - - - - Italic - I - - - - Underline - - - - Strikethrough - + {item => ( + + + {item.children} + + )} ); } @@ -582,30 +517,25 @@ function renderCollapsibleAlignment(props = {}) { return ( - - - Align Left - - - - Align Center - - - Align Right - - - - - Justify - + {item => ( + + + {item.label} + + )} ); } diff --git a/design-system/pkg/src/action-group/test/ActionGroup.test.tsx b/design-system/pkg/src/action-group/test/ActionGroup.test.tsx index 610e3cb7a..b71cdd71b 100644 --- a/design-system/pkg/src/action-group/test/ActionGroup.test.tsx +++ b/design-system/pkg/src/action-group/test/ActionGroup.test.tsx @@ -1,18 +1,56 @@ -import { renderWithProvider } from '#test-utils'; +import { firePress, renderWithProvider } from '#test-utils'; -import { ActionGroup, Item } from '..'; -import { expect, describe, it } from '@jest/globals'; +import { ActionGroup, ActionGroupItem } from '..'; +import { expect, describe, it, jest } from '@jest/globals'; +import { Text } from '@keystar/ui/typography'; describe('action-group/ActionGroup', () => { it('renders', () => { const { getByRole, getAllByRole } = renderWithProvider( - Child one - Child two + Child one + Child two ); expect(getByRole('toolbar')).toBeTruthy(); expect(getAllByRole('button')).toHaveLength(2); }); + + it('supports actions and selection without collection state adapters', () => { + let onAction = jest.fn(); + let onSelectionChange = jest.fn(); + let result = renderWithProvider( + + Bold + Italic + + ); + + firePress(result.getByRole('button', { name: 'Bold' })); + expect(onAction).toHaveBeenCalledWith('bold'); + expect(onSelectionChange).toHaveBeenCalledWith(new Set(['bold'])); + expect(result.getByRole('button', { name: 'Bold' })).toHaveAttribute( + 'aria-pressed', + 'true' + ); + }); + + it('keeps hidden labels accessible', () => { + let result = renderWithProvider( + + + icon + Bold + + + ); + + expect(result.getByRole('button', { name: 'Bold' })).toBeVisible(); + }); }); diff --git a/design-system/pkg/src/action-group/types.ts b/design-system/pkg/src/action-group/types.ts index 63de225ba..164f51ad6 100644 --- a/design-system/pkg/src/action-group/types.ts +++ b/design-system/pkg/src/action-group/types.ts @@ -1,41 +1,40 @@ -import { AriaActionGroupProps } from 'react-aria/private/actiongroup/useActionGroup'; -import { ReactElement } from 'react'; +import type { Key } from '@react-types/shared'; +import type { AnchorHTMLAttributes, ReactElement, ReactNode } from 'react'; -import { BaseStyleProps } from '@keystar/ui/style'; +import type { BaseStyleProps } from '@keystar/ui/style'; -export type ActionGroupProps = AriaActionGroupProps & { - /** - * Sets the amount of space between buttons. - * @default 'regular' - */ +export interface ActionGroupItemProps { + id: Key; + children: ReactNode; + isDisabled?: boolean; + textValue?: string; + href?: string; + target?: AnchorHTMLAttributes['target']; + rel?: AnchorHTMLAttributes['rel']; +} + +export interface ActionGroupProps extends BaseStyleProps { + children: + | ReactElement + | null + | (ReactElement | null)[] + | ((item: T) => ReactElement | null); + items?: Iterable; density?: 'compact' | 'regular'; - /** Whether the buttons should be justified in their container. */ isJustified?: boolean; - /** - * Defines the behavior of the group when the buttons do not fit in the - * available space. When set to 'wrap', the items wrap to form a new line. - * When set to 'collapse', the items that do not fit are collapsed into a - * dropdown menu. - * @default 'wrap' - */ + isDisabled?: boolean; + disabledKeys?: Iterable; + orientation?: 'horizontal' | 'vertical'; overflowMode?: 'wrap' | 'collapse'; - /** - * Defines when the text within the buttons should be hidden and only the icon - * should be shown. When set to 'hide', the text is always shown in a tooltip. - * When set to 'collapse', the text is visible if space is available, and - * hidden when space is limited. The text is always visible when the item is - * collapsed into a menu. - * @default 'show' - */ - buttonLabelBehavior?: 'show' | 'collapse' | 'hide'; - /** - * The prominence of each button in the group. - * @default 'default' - */ + buttonLabelBehavior?: 'show' | 'hide'; prominence?: 'low' | 'default'; - /** - * The icon to display in the dropdown menu trigger button when a selectable - * group is collapsed. - */ + selectionMode?: 'none' | 'single' | 'multiple'; + selectedKeys?: Iterable; + defaultSelectedKeys?: Iterable; + disallowEmptySelection?: boolean; + onSelectionChange?: (keys: Set) => void; + onAction?: (key: Key) => void; summaryIcon?: ReactElement; -} & BaseStyleProps; + 'aria-label'?: string; + 'aria-labelledby'?: string; +} diff --git a/design-system/pkg/src/breadcrumbs/BreadcrumbItem.tsx b/design-system/pkg/src/breadcrumbs/BreadcrumbItem.tsx index 27d19ee01..ce12e91a8 100644 --- a/design-system/pkg/src/breadcrumbs/BreadcrumbItem.tsx +++ b/design-system/pkg/src/breadcrumbs/BreadcrumbItem.tsx @@ -1,27 +1,29 @@ -import { useBreadcrumbItem } from 'react-aria/useBreadcrumbs'; +import { + Breadcrumb as AriaBreadcrumb, + type BreadcrumbRenderProps, +} from 'react-aria-components/Breadcrumbs'; +import { Link } from 'react-aria-components/Link'; import { useLocale } from 'react-aria/I18nProvider'; -import { useHover } from 'react-aria/useHover'; -import { mergeProps } from 'react-aria/mergeProps'; -import React, { Fragment, useMemo, useRef, ElementType } from 'react'; +import { + type ForwardedRef, + type ReactNode, + forwardRef, + useContext, +} from 'react'; import { Icon } from '@keystar/ui/icon'; -import { chevronRightIcon } from '@keystar/ui/icon/icons/chevronRightIcon'; import { chevronLeftIcon } from '@keystar/ui/icon/icons/chevronLeftIcon'; +import { chevronRightIcon } from '@keystar/ui/icon/icons/chevronRightIcon'; import { ClassList, - FocusRing, classNames, css, - toDataAttributes, tokenSchema, + useStyleProps, } from '@keystar/ui/style'; -import { BreadcrumbItemProps as _BreadcrumbItemProps } from './types'; - -type BreadcrumbItemProps = _BreadcrumbItemProps & { - /** @private internal prop for distinguishing link behaviour. */ - isMenu?: boolean; -}; +import { BreadcrumbsStyleContext } from './context'; +import type { BreadcrumbItemProps } from './types'; export const breadcrumbsClassList = new ClassList('Breadcrumbs', [ 'item', @@ -30,106 +32,94 @@ export const breadcrumbsClassList = new ClassList('Breadcrumbs', [ 'separator', ]); -export function BreadcrumbItem(props: BreadcrumbItemProps) { - let { children, isCurrent, isDisabled, isMenu, size = 'regular' } = props; - let { href, ...propsWithoutHref } = props; - +function BreadcrumbItemImpl( + props: BreadcrumbItemProps, + forwardedRef: ForwardedRef +) { + let { children, href, size: sizeProp, ...otherProps } = props; + let { size: contextSize } = useContext(BreadcrumbsStyleContext); + let size = sizeProp ?? contextSize; let { direction } = useLocale(); - let ref = useRef(null); - let ElementType: ElementType = href ? 'a' : 'span'; - let { itemProps } = useBreadcrumbItem( - { - ...propsWithoutHref, - ...(href ? { href } : {}), - elementType: ElementType, - }, - ref - ); - let { hoverProps, isHovered } = useHover(props); - let icon = useMemo(() => { - return direction === 'rtl' ? chevronLeftIcon : chevronRightIcon; - }, [direction]); - - // if this item contains a menu button, then it shouldn't be a link - if (isMenu) { - itemProps = {}; - } - + let styleProps = useStyleProps(props); return ( - - - + {states => ( + <> + + {resolveChildren(children, states)} + + {!states.isCurrent && ( + )} - > - {children} - - - {!isCurrent && ( - + )} - + ); } + +export const BreadcrumbItem = forwardRef(BreadcrumbItemImpl); + +function resolveChildren( + children: BreadcrumbItemProps['children'], + states: BreadcrumbRenderProps +): ReactNode { + return typeof children === 'function' + ? (children as (states: BreadcrumbRenderProps) => ReactNode)(states) + : children; +} diff --git a/design-system/pkg/src/breadcrumbs/Breadcrumbs.tsx b/design-system/pkg/src/breadcrumbs/Breadcrumbs.tsx index 969717b8d..4cc7cab60 100644 --- a/design-system/pkg/src/breadcrumbs/Breadcrumbs.tsx +++ b/design-system/pkg/src/breadcrumbs/Breadcrumbs.tsx @@ -1,265 +1,42 @@ -import { useBreadcrumbs } from 'react-aria/useBreadcrumbs'; -import { - useLayoutEffect, - Children, - ForwardedRef, - Key, - ReactElement, - forwardRef, - isValidElement, - useCallback, - useRef, - useState, - Ref, -} from 'react'; -import { useObjectRef } from 'react-aria/useObjectRef'; -import { useResizeObserver } from 'react-aria/private/utils/useResizeObserver'; -import { useValueEffect } from 'react-aria/private/utils/useValueEffect'; +import { Breadcrumbs as AriaBreadcrumbs } from 'react-aria-components/Breadcrumbs'; +import { type ForwardedRef, type ReactElement, forwardRef } from 'react'; -import { ActionButton } from '@keystar/ui/button'; -import { useProviderProps } from '@keystar/ui/core'; -import { Icon } from '@keystar/ui/icon'; -import { folderClosedIcon } from '@keystar/ui/icon/icons/folderClosedIcon'; -import { folderOpenIcon } from '@keystar/ui/icon/icons/folderOpenIcon'; -import { Menu, MenuTrigger } from '@keystar/ui/menu'; -import { classNames, css, tokenSchema, useStyleProps } from '@keystar/ui/style'; +import { classNames, css, useStyleProps } from '@keystar/ui/style'; -import { BreadcrumbItem, breadcrumbsClassList } from './BreadcrumbItem'; -import { BreadcrumbsProps } from './types'; -import { ItemProps } from '@react-types/shared'; +import { breadcrumbsClassList } from './BreadcrumbItem'; +import { BreadcrumbsStyleContext } from './context'; +import type { BreadcrumbsProps } from './types'; -const MIN_VISIBLE_ITEMS = 1; -const MAX_VISIBLE_ITEMS = 4; - -function Breadcrumbs( +function Breadcrumbs( props: BreadcrumbsProps, - ref: ForwardedRef + forwardedRef: ForwardedRef ) { - props = useProviderProps(props); - let { - children, - showRoot, - size = 'regular', - isDisabled, - onAction, - ...otherProps - } = props; - - // Not using React.Children.toArray because it mutates the key prop. - let childArray: ReactElement>[] = []; - Children.forEach(children, child => { - if (isValidElement(child)) { - childArray.push(child); - } - }); - - let domRef = useObjectRef(ref); - let listRef = useRef(null); - let [menuIsOpen, setMenuOpen] = useState(false); - - let [visibleItems, setVisibleItems] = useValueEffect(childArray.length); - - let { navProps } = useBreadcrumbs(props); - let styleProps = useStyleProps(otherProps); - - let updateOverflow = useCallback(() => { - let computeVisibleItems = (visibleItems: number): number => { - // refs may be null at runtime - let currListRef: HTMLUListElement | null = listRef.current; - if (!currListRef) { - return visibleItems; - } - - let listItems = Array.from(currListRef.children) as HTMLLIElement[]; - if (listItems.length <= 0) { - return visibleItems; - } - let containerWidth = currListRef.offsetWidth; - let isShowingMenu = childArray.length > visibleItems; - let calculatedWidth = 0; - let newVisibleItems = 0; - let maxVisibleItems = MAX_VISIBLE_ITEMS; - - if (showRoot) { - calculatedWidth += (listItems.shift() as HTMLLIElement).offsetWidth; - newVisibleItems++; - } - - if (isShowingMenu) { - calculatedWidth += (listItems.shift() as HTMLLIElement).offsetWidth; - maxVisibleItems--; - } - - if (showRoot && calculatedWidth >= containerWidth) { - newVisibleItems--; - } - - if (listItems.length > 0) { - // Ensure the last breadcrumb isn't truncated when we measure it. - let last = listItems.pop() as HTMLLIElement; - last.style.overflow = 'visible'; - - calculatedWidth += last.offsetWidth; - if (calculatedWidth < containerWidth) { - newVisibleItems++; - } - - last.style.overflow = ''; - } - - for (let breadcrumb of listItems.reverse()) { - calculatedWidth += breadcrumb.offsetWidth; - if (calculatedWidth < containerWidth) { - newVisibleItems++; - } - } - - return Math.max( - MIN_VISIBLE_ITEMS, - Math.min(maxVisibleItems, newVisibleItems) - ); - }; - - setVisibleItems(function* () { - // Update to show all items. - yield childArray.length; - - // Measure, and update to show the items that fit. - let newVisibleItems = computeVisibleItems(childArray.length); - yield newVisibleItems; - - // If the number of items is less than the number of children, - // then update again to ensure that the menu fits. - if (newVisibleItems < childArray.length && newVisibleItems > 1) { - yield computeVisibleItems(newVisibleItems); - } - }); - }, [childArray.length, setVisibleItems, showRoot]); - - useResizeObserver({ ref: domRef, onResize: updateOverflow }); - - let lastChildren = useRef(null); - useLayoutEffect(() => { - if (children !== lastChildren.current) { - lastChildren.current = children; - updateOverflow(); - } - }); - - let contents = childArray; - if (childArray.length > visibleItems) { - let selectedItem = childArray[childArray.length - 1]; - let selectedKey = selectedItem.key ?? childArray.length - 1; - let onMenuAction = (key: Key) => { - // Don't fire onAction when clicking on the last item - if (key !== selectedKey && onAction) { - onAction(key); - } - }; - - let menuItem = ( - - - - - - - {childArray} - - - - ); - - contents = [menuItem]; - let breadcrumbs = [...childArray]; - let endItems = visibleItems; - if (showRoot && visibleItems > 1) { - let rootItem = breadcrumbs.shift(); - if (rootItem) { - contents.unshift(rootItem); - } - endItems--; - } - contents.push(...breadcrumbs.slice(-endItems)); - } - - let lastIndex = contents.length - 1; - let breadcrumbItems = contents.map((child, index) => { - let isCurrent = index === lastIndex; - let key = child.key ?? index; - let onPress = () => { - if (onAction) { - onAction(key); - } - }; - - return ( -
  • - - {child.props.children} - -
  • - ); - }); - + let { size = 'regular', ...otherProps } = props; + let styleProps = useStyleProps(props); return ( - + /> + ); } -// forwardRef doesn't support generic parameters, so cast the result to the correct type -// https://stackoverflow.com/questions/58469229/react-with-typescript-generics-while-using-react-forwardref - -/** - * Breadcrumbs show hierarchy and navigational context for a user's location - * within an application. - */ -const _Breadcrumbs: ( - props: BreadcrumbsProps & { ref?: Ref } -) => ReactElement = forwardRef(Breadcrumbs) as any; +const _Breadcrumbs = forwardRef(Breadcrumbs) as ( + props: BreadcrumbsProps & { ref?: ForwardedRef } +) => ReactElement; export { _Breadcrumbs as Breadcrumbs }; diff --git a/design-system/pkg/src/breadcrumbs/context.ts b/design-system/pkg/src/breadcrumbs/context.ts new file mode 100644 index 000000000..b04a915f7 --- /dev/null +++ b/design-system/pkg/src/breadcrumbs/context.ts @@ -0,0 +1,3 @@ +import { createContext } from 'react'; + +export const BreadcrumbsStyleContext = createContext({ size: 'regular' }); diff --git a/design-system/pkg/src/breadcrumbs/docs/index.mdoc b/design-system/pkg/src/breadcrumbs/docs/index.mdoc index 47676affc..11e6ca9a1 100644 --- a/design-system/pkg/src/breadcrumbs/docs/index.mdoc +++ b/design-system/pkg/src/breadcrumbs/docs/index.mdoc @@ -9,131 +9,51 @@ category: Navigation ## Example ```jsx {% live=true %} - - Dashboard - Posts - Some post title + + Dashboard + Posts + Some post title ``` -## Patterns +## Collections -### Collections - -Breadcrumbs implements `react-stately` -[collection components](https://react-spectrum.adobe.com/react-stately/collections.html), -accepting only static children. - -`Breadcrumbs` accepts `` elements as children, each with a `key` prop, -which is passed to the `onAction` handler to identify the selected item. +`Breadcrumbs` accepts explicit `BreadcrumbItem` children. It supports both +static composition and the React Aria Components dynamic collection API. ```jsx {% live=true %} let items = [ - { key: 'dashboard', label: 'Dashboard' }, - { key: 'posts', label: 'Posts' }, - { key: 'some-post-title', label: 'Some post title' }, + { id: 'dashboard', label: 'Dashboard', href: '/' }, + { id: 'posts', label: 'Posts', href: '/posts' }, + { id: 'current', label: 'Some post title' }, ]; -let [itemKey, setItemKey] = React.useState(); return ( - - - {items.map(item => ( - {item.label} - ))} - - Last key: {itemKey} - + + {item => ( + {item.label} + )} + ); ``` -### Links - -By default, interacting with an item in Breadcrumbs triggers `onAction`. Items -may also be links to another page or website. This can be achieved by passing -an `href` prop to the `` component. - -```jsx {% live=true %} - - Home - Breadcrumbs - -``` - -#### Client-side routing - -The `` component works with frameworks and client-side routers like -[Next.js](https://nextjs.org/) and [React Router](https://reactrouter.com/en/main). -As with other components that support links, this works via the -[Provider](/package/core#provider) component at the root of your app. See the -[client-side routing](/package/core#client-side-routing) section to learn how -to set this up. - -### Overflow - -Breadcrumbs collapses items into a menu when space is limited. It will only show -a maximum of 4 visible items including the root and menu button, if either are -visible. Note that the last breadcrumb item will automatically truncate with an -ellipsis instead of collapsing into the menu. - -Resize your browser window to see the above behavior in the examples below. - -```jsx {% live=true %} - - Home - Products - Tools - Power Tools - Drills - Impact Drill Drivers - -``` - -If the root item cannot be rendered in the available horizontal space, it will -be collapsed into the menu regardless of `showRoot`. +The final item is the current page. Its `href` is omitted from the rendered +link and `aria-current="page"` is applied automatically. -```jsx {% live=true %} - - Home - Products - Tools - Power Tools - Drills - Impact Drill Drivers - -``` +## Client-side routing -## Props +`BreadcrumbItem` works with frameworks and client-side routers through the +[Provider](/package/core#provider) at the root of your application. See +[client-side routing](/package/core#client-side-routing) for configuration. -### Disabled +## Sizes -Breadcrumbs in a disabled state shows items, but indicates that navigation is -not available. This can be used to maintain layout continuity. +Use the `size` prop on `Breadcrumbs` to set the text size for all items. An +individual `BreadcrumbItem` can override it. ```jsx {% live=true %} - - Dashboard - Posts - Some post title + + Home + Breadcrumbs ``` - -### Size - -Breadcrumbs accept a `size` prop, which aligns with the -[Text](/package/typography/text) component. - -```jsx {% live=true %} - - - Dashboard - Posts - Some post title - - - Dashboard - Posts - Some post title - - -``` diff --git a/design-system/pkg/src/breadcrumbs/index.ts b/design-system/pkg/src/breadcrumbs/index.ts index a3a865fb8..7f8b4817a 100644 --- a/design-system/pkg/src/breadcrumbs/index.ts +++ b/design-system/pkg/src/breadcrumbs/index.ts @@ -1,7 +1,6 @@ 'use client'; -export { Item } from 'react-stately/Item'; - export { Breadcrumbs } from './Breadcrumbs'; +export { BreadcrumbItem } from './BreadcrumbItem'; -export type { BreadcrumbsProps } from './types'; +export type { BreadcrumbItemProps, BreadcrumbsProps } from './types'; diff --git a/design-system/pkg/src/breadcrumbs/stories/Breadcrumbs.stories.tsx b/design-system/pkg/src/breadcrumbs/stories/Breadcrumbs.stories.tsx index da40c04e6..0039ae5b2 100644 --- a/design-system/pkg/src/breadcrumbs/stories/Breadcrumbs.stories.tsx +++ b/design-system/pkg/src/breadcrumbs/stories/Breadcrumbs.stories.tsx @@ -1,7 +1,7 @@ import { Box, VStack } from '@keystar/ui/layout'; import { StoryFn, StoryObj, action } from '@keystar/ui-storybook'; -import { Breadcrumbs, BreadcrumbsProps, Item } from '..'; +import { Breadcrumbs, BreadcrumbsProps, BreadcrumbItem } from '..'; import { ReactNode } from 'react'; export type BreadcrumbsStory = StoryObj; @@ -44,9 +44,9 @@ export const Sizes = () => ( {sizes.map(size => ( - Size: {size} - Second - Third + Size: {size} + Second + Third ))} @@ -54,23 +54,27 @@ export const Sizes = () => ( export const ManyItems = () => ( - Home - Products - Tools - Power Tools - Drills - Impact Drill Drivers + Home + Products + Tools + Power Tools + Drills + + Impact Drill Drivers + ); export const ShowRoot = () => ( - - Home - Products - Tools - Power Tools - Drills - Impact Drill Drivers + + Home + Products + Tools + Power Tools + Drills + + Impact Drill Drivers + ); @@ -86,26 +90,28 @@ export const Resizable = () => ( }} > - First item with long text - Second item with long text - Third item with long text + First item with long text + Second item with long text + Third item with long text ); export const SingleItem = () => ( - Dashboard + Dashboard ); export const Links = () => ( - Example.com - Foo - Bar - Baz - Qux + Example.com + Foo + Bar + Baz + + Qux + ); @@ -113,12 +119,14 @@ type Render = (() => ReactNode) & { storyName?: string; }; -function render(props: Partial> = {}): Render { +function render( + props: Partial> = {} +): Render { return () => ( - Dashboard - Posts - Some post title + Dashboard + Posts + Some post title ); } diff --git a/design-system/pkg/src/breadcrumbs/test/Breadcrumbs.test.tsx b/design-system/pkg/src/breadcrumbs/test/Breadcrumbs.test.tsx index 19bc4f2ca..05443edab 100644 --- a/design-system/pkg/src/breadcrumbs/test/Breadcrumbs.test.tsx +++ b/design-system/pkg/src/breadcrumbs/test/Breadcrumbs.test.tsx @@ -1,216 +1,89 @@ -import { beforeEach, expect, describe, it, jest } from '@jest/globals'; +import { describe, expect, it } from '@jest/globals'; -import { Breadcrumbs, Item } from '..'; -import { firePress, renderWithProvider, within } from '#test-utils'; +import { renderWithProvider } from '#test-utils'; +import { BreadcrumbItem, Breadcrumbs } from '..'; describe('breadcrumbs/Breadcrumbs', () => { - beforeEach(() => { - // avoid issues when measuring the width of the breadcrumbs - jest - .spyOn(HTMLElement.prototype, 'offsetWidth', 'get') - .mockImplementation(function () { - // @ts-expect-error - if (this instanceof HTMLUListElement) { - return 500; - } - - return 100; - }); - }); - - it('handles defaults', function () { - let { getByLabelText } = renderWithProvider( - - Folder 1 + it('renders an accessible static collection', () => { + let result = renderWithProvider( + + Folder 1 + Folder 2 + Folder 3 ); - let breadcrumbs = getByLabelText('breadcrumbs-test'); - expect(breadcrumbs).toHaveAttribute('id', 'breadcrumbs-id'); - }); - - it('handles multiple items', () => { - let { getByText } = renderWithProvider( - - Folder 1 - Folder 2 - Folder 3 - + let list = result.getByRole('list', { name: 'Breadcrumbs' }); + expect(list).toHaveAttribute('id', 'breadcrumbs-id'); + expect(result.getAllByRole('listitem')).toHaveLength(3); + expect(result.getByText('Folder 1')).toHaveAttribute('href', '/one'); + expect(result.getByText('Folder 2')).toHaveAttribute('href', '/one/two'); + expect(result.getByText('Folder 3')).toHaveAttribute( + 'aria-current', + 'page' ); - let item1 = getByText('Folder 1'); - expect(item1.tabIndex).toBe(0); - expect(item1).not.toHaveAttribute('aria-current'); - let item2 = getByText('Folder 2'); - expect(item2.tabIndex).toBe(0); - expect(item2).not.toHaveAttribute('aria-current'); - let item3 = getByText('Folder 3'); - expect(item3.tabIndex).toBe(-1); - expect(item3).toHaveAttribute('aria-current', 'page'); + expect(result.getByText('Folder 3')).not.toHaveAttribute('href'); }); - it('handles single item and showRoot', () => { - let { getByText } = renderWithProvider( - - Folder 1 + it('renders a dynamic collection', () => { + let items = [ + { id: 'one', label: 'Folder 1', href: '/one' }, + { id: 'two', label: 'Folder 2', href: '/one/two' }, + ]; + let result = renderWithProvider( + + {item => {item.label}} ); - let item = getByText('Folder 1'); - expect(item).toBeTruthy(); - expect(item.tabIndex).toBe(-1); - }); - it('shows four items with no menu', () => { - let { getByText, getByRole } = renderWithProvider( - - Folder 1 - Folder 2 - Folder 3 - Folder 4 - + expect(result.getAllByRole('listitem')).toHaveLength(2); + expect(result.getByText('Folder 1')).toHaveAttribute('href', '/one'); + expect(result.getByText('Folder 2')).toHaveAttribute( + 'aria-current', + 'page' ); - let { children } = getByRole('list'); - expect(within(children[0] as HTMLElement).queryByRole('button')).toBeNull(); - expect(getByText('Folder 1')).toBeTruthy(); - expect(getByText('Folder 2')).toBeTruthy(); - expect(getByText('Folder 3')).toBeTruthy(); - expect(getByText('Folder 4')).toBeTruthy(); }); - it('shows a maximum of 4 items', () => { - let { getByText, getByRole } = renderWithProvider( - - Folder 1 - Folder 2 - Folder 3 - Folder 4 - Folder 5 - - ); - let { children } = getByRole('list'); - expect(within(children[0] as HTMLElement).getByRole('button')).toBeTruthy(); - expect(() => getByText('Folder 1')).toThrow(); - expect(() => getByText('Folder 2')).toThrow(); - expect(getByText('Folder 3')).toBeTruthy(); - expect(getByText('Folder 4')).toBeTruthy(); - expect(getByText('Folder 5')).toBeTruthy(); - }); - - it('shows a maximum of 4 items with showRoot', () => { - let { getByText, getByRole } = renderWithProvider( - - Folder 1 - Folder 2 - Folder 3 - Folder 4 - Folder 5 - - ); - let { children } = getByRole('list'); - expect(getByText('Folder 1')).toBeTruthy(); - expect(within(children[1] as HTMLElement).getByRole('button')).toBeTruthy(); - expect(() => getByText('Folder 2')).toThrow(); - expect(() => getByText('Folder 3')).toThrow(); - expect(getByText('Folder 4')).toBeTruthy(); - expect(getByText('Folder 5')).toBeTruthy(); - }); - - it('handles isDisabled', () => { - let { getByText } = renderWithProvider( - - Folder 1 - Folder 2 - - ); - - let item1 = getByText('Folder 1'); - expect(item1).toHaveAttribute('aria-disabled', 'true'); - let item2 = getByText('Folder 2'); - expect(item2).toHaveAttribute('aria-disabled', 'true'); - }); - - it('supports aria-label', function () { - let { getByRole } = renderWithProvider( - - Folder 1 - - ); - let breadcrumbs = getByRole('navigation'); - expect(breadcrumbs).toHaveAttribute('aria-label', 'Test'); - }); - - it('supports aria-labelledby', function () { - let { getByRole } = renderWithProvider( - <> - Test - - Folder 1 - - - ); - let breadcrumbs = getByRole('navigation'); - expect(breadcrumbs).toHaveAttribute('aria-labelledby', 'test'); - }); - - it('supports aria-describedby', function () { - let { getByRole } = renderWithProvider( + it('forwards accessibility and custom DOM props', () => { + let result = renderWithProvider( <> - Test - - Folder 1 + Location + Current folder + + Folder 1 ); - let breadcrumbs = getByRole('navigation'); - expect(breadcrumbs).toHaveAttribute('aria-describedby', 'test'); - }); - it('supports custom props', function () { - let { getByRole } = renderWithProvider( - - Folder 1 - - ); - let breadcrumbs = getByRole('navigation'); - expect(breadcrumbs).toHaveAttribute('data-testid', 'test'); + let list = result.getByTestId('breadcrumbs'); + expect(list).toHaveAttribute('aria-labelledby', 'breadcrumb-label'); + expect(list).toHaveAttribute('aria-describedby', 'breadcrumb-description'); }); - it('should support links', function () { - let { getByRole, getAllByRole } = renderWithProvider( - - Example.com - Foo - Bar - Baz - Qux + it('disables all items when the collection is disabled', () => { + let result = renderWithProvider( + + Folder 1 + Folder 2 ); - let links = getAllByRole('link'); - expect(links).toHaveLength(3); - expect(links[0]).toHaveAttribute('href', 'https://example.com/foo/bar'); - expect(links[1]).toHaveAttribute('href', 'https://example.com/foo/bar/baz'); - expect(links[2]).toHaveAttribute( - 'href', - 'https://example.com/foo/bar/baz/qux' - ); - - let menuButton = getByRole('button'); - firePress(menuButton); - - let menu = getByRole('menu'); - let items = within(menu).getAllByRole('menuitemradio'); - expect(items).toHaveLength(5); - expect(items[0].tagName).toBe('A'); - expect(items[0]).toHaveAttribute('href', 'https://example.com'); + for (let link of result.getAllByRole('link')) { + expect(link).toHaveAttribute('aria-disabled', 'true'); + } }); - it('does not pass an empty href to the rendered element', function () { - let { getByText } = renderWithProvider( - - Folder 1 + it('does not pass an empty href to the rendered element', () => { + let result = renderWithProvider( + + Folder 1 ); - expect(getByText('Folder 1')).not.toHaveAttribute('href'); + expect(result.getByText('Folder 1')).not.toHaveAttribute('href'); }); }); diff --git a/design-system/pkg/src/breadcrumbs/types.ts b/design-system/pkg/src/breadcrumbs/types.ts index e71c21618..f6937837e 100644 --- a/design-system/pkg/src/breadcrumbs/types.ts +++ b/design-system/pkg/src/breadcrumbs/types.ts @@ -1,32 +1,22 @@ -import { - AriaBreadcrumbsProps, - BreadcrumbItemProps as ReactBreadcrumbItemProps, -} from 'react-aria/useBreadcrumbs'; -import { ItemProps } from '@react-types/shared'; -import { Key, ReactElement } from 'react'; +import type { + BreadcrumbProps as AriaBreadcrumbProps, + BreadcrumbsProps as AriaBreadcrumbsProps, +} from 'react-aria-components/Breadcrumbs'; -import { BaseStyleProps } from '@keystar/ui/style'; -import { TextProps } from '@keystar/ui/typography'; +import type { BaseStyleProps } from '@keystar/ui/style'; +import type { TextProps } from '@keystar/ui/typography'; type BreadcrumbSize = NonNullable; -export type BreadcrumbsProps = AriaBreadcrumbsProps & - BaseStyleProps & { - /** The breadcrumb items. */ - children: ReactElement> | ReactElement>[]; - /** Whether the Breadcrumbs are disabled. */ - isDisabled?: boolean; - /** Called when an item is acted upon (usually selection via press). */ - onAction?: (key: Key) => void; - /** Whether to always show the root item if the items are collapsed. */ - showRoot?: boolean; - /** - * Size of the Breadcrumbs including spacing and layout. - * @default 'regular' - */ - size?: BreadcrumbSize; - }; +export interface BreadcrumbsProps + extends Omit, 'className' | 'style'>, + BaseStyleProps { + size?: BreadcrumbSize; +} -export type BreadcrumbItemProps = ReactBreadcrumbItemProps & { +export interface BreadcrumbItemProps + extends Omit, + BaseStyleProps { + href?: string; size?: BreadcrumbSize; -}; +} diff --git a/design-system/pkg/src/checkbox/Checkbox.tsx b/design-system/pkg/src/checkbox/Checkbox.tsx index 1027e66bc..e7b64b064 100644 --- a/design-system/pkg/src/checkbox/Checkbox.tsx +++ b/design-system/pkg/src/checkbox/Checkbox.tsx @@ -128,7 +128,10 @@ function CheckboxInner( )} /> - + {children && ( @@ -146,7 +149,8 @@ function CheckboxInner( let sizeToken = tokenSchema.size.element.xsmall; type IndicatorProps = Pick; -const Indicator = (props: IndicatorProps) => { +/** @private A presentational checkbox indicator for state-managed checkbox primitives. */ +export const CheckboxIndicator = (props: IndicatorProps) => { let { isIndeterminate, prominence } = props; return ( @@ -199,6 +203,10 @@ const Indicator = (props: IndicatorProps) => { boxShadow: `0 0 0 ${tokenSchema.size.alias.focusRing} ${tokenSchema.color.alias.focusRing}`, margin: `calc(${tokenSchema.size.alias.focusRingGap} * -1)`, }, + 'label[data-focus-visible] &::after': { + boxShadow: `0 0 0 ${tokenSchema.size.alias.focusRing} ${tokenSchema.color.alias.focusRing}`, + margin: `calc(${tokenSchema.size.alias.focusRingGap} * -1)`, + }, // border / background '&::before': { @@ -218,12 +226,25 @@ const Indicator = (props: IndicatorProps) => { borderColor: tokenSchema.color.alias.borderDisabled, }, }, + 'label[data-disabled] &': { + color: tokenSchema.color.alias.foregroundDisabled, + '&::before': { + backgroundColor: tokenSchema.color.alias.borderDisabled, + borderColor: tokenSchema.color.alias.borderDisabled, + }, + }, 'input[type="checkbox"]:enabled:hover + &::before': { borderColor: tokenSchema.color.scale.slate9, }, + 'label:not([data-disabled])[data-hovered] &::before': { + borderColor: tokenSchema.color.scale.slate9, + }, 'input[type="checkbox"]:enabled:active + &::before': { borderColor: tokenSchema.color.scale.slate10, }, + 'label:not([data-disabled])[data-pressed] &::before': { + borderColor: tokenSchema.color.scale.slate10, + }, // checked states 'input[type="checkbox"]:checked + &, input[type="checkbox"]:indeterminate + &': @@ -237,18 +258,40 @@ const Indicator = (props: IndicatorProps) => { transform: `scale(1)`, }, }, + 'label[data-selected] &, label[data-indeterminate] &': { + '&::before': { + borderWidth: `calc(${sizeToken} / 2)`, + }, + + [checkboxClassList.selector('indicator')]: { + opacity: 1, + transform: `scale(1)`, + }, + }, 'input[type="checkbox"]:enabled:checked + &::before, input[type="checkbox"]:enabled:indeterminate + &::before': { borderColor: 'var(--selected-idle-bg)', }, + 'label:not([data-disabled])[data-selected] &::before, label:not([data-disabled])[data-indeterminate] &::before': + { + borderColor: 'var(--selected-idle-bg)', + }, 'input[type="checkbox"]:enabled:checked:hover + &::before, input[type="checkbox"]:enabled:indeterminate:hover + &::before': { borderColor: 'var(--selected-hover-bg)', }, + 'label:not([data-disabled])[data-hovered][data-selected] &::before, label:not([data-disabled])[data-hovered][data-indeterminate] &::before': + { + borderColor: 'var(--selected-hover-bg)', + }, 'input[type="checkbox"]:enabled:checked:active + &::before, input[type="checkbox"]:enabled:indeterminate:active + &::before': { borderColor: 'var(--selected-pressed-bg)', }, + 'label:not([data-disabled])[data-pressed][data-selected] &::before, label:not([data-disabled])[data-pressed][data-indeterminate] &::before': + { + borderColor: 'var(--selected-pressed-bg)', + }, }) )} > diff --git a/design-system/pkg/src/checkbox/index.ts b/design-system/pkg/src/checkbox/index.ts index 53c471e0b..97290813c 100644 --- a/design-system/pkg/src/checkbox/index.ts +++ b/design-system/pkg/src/checkbox/index.ts @@ -1,6 +1,6 @@ 'use client'; -export { Checkbox } from './Checkbox'; +export { Checkbox, CheckboxIndicator } from './Checkbox'; export { CheckboxGroup } from './CheckboxGroup'; export type { CheckboxProps, CheckboxGroupProps } from './types'; diff --git a/design-system/pkg/src/combobox/Combobox.tsx b/design-system/pkg/src/combobox/Combobox.tsx index 000fafb71..28376d270 100644 --- a/design-system/pkg/src/combobox/Combobox.tsx +++ b/design-system/pkg/src/combobox/Combobox.tsx @@ -1,365 +1,16 @@ -import { useComboBox } from 'react-aria/useComboBox'; -import { useFilter } from 'react-aria/useFilter'; -import { useLocalizedStringFormatter } from 'react-aria/useLocalizedStringFormatter'; -import { PressResponder } from 'react-aria/private/interactions/PressResponder'; -import React, { - useLayoutEffect, - CSSProperties, - ForwardedRef, - InputHTMLAttributes, - ReactElement, - RefObject, - useCallback, - useEffect, - useRef, - useState, -} from 'react'; -import { useObjectRef } from 'react-aria/useObjectRef'; -import { useResizeObserver } from 'react-aria/private/utils/useResizeObserver'; -import { useComboBoxState } from 'react-stately/useComboBoxState'; -import { AriaButtonProps } from 'react-aria/useButton'; -import { LoadingState } from '@react-types/shared'; +import { type ForwardedRef, type ReactElement, forwardRef } from 'react'; -import { FieldButton } from '@keystar/ui/button'; -import { useProviderProps } from '@keystar/ui/core'; -import { FieldPrimitive } from '@keystar/ui/field'; -import { Icon } from '@keystar/ui/icon'; -import { chevronDownIcon } from '@keystar/ui/icon/icons/chevronDownIcon'; -import { Flex } from '@keystar/ui/layout'; -import { ListBoxBase, listStyles, useListBoxLayout } from '@keystar/ui/listbox'; -import { Popover } from '@keystar/ui/overlays'; -import { ProgressCircle } from '@keystar/ui/progress'; -import { - FocusRing, - css, - tokenSchema, - useIsMobileDevice, -} from '@keystar/ui/style'; -import { - TextFieldPrimitive, - validateTextFieldProps, -} from '@keystar/ui/text-field'; -import { Text } from '@keystar/ui/typography'; - -import { comboboxClassList } from './class-list'; -import localizedMessages from './l10n'; -import { MobileCombobox } from './MobileCombobox'; -import { ComboboxProps } from './types'; +import { ComboboxBase } from './ComboboxBase'; +import type { ComboboxProps } from './types'; function Combobox( props: ComboboxProps, forwardedRef: ForwardedRef ) { - props = useProviderProps(props); - // FIXME - props = validateTextFieldProps(props as any) as typeof props; - - let isMobile = useIsMobileDevice(); - if (isMobile) { - // menuTrigger=focus/manual don't apply to mobile combobox - return ; - } else { - // @ts-expect-error FIXME: 'T' could be instantiated with an arbitrary type which could be unrelated to 'unknown'. - return ; - } + return ComboboxBase(props, forwardedRef, 'single'); } -const ComboboxBase = React.forwardRef(function ComboboxBase( - props: ComboboxProps, - forwardedRef: ForwardedRef -) { - let { - align = 'start', - menuTrigger = 'input', - shouldFlip = true, - direction = 'bottom', - loadingState, - menuWidth, - onLoadMore, - } = props; - - let isAsync = loadingState != null; - let buttonRef = useRef(null); - let inputRef = useRef(null); - let listBoxRef = useRef(null); - let popoverRef = useRef(null); - let fieldRef = useObjectRef(forwardedRef); - - let { contains } = useFilter({ sensitivity: 'base' }); - let state = useComboBoxState({ - ...props, - defaultFilter: contains, - allowsEmptyCollection: isAsync, - }); - let layout = useListBoxLayout(); - - let { - buttonProps, - inputProps, - listBoxProps, - labelProps, - descriptionProps, - errorMessageProps, - } = useComboBox( - { - ...props, - layoutDelegate: layout, - buttonRef, - popoverRef, - listBoxRef, - inputRef, - menuTrigger, - }, - state - ); - - let popoverStyle = usePopoverStyles({ - menuWidth, - buttonRef, - inputRef, - fieldRef, - }); - - return ( - <> - - - - - - isAsync && - } - /> - - - ); -}); - -export function ComboboxEmptyState(props: { loadingState?: LoadingState }) { - let stringFormatter = useLocalizedStringFormatter(localizedMessages); - return ( - - - {props.loadingState === 'loading' - ? stringFormatter.format('loading') - : stringFormatter.format('noResults')} - - - ); -} - -export function usePopoverStyles(props: { - menuWidth?: number; - buttonRef: RefObject; - inputRef: RefObject; - fieldRef: RefObject; -}) { - const { buttonRef, inputRef, fieldRef, menuWidth: menuWidthProp } = props; - - // Measure the width of the input and the button to inform the width of the menu (below). - let [menuWidth, setMenuWidth] = useState(); - - let onResize = useCallback(() => { - if (buttonRef.current && inputRef.current) { - let buttonWidth = buttonRef.current.offsetWidth; - let inputWidth = inputRef.current.offsetWidth; - - setMenuWidth(inputWidth + buttonWidth); - } - }, [buttonRef, inputRef, setMenuWidth]); - - useResizeObserver({ - ref: fieldRef, - onResize: onResize, - }); - - useLayoutEffect(onResize, [onResize]); - - return { - width: menuWidth, - minWidth: menuWidthProp ?? menuWidth, - }; -} - -interface ComboboxInputProps extends ComboboxProps { - inputProps: InputHTMLAttributes; - inputRef: RefObject; - triggerProps: AriaButtonProps; - triggerRef: RefObject; - style?: CSSProperties; - isOpen?: boolean; -} - -/** @private Used by multi variant. */ -export const ComboboxInput = React.forwardRef(function ComboboxInput( - props: ComboboxInputProps, - forwardedRef: ForwardedRef -) { - let { - isDisabled, - inputProps, - inputRef, - triggerProps, - triggerRef, - autoFocus, - style, - loadingState, - isOpen, - menuTrigger, - } = props; - let stringFormatter = useLocalizedStringFormatter(localizedMessages); - let timeoutRef = useRef(undefined); - let [showLoading, setShowLoading] = useState(false); - - let loadingCircle = ( - - - - ); - - let isLoading = loadingState === 'loading' || loadingState === 'filtering'; - let inputValue = inputProps.value; - let lastInputValue = useRef(inputValue); - useEffect(() => { - if (isLoading && !showLoading) { - if (!timeoutRef.current) { - timeoutRef.current = setTimeout(() => { - setShowLoading(true); - }, 500); - } - - // If user is typing, clear the timer and restart since it is a new request - if (inputValue !== lastInputValue.current) { - clearTimeout(timeoutRef.current); - timeoutRef.current = setTimeout(() => { - setShowLoading(true); - }, 500); - } - } else if (!isLoading) { - // If loading is no longer happening, clear any timers and hide the loading circle - setShowLoading(false); - clearTimeout(timeoutRef.current); - timeoutRef.current = undefined; - } - - lastInputValue.current = inputValue; - }, [isLoading, showLoading, inputValue]); - - return ( - -
    - - {showLoading && - (isOpen || menuTrigger === 'manual' || loadingState === 'loading') - ? loadingCircle - : null} - - - - - - - } - /> -
    -
    - ); -}) as ( - props: ComboboxInputProps & { ref?: ForwardedRef } -) => ReactElement; - -/** - * A combobox combines a text input with a listbox, and allows users to filter a - * list of options. - */ -const _Combobox: ( +const _Combobox = forwardRef(Combobox) as ( props: ComboboxProps & { ref?: ForwardedRef } -) => ReactElement = React.forwardRef(Combobox as any) as any; - +) => ReactElement; export { _Combobox as Combobox }; diff --git a/design-system/pkg/src/combobox/ComboboxBase.tsx b/design-system/pkg/src/combobox/ComboboxBase.tsx new file mode 100644 index 000000000..8e68f3aa4 --- /dev/null +++ b/design-system/pkg/src/combobox/ComboboxBase.tsx @@ -0,0 +1,108 @@ +import { Button as AriaButton } from 'react-aria-components/Button'; +import { + ComboBox as AriaComboBox, + type ComboBoxProps as AriaComboBoxProps, +} from 'react-aria-components/ComboBox'; +import { Input } from 'react-aria-components/Input'; +import { Popover } from 'react-aria-components/Popover'; +import type { ForwardedRef } from 'react'; + +import { FieldButton } from '@keystar/ui/button'; +import type { FieldButtonProps } from '@keystar/ui/button'; +import { FieldPrimitive } from '@keystar/ui/field'; +import { Icon } from '@keystar/ui/icon'; +import { chevronsUpDownIcon } from '@keystar/ui/icon/icons/chevronsUpDownIcon'; +import { ListBox } from '@keystar/ui/listbox'; +import { css, tokenSchema } from '@keystar/ui/style'; + +import type { ComboboxMultiProps, ComboboxProps } from './types'; + +export function ComboboxBase( + props: M extends 'multiple' ? ComboboxMultiProps : ComboboxProps, + forwardedRef: ForwardedRef, + selectionMode: M +) { + let { + align = 'start', + children, + dependencies, + direction = 'bottom', + label, + menuWidth, + shouldFlip = true, + ...comboBoxProps + } = props; + + return ( + )} + aria-label={ + comboBoxProps['aria-label'] ?? + (typeof label === 'string' ? label : undefined) + } + ref={forwardedRef} + selectionMode={selectionMode} + > + + <> +
    + + ( + + )} + > + + +
    + + {children} + + +
    +
    + ); +} diff --git a/design-system/pkg/src/combobox/ComboboxMulti.tsx b/design-system/pkg/src/combobox/ComboboxMulti.tsx index c826db792..f54d144c9 100644 --- a/design-system/pkg/src/combobox/ComboboxMulti.tsx +++ b/design-system/pkg/src/combobox/ComboboxMulti.tsx @@ -1,151 +1,16 @@ -import { useObjectRef } from 'react-aria/useObjectRef'; -import React, { ForwardedRef, ReactElement, useRef } from 'react'; +import { type ForwardedRef, type ReactElement, forwardRef } from 'react'; -import { useProviderProps } from '@keystar/ui/core'; -import { FieldPrimitive } from '@keystar/ui/field'; -import { ListBoxBase, listStyles, useListBoxLayout } from '@keystar/ui/listbox'; -import { Popover } from '@keystar/ui/overlays'; -import { useIsMobileDevice } from '@keystar/ui/style'; -import { validateTextFieldProps } from '@keystar/ui/text-field'; - -import { - ComboboxEmptyState, - ComboboxInput, - usePopoverStyles, -} from './Combobox'; -import { MobileComboboxMulti } from './MobileComboboxMulti'; -import { ComboboxMultiProps } from './types'; -import { useComboboxMultiState } from './useComboboxMultiState'; -import { useComboboxMulti } from './useComboboxMulti'; +import { ComboboxBase } from './ComboboxBase'; +import type { ComboboxMultiProps } from './types'; function ComboboxMulti( props: ComboboxMultiProps, forwardedRef: ForwardedRef ) { - props = useProviderProps(props); - // FIXME - props = validateTextFieldProps(props as any) as typeof props; - - let isMobile = useIsMobileDevice(); - if (isMobile) { - // menuTrigger=focus/manual don't apply to mobile combobox - return ( - - ); - } else { - // @ts-expect-error FIXME: 'T' could be instantiated with an arbitrary type which could be unrelated to 'unknown'. - return ; - } + return ComboboxBase(props, forwardedRef, 'multiple'); } -const ComboboxMultiBase = React.forwardRef(function ComboboxMultiBase< - T extends object, ->(props: ComboboxMultiProps, forwardedRef: ForwardedRef) { - let { - align = 'start', - // menuTrigger = 'focus', - shouldFlip = true, - direction = 'bottom', - loadingState, - menuWidth, - onLoadMore, - } = props; - - let isAsync = loadingState != null; - let buttonRef = useRef(null); - let inputRef = useRef(null); - let listBoxRef = useRef(null); - let popoverRef = useRef(null); - let fieldRef = useObjectRef(forwardedRef); - - let layoutDelegate = useListBoxLayout(); - let state = useComboboxMultiState(props); - let { - buttonProps, - descriptionProps, - errorMessageProps, - inputProps, - labelProps, - listBoxProps, - } = useComboboxMulti( - { - ...props, - buttonRef, - inputRef, - layoutDelegate, - listBoxRef, - popoverRef, - }, - state - ); - - let popoverStyle = usePopoverStyles({ - menuWidth, - buttonRef, - inputRef, - fieldRef, - }); - - return ( - <> - - {/* @ts-expect-error FIXME: not sure how to resolve this type error */} - - - - - isAsync && - } - /> - - - ); -}); - -/** - * This component is not accessible, use with caution. - * - * A multi-combobox combines a text input with a listbox, and allows users to filter a - * list of options. - */ -const _ComboboxMulti: ( +const _ComboboxMulti = forwardRef(ComboboxMulti) as ( props: ComboboxMultiProps & { ref?: ForwardedRef } -) => ReactElement = React.forwardRef(ComboboxMulti as any) as any; - +) => ReactElement; export { _ComboboxMulti as ComboboxMulti }; diff --git a/design-system/pkg/src/combobox/MobileCombobox.tsx b/design-system/pkg/src/combobox/MobileCombobox.tsx deleted file mode 100644 index f507095c1..000000000 --- a/design-system/pkg/src/combobox/MobileCombobox.tsx +++ /dev/null @@ -1,523 +0,0 @@ -import { useButton, AriaButtonProps } from 'react-aria/useButton'; -import { useComboBox } from 'react-aria/useComboBox'; -import { useDialog } from 'react-aria/useDialog'; -import { FocusScope } from 'react-aria/FocusScope'; -import { focusSafely } from 'react-aria/private/interactions/focusSafely'; -import { useFilter } from 'react-aria/useFilter'; -import { useLocalizedStringFormatter } from 'react-aria/useLocalizedStringFormatter'; -import { setInteractionModality } from 'react-aria/private/interactions/useFocusVisible'; -import { useHover } from 'react-aria/useHover'; -import { useField } from 'react-aria/useField'; -import { DismissButton } from 'react-aria/Overlay'; -import { useOverlayTrigger } from 'react-aria/useOverlayTrigger'; -import { mergeProps } from 'react-aria/mergeProps'; -import { useId } from 'react-aria/useId'; -import { useObjectRef } from 'react-aria/useObjectRef'; -import { - ComboBoxState, - useComboBoxState, -} from 'react-stately/useComboBoxState'; - -import { ValidationState } from '@react-types/shared'; -import React, { - CSSProperties, - ForwardedRef, - HTMLAttributes, - KeyboardEvent, - ReactElement, - ReactNode, - useCallback, - useEffect, - useRef, - useState, -} from 'react'; - -import { - ClearButton, - FieldButtonProps, - useFieldButton, -} from '@keystar/ui/button'; -import { useProviderProps } from '@keystar/ui/core'; -import { FieldPrimitive } from '@keystar/ui/field'; -import { Icon } from '@keystar/ui/icon'; -import { chevronDownIcon } from '@keystar/ui/icon/icons/chevronDownIcon'; -import { Flex } from '@keystar/ui/layout'; -import { ListBoxBase, useListBoxLayout } from '@keystar/ui/listbox'; -import { Tray } from '@keystar/ui/overlays'; -import { ProgressCircle } from '@keystar/ui/progress'; -import { - FocusRing, - css, - toDataAttributes, - tokenSchema, - transition, -} from '@keystar/ui/style'; -import { TextFieldPrimitive } from '@keystar/ui/text-field'; -import { Text } from '@keystar/ui/typography'; - -import localizedMessages from './l10n'; -import { ComboboxProps } from './types'; -import { comboboxClassList } from './class-list'; - -function MobileCombobox( - _props: ComboboxProps, - forwardedRef: ForwardedRef -) { - const props = useProviderProps(_props); - - let { isDisabled, validationState, isReadOnly } = props; - - let { contains } = useFilter({ sensitivity: 'base' }); - let state = useComboBoxState({ - ...props, - defaultFilter: contains, - allowsEmptyCollection: true, - // Needs to be false here otherwise we double up on - // commitSelection/commitCustomValue calls when user taps on underlay (i.e. - // initial tap will call setFocused(false) -> - // commitSelection/commitCustomValue via onBlur, then the closing of the - // tray will call setFocused(false) again due to cleanup effect) - shouldCloseOnBlur: false, - }); - - let buttonRef = useRef(null); - let domRef = useObjectRef(forwardedRef); - let { triggerProps, overlayProps } = useOverlayTrigger( - { type: 'listbox' }, - state, - buttonRef - ); - - let { labelProps, fieldProps } = useField({ - ...props, - labelElementType: 'span', - }); - - // Focus the button and show focus ring when clicking on the label - labelProps = { - ...labelProps, - onClick: () => { - let button = buttonRef.current; - if (button && !props.isDisabled) { - button.focus(); - setInteractionModality('keyboard'); - } - }, - }; - - return ( - <> - - !isReadOnly && state.open(null, 'manual')} - > - {state.inputValue || props.placeholder || ''} - - - - - - - ); -} - -interface ComboboxButtonProps extends AriaButtonProps { - children?: ReactNode; - className?: string; - isReadOnly?: boolean; - isDisabled?: boolean; - isPlaceholder?: boolean; - style?: CSSProperties; - validationState?: ValidationState; -} - -const ComboboxButton = React.forwardRef(function ComboboxButton( - props: ComboboxButtonProps, - forwardedRef: ForwardedRef -) { - let { isDisabled, isPlaceholder, validationState, children, style } = props; - let valueId = useId(); - let invalidId = useId(); - - let domRef = useObjectRef(forwardedRef); - let { hoverProps, isHovered } = useHover({}); - let { buttonProps, isPressed } = useButton( - { - ...props, - 'aria-labelledby': [ - props['aria-labelledby'], - props['aria-label'] && !props['aria-labelledby'] ? props.id : null, - valueId, - validationState === 'invalid' ? invalidId : null, - ] - .filter(Boolean) - .join(' '), - elementType: 'div', - }, - domRef - ); - - return ( - - - - - {children} - - - - - - - - - ); -}); - -type CosmeticProps = FieldButtonProps & { - isHovered: boolean; - isPressed: boolean; -}; -const CosmeticFieldButton = (props: CosmeticProps) => { - let { isHovered, isPressed, ...otherProps } = props; - let { children, styleProps } = useFieldButton(otherProps, { - isHovered, - isPressed, - }); - return ( -
    - {children} -
    - ); -}; - -const InputStateIndicator = (props: CosmeticProps) => { - let { isDisabled, isHovered, isPressed } = props; - return ( -
    - ); -}; - -interface ComboboxTrayProps extends ComboboxProps { - state: ComboBoxState; - overlayProps: HTMLAttributes; - loadingIndicator?: ReactElement; - onClose: () => void; -} - -function ComboboxTray(props: ComboboxTrayProps) { - let { - state, - isDisabled, - validationState, - label, - overlayProps, - loadingState, - onLoadMore, - onClose, - } = props; - - let timeoutRef = useRef(undefined); - let [showLoading, setShowLoading] = useState(false); - let inputRef = useRef(null); - let buttonRef = useRef(null); - let popoverRef = useRef(null); - let listBoxRef = useRef(null); - let layout = useListBoxLayout(); - let stringFormatter = useLocalizedStringFormatter(localizedMessages); - - let { inputProps, listBoxProps, labelProps } = useComboBox( - { - ...props, - layoutDelegate: layout, - buttonRef, - popoverRef, - listBoxRef, - inputRef, - }, - state - ); - - React.useEffect(() => { - let input = inputRef.current; - if (input) { - focusSafely(input); - } - - // When the tray unmounts, set state.isFocused (i.e. the tray input's focus tracker) to false. - // This is to prevent state.isFocused from being set to true when the tray closes via tapping on the underlay - // (FocusScope attempts to restore focus to the tray input when tapping outside the tray due to "contain") - // Have to do this manually since React doesn't call onBlur when a component is unmounted: https://github.com/facebook/react/issues/12363 - return () => { - state.setFocused(false); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - let { dialogProps } = useDialog( - { - 'aria-labelledby': useId(labelProps.id), - }, - popoverRef - ); - - // Override the role of the input to "searchbox" instead of "combobox". - // Since the listbox is always visible, the combobox role doesn't really give us anything. - // VoiceOver on iOS reads "double tap to collapse" when focused on the input rather than - // "double tap to edit text", as with a textbox or searchbox. We'd like double tapping to - // open the virtual keyboard rather than closing the tray. - inputProps.role = 'searchbox'; - inputProps['aria-haspopup'] = 'listbox'; - delete inputProps.onTouchEnd; - - let clearButton = ( - { - state.setInputValue(''); - let input = inputRef.current; - if (input) { - input.focus(); - } - }} - isDisabled={isDisabled} - /> - ); - - let loadingCircle = ( - - - - ); - - // Close the software keyboard on scroll to give the user a bigger area to scroll. - // But only do this if scrolling with touch, otherwise it can cause issues with touch - // screen readers. - let isTouchDown = useRef(false); - let onTouchStart = () => { - isTouchDown.current = true; - }; - - let onTouchEnd = () => { - isTouchDown.current = false; - }; - - let onScroll = useCallback(() => { - let input = inputRef.current; - let popover = popoverRef.current; - if (!input || document.activeElement !== input || !isTouchDown.current) { - return; - } - - if (popover) { - popover.focus(); - } - }, [inputRef, popoverRef, isTouchDown]); - - let inputValue = inputProps.value; - let lastInputValue = useRef(inputValue); - useEffect(() => { - if (loadingState === 'filtering' && !showLoading) { - if (!timeoutRef.current) { - timeoutRef.current = setTimeout(() => { - setShowLoading(true); - }, 500); - } - - // If user is typing, clear the timer and restart since it is a new request - if (inputValue !== lastInputValue.current) { - clearTimeout(timeoutRef.current); - timeoutRef.current = setTimeout(() => { - setShowLoading(true); - }, 500); - } - } else if (loadingState !== 'filtering') { - // If loading is no longer happening, clear any timers and hide the loading circle - setShowLoading(false); - clearTimeout(timeoutRef.current); - timeoutRef.current = undefined; - } - - lastInputValue.current = inputValue; - }, [loadingState, inputValue, showLoading]); - - let onKeyDown = (e: KeyboardEvent) => { - let popover = popoverRef.current; - // Close virtual keyboard if user hits Enter w/o any focused options - if ( - popover && - e.key === 'Enter' && - state.selectionManager.focusedKey == null - ) { - popover.focus(); - } else { - inputProps.onKeyDown?.(e); - } - }; - - return ( - - - - - {showLoading && loadingState === 'filtering' && loadingCircle} - {(state.inputValue !== '' || - loadingState === 'filtering' || - validationState != null) && - !props.isReadOnly && - clearButton} - - } - /> - - loadingState !== 'loading' && ( - - - {stringFormatter.format('noResults')} - - - ) - } - ref={listBoxRef} - onScroll={onScroll} - onLoadMore={onLoadMore} - isLoading={ - loadingState === 'loading' || loadingState === 'loadingMore' - } - /> - - - - ); -} -const _MobileCombobox: ( - props: ComboboxProps & { ref?: ForwardedRef } -) => ReactElement = React.forwardRef(MobileCombobox as any) as any; - -export { _MobileCombobox as MobileCombobox }; diff --git a/design-system/pkg/src/combobox/MobileComboboxMulti.tsx b/design-system/pkg/src/combobox/MobileComboboxMulti.tsx deleted file mode 100644 index 86fcde8c9..000000000 --- a/design-system/pkg/src/combobox/MobileComboboxMulti.tsx +++ /dev/null @@ -1,518 +0,0 @@ -import { useButton, AriaButtonProps } from 'react-aria/useButton'; -import { useDialog } from 'react-aria/useDialog'; -import { FocusScope } from 'react-aria/FocusScope'; -import { focusSafely } from 'react-aria/private/interactions/focusSafely'; -import { useLocalizedStringFormatter } from 'react-aria/useLocalizedStringFormatter'; -import { setInteractionModality } from 'react-aria/private/interactions/useFocusVisible'; -import { useHover } from 'react-aria/useHover'; -import { useField } from 'react-aria/useField'; -import { DismissButton } from 'react-aria/Overlay'; -import { useOverlayTrigger } from 'react-aria/useOverlayTrigger'; -import { mergeProps } from 'react-aria/mergeProps'; -import { useId } from 'react-aria/useId'; -import { useObjectRef } from 'react-aria/useObjectRef'; - -import { ValidationState } from '@react-types/shared'; -import React, { - CSSProperties, - ForwardedRef, - HTMLAttributes, - KeyboardEvent, - ReactElement, - ReactNode, - useCallback, - useEffect, - useRef, - useState, -} from 'react'; - -import { - ClearButton, - FieldButtonProps, - useFieldButton, -} from '@keystar/ui/button'; -import { useProviderProps } from '@keystar/ui/core'; -import { FieldPrimitive } from '@keystar/ui/field'; -import { Icon } from '@keystar/ui/icon'; -import { chevronDownIcon } from '@keystar/ui/icon/icons/chevronDownIcon'; -import { Flex } from '@keystar/ui/layout'; -import { ListBoxBase, useListBoxLayout } from '@keystar/ui/listbox'; -import { Tray } from '@keystar/ui/overlays'; -import { ProgressCircle } from '@keystar/ui/progress'; -import { - FocusRing, - css, - toDataAttributes, - tokenSchema, - transition, -} from '@keystar/ui/style'; -import { TextFieldPrimitive } from '@keystar/ui/text-field'; -import { Text } from '@keystar/ui/typography'; - -import localizedMessages from './l10n'; -import { ComboboxMultiProps } from './types'; -import { comboboxClassList } from './class-list'; -import { - ComboboxMultiState, - useComboboxMultiState, -} from './useComboboxMultiState'; -import { useComboboxMulti } from './useComboboxMulti'; - -function MobileComboboxMulti( - _props: ComboboxMultiProps, - forwardedRef: ForwardedRef -) { - const props = useProviderProps(_props); - - let { isDisabled, isReadOnly, validationState } = props; - - // let { contains } = useFilter({ sensitivity: 'base' }); - let state = useComboboxMultiState({ - ...props, - // defaultFilter: contains, - allowsEmptyCollection: true, - }); - - let buttonRef = useRef(null); - let domRef = useObjectRef(forwardedRef); - let { triggerProps, overlayProps } = useOverlayTrigger( - { type: 'listbox' }, - state, - buttonRef - ); - - let { labelProps, fieldProps } = useField({ - ...props, - labelElementType: 'span', - }); - - // Focus the button and show focus ring when clicking on the label - labelProps = { - ...labelProps, - onClick: () => { - let button = buttonRef.current; - if (button && !props.isDisabled) { - button.focus(); - setInteractionModality('keyboard'); - } - }, - }; - - return ( - <> - - !isReadOnly && state.open()} - > - {state.inputValue || props.placeholder || ''} - - - - - - - ); -} - -interface ComboboxButtonProps extends AriaButtonProps { - children?: ReactNode; - className?: string; - isReadOnly?: boolean; - isDisabled?: boolean; - isPlaceholder?: boolean; - style?: CSSProperties; - validationState?: ValidationState; -} - -const ComboboxButton = React.forwardRef(function ComboboxButton( - props: ComboboxButtonProps, - forwardedRef: ForwardedRef -) { - let { isDisabled, isPlaceholder, validationState, children, style } = props; - let valueId = useId(); - let invalidId = useId(); - - let domRef = useObjectRef(forwardedRef); - let { hoverProps, isHovered } = useHover({}); - let { buttonProps, isPressed } = useButton( - { - ...props, - 'aria-labelledby': [ - props['aria-labelledby'], - props['aria-label'] && !props['aria-labelledby'] ? props.id : null, - valueId, - validationState === 'invalid' ? invalidId : null, - ] - .filter(Boolean) - .join(' '), - elementType: 'div', - }, - domRef - ); - - return ( - - - - - {children} - - - - - - - - - ); -}); - -type CosmeticProps = FieldButtonProps & { - isHovered: boolean; - isPressed: boolean; -}; -const CosmeticFieldButton = (props: CosmeticProps) => { - let { isHovered, isPressed, ...otherProps } = props; - let { children, styleProps } = useFieldButton(otherProps, { - isHovered, - isPressed, - }); - return ( -
    - {children} -
    - ); -}; - -const InputStateIndicator = (props: CosmeticProps) => { - let { isDisabled, isHovered, isPressed } = props; - return ( -
    - ); -}; - -interface ComboboxTrayProps extends ComboboxMultiProps { - state: ComboboxMultiState; - overlayProps: HTMLAttributes; - loadingIndicator?: ReactElement; - onClose: () => void; -} - -function ComboboxTray(props: ComboboxTrayProps) { - let { - state, - isDisabled, - validationState, - label, - overlayProps, - loadingState, - onLoadMore, - onClose, - } = props; - - let timeoutRef = useRef(undefined); - let [showLoading, setShowLoading] = useState(false); - let inputRef = useRef(null); - let buttonRef = useRef(null); - let popoverRef = useRef(null); - let listBoxRef = useRef(null); - let layout = useListBoxLayout(); - let stringFormatter = useLocalizedStringFormatter(localizedMessages); - - let { inputProps, listBoxProps, labelProps } = useComboboxMulti( - { - ...props, - layoutDelegate: layout, - buttonRef, - popoverRef, - listBoxRef, - inputRef, - // fix for close on blur behaviour - shouldCloseOnBlur: false, - }, - state - ); - - React.useEffect(() => { - let input = inputRef.current; - if (input) { - focusSafely(input); - } - - // When the tray unmounts, set state.isFocused (i.e. the tray input's focus tracker) to false. - // This is to prevent state.isFocused from being set to true when the tray closes via tapping on the underlay - // (FocusScope attempts to restore focus to the tray input when tapping outside the tray due to "contain") - // Have to do this manually since React doesn't call onBlur when a component is unmounted: https://github.com/facebook/react/issues/12363 - return () => { - state.selectionManager.setFocusedKey(null); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - let { dialogProps } = useDialog( - { - 'aria-labelledby': useId(labelProps.id), - }, - popoverRef - ); - - // Override the role of the input to "searchbox" instead of "combobox". - // Since the listbox is always visible, the combobox role doesn't really give us anything. - // VoiceOver on iOS reads "double tap to collapse" when focused on the input rather than - // "double tap to edit text", as with a textbox or searchbox. We'd like double tapping to - // open the virtual keyboard rather than closing the tray. - inputProps.role = 'searchbox'; - inputProps['aria-haspopup'] = 'listbox'; - delete inputProps.onTouchEnd; - - let clearButton = ( - { - state.setInputValue(''); - let input = inputRef.current; - if (input) { - input.focus(); - } - }} - isDisabled={isDisabled} - /> - ); - - let loadingCircle = ( - - - - ); - - // Close the software keyboard on scroll to give the user a bigger area to scroll. - // But only do this if scrolling with touch, otherwise it can cause issues with touch - // screen readers. - let isTouchDown = useRef(false); - let onTouchStart = () => { - isTouchDown.current = true; - }; - - let onTouchEnd = () => { - isTouchDown.current = false; - }; - - let onScroll = useCallback(() => { - let input = inputRef.current; - let popover = popoverRef.current; - if (!input || document.activeElement !== input || !isTouchDown.current) { - return; - } - - if (popover) { - popover.focus(); - } - }, [inputRef, popoverRef, isTouchDown]); - - let inputValue = inputProps.value; - let lastInputValue = useRef(inputValue); - useEffect(() => { - if (loadingState === 'filtering' && !showLoading) { - if (!timeoutRef.current) { - timeoutRef.current = setTimeout(() => { - setShowLoading(true); - }, 500); - } - - // If user is typing, clear the timer and restart since it is a new request - if (inputValue !== lastInputValue.current) { - clearTimeout(timeoutRef.current); - timeoutRef.current = setTimeout(() => { - setShowLoading(true); - }, 500); - } - } else if (loadingState !== 'filtering') { - // If loading is no longer happening, clear any timers and hide the loading circle - setShowLoading(false); - clearTimeout(timeoutRef.current); - timeoutRef.current = undefined; - } - - lastInputValue.current = inputValue; - }, [loadingState, inputValue, showLoading]); - - let onKeyDown = (e: KeyboardEvent) => { - let popover = popoverRef.current; - // Close virtual keyboard if user hits Enter w/o any focused options - if ( - popover && - e.key === 'Enter' && - state.selectionManager.focusedKey == null - ) { - popover.focus(); - } else { - inputProps.onKeyDown?.(e); - } - }; - - return ( - - - - - {showLoading && loadingState === 'filtering' && loadingCircle} - {(state.inputValue !== '' || - loadingState === 'filtering' || - validationState != null) && - !props.isReadOnly && - clearButton} - - } - /> - - loadingState !== 'loading' && ( - - - {stringFormatter.format('noResults')} - - - ) - } - ref={listBoxRef} - onScroll={onScroll} - onLoadMore={onLoadMore} - isLoading={ - loadingState === 'loading' || loadingState === 'loadingMore' - } - /> - - - - ); -} -const _MobileComboboxMulti: ( - props: ComboboxMultiProps & { ref?: ForwardedRef } -) => ReactElement = React.forwardRef(MobileComboboxMulti as any) as any; - -export { _MobileComboboxMulti as MobileComboboxMulti }; diff --git a/design-system/pkg/src/combobox/docs/index.mdoc b/design-system/pkg/src/combobox/docs/index.mdoc index 87c3f2341..3fe12724f 100644 --- a/design-system/pkg/src/combobox/docs/index.mdoc +++ b/design-system/pkg/src/combobox/docs/index.mdoc @@ -13,9 +13,9 @@ popover to improve usability. ```jsx {% live=true %} - Compact - Regular - Spacious + Compact + Regular + Spacious ``` @@ -23,9 +23,9 @@ popover to improve usability. ### Collections -Combobox implements `react-stately` -[collection components](https://react-spectrum.adobe.com/react-stately/collections.html), -`` and `
    `. +Combobox implements `react-aria-components` +[collection components](https://react-spectrum.adobe.com/react-aria-components/collections.html), +`` and ``. Static collections, seen in the example above, can be used when the full list of options is known ahead of time. @@ -49,7 +49,7 @@ let [animalId, setAnimalId] = React.useState(); return ( - {item => {item.name}} + {item => {item.name}} Selected ID: {animalId} @@ -67,27 +67,28 @@ then this is _used automatically and a **key prop is not required**_. #### Sections -Use the `
    ` component to group related items. Each `Section` takes a -`title` and `key` prop. +Use `` with an explicit `` to group +related items. ```jsx {% live=true %} -
    - Bilby - Kangaroo - Quokka -
    -
    - Echidna - Dingo - Cassowary -
    + + Marsupials + Bilby + Kangaroo + Quokka + + + Other + Echidna + Dingo + Cassowary +
    ``` -Sections used with dynamic items are populated from a hierarchical data -structure. Similar to the combobox itself, `Section` takes an array of data -using the `items` prop. +Sections used with dynamic items are populated with an explicit +``. ```jsx {% live=true %} let items = [ @@ -116,9 +117,12 @@ return ( onSelectionChange={selected => alert(selected)} > {item => ( -
    - {item => {item.name}} -
    + + {item.name} + + {item => {item.name}} + + )} ); @@ -130,8 +134,8 @@ Setting a selected item can be done by using the `defaultSelectedKey` or `selectedKey` prop. The selected key corresponds to the key of an item, where `"id"` is used by default. -See the `react-stately` -[selection docs](https://react-spectrum.adobe.com/react-stately/selection.html#selected-key-data-type) +See the `react-aria-components` +[selection docs](https://react-spectrum.adobe.com/react-aria-components/selection.html#selected-key-data-type) for more information. #### Controlled @@ -158,7 +162,7 @@ return ( selectedKey={animal} onSelectionChange={setAnimal} > - {item => {item.name}} + {item => {item.name}} ); ``` @@ -179,7 +183,7 @@ let items = [ return ( - {item => {item.name}} + {item => {item.name}} ); ``` @@ -193,21 +197,21 @@ the prop `slot="description"` must be used to distinguish it from the primary ```jsx {% live=true %} - + Read Read only - - + + Write Read and write - - + + Admin Unrestricted access - + ``` @@ -225,9 +229,9 @@ accessibility. ```jsx {% live=true %} - Compact - Regular - Spacious + Compact + Regular + Spacious ``` @@ -238,9 +242,9 @@ available. ```jsx {% live=true %} - Compact - Regular - Spacious + Compact + Regular + Spacious ``` @@ -248,9 +252,9 @@ You can also provide `disabledKeys` to limit [selection](#selection). ```jsx {% live=true %} - Compact - Regular - Spacious + Compact + Regular + Spacious ``` @@ -261,9 +265,9 @@ field remains focusable and the contents can still be copied. ```jsx {% live=true %} - Compact - Regular - Spacious + Compact + Regular + Spacious ``` @@ -273,9 +277,9 @@ A combobox can be marked as required by setting the `isRequired` prop. ```jsx {% live=true %} - Australian dollar - British pound - Japanese yen + Australian dollar + British pound + Japanese yen ``` @@ -289,9 +293,9 @@ requirements for what to choose. label="Default currency" description="Products without an ISO currency code will default to this currency." > - Australian dollar - British pound - Japanese yen + Australian dollar + British pound + Japanese yen ``` @@ -307,9 +311,9 @@ met, prompting the user to adjust what they had originally selected. isRequired selectedKey={null} > - Australian dollar - British pound - Japanese yen + Australian dollar + British pound + Japanese yen ``` @@ -319,9 +323,9 @@ The menu will assume the `width` of the invoking trigger. ```jsx {% live=true %} - Compact - Regular - Spacious + Compact + Regular + Spacious ``` @@ -333,8 +337,9 @@ should always render above. ```jsx {% live=true %} - Compact - Regular - Spacious + Compact + Regular + Spacious ``` + diff --git a/design-system/pkg/src/combobox/index.ts b/design-system/pkg/src/combobox/index.ts index 0630d0e0a..c46ab35bd 100644 --- a/design-system/pkg/src/combobox/index.ts +++ b/design-system/pkg/src/combobox/index.ts @@ -1,7 +1,12 @@ 'use client'; -export { Item } from 'react-stately/Item'; -export { Section } from 'react-stately/Section'; +export { + ListBoxItem as ComboboxItem, + ListBoxHeader as ComboboxHeader, + ListBoxLoadMoreItem as ComboboxLoadMoreItem, + ListBoxSection as ComboboxSection, +} from '@keystar/ui/listbox'; +export { Collection as ComboboxCollection } from 'react-aria-components/Collection'; export { Combobox } from './Combobox'; export { ComboboxMulti } from './ComboboxMulti'; diff --git a/design-system/pkg/src/combobox/l10n.json b/design-system/pkg/src/combobox/l10n.json deleted file mode 100644 index dfdb07bbe..000000000 --- a/design-system/pkg/src/combobox/l10n.json +++ /dev/null @@ -1,274 +0,0 @@ -{ - "ar-AE": { - "clear": "مسح", - "invalid": "(غير صالح)", - "loading": "جارٍ التحميل...", - "noResults": "لا توجد نتائج", - "buttonLabel": "عرض المقترحات", - "listboxLabel": "مقترحات" - }, - "bg-BG": { - "clear": "Изчисти", - "invalid": "(невалиден)", - "loading": "Зареждане...", - "noResults": "Няма резултати", - "buttonLabel": "Покажи предложения", - "listboxLabel": "Предложения" - }, - "cs-CZ": { - "clear": "Vymazat", - "invalid": "(neplatné)", - "loading": "Načítání...", - "noResults": "Žádné výsledky", - "buttonLabel": "Zobrazit doporučení", - "listboxLabel": "Návrhy" - }, - "da-DK": { - "clear": "Ryd", - "invalid": "(ugyldig)", - "loading": "Indlæser ...", - "noResults": "Ingen resultater", - "buttonLabel": "Vis forslag", - "listboxLabel": "Forslag" - }, - "de-DE": { - "clear": "Löschen", - "invalid": "(ungültig)", - "loading": "Wird geladen...", - "noResults": "Keine Ergebnisse", - "buttonLabel": "Empfehlungen anzeigen", - "listboxLabel": "Empfehlungen" - }, - "el-GR": { - "clear": "Καθαρισμός", - "invalid": "(δεν ισχύει)", - "loading": "Φόρτωση...", - "noResults": "Χωρίς αποτέλεσμα", - "buttonLabel": "Προβολή προτάσεων", - "listboxLabel": "Προτάσεις" - }, - "en-US": { - "clear": "Clear", - "invalid": "(invalid)", - "loading": "Loading...", - "noResults": "No results", - "buttonLabel": "Show suggestions", - "listboxLabel": "Suggestions" - }, - "es-ES": { - "clear": "Borrar", - "invalid": "(no válido)", - "loading": "Cargando...", - "noResults": "Sin resultados", - "buttonLabel": "Mostrar sugerencias", - "listboxLabel": "Sugerencias" - }, - "et-EE": { - "clear": "Puhasta", - "invalid": "(kehtetu)", - "loading": "Laadimine...", - "noResults": "Tulemusi pole", - "buttonLabel": "Kuva soovitused", - "listboxLabel": "Soovitused" - }, - "fi-FI": { - "clear": "Kirkas", - "invalid": "(epäkelpo)", - "loading": "Ladataan...", - "noResults": "Ei tuloksia", - "buttonLabel": "Näytä ehdotukset", - "listboxLabel": "Ehdotukset" - }, - "fr-FR": { - "clear": "Effacer", - "invalid": "(non valide)", - "loading": "Chargement en cours...", - "noResults": "Aucun résultat", - "buttonLabel": "Afficher les suggestions", - "listboxLabel": "Suggestions" - }, - "he-IL": { - "clear": "נקי", - "invalid": "(לא חוקי)", - "loading": "טוען...", - "noResults": "אין תוצאות", - "buttonLabel": "הצג הצעות", - "listboxLabel": "הצעות" - }, - "hr-HR": { - "clear": "Izbriši", - "invalid": "(nevažeće)", - "loading": "Učitavam...", - "noResults": "Nema rezultata", - "buttonLabel": "Prikaži prijedloge", - "listboxLabel": "Prijedlozi" - }, - "hu-HU": { - "clear": "Törlés", - "invalid": "(érvénytelen)", - "loading": "Betöltés folyamatban…", - "noResults": "Nincsenek találatok", - "buttonLabel": "Javaslatok megjelenítése", - "listboxLabel": "Javaslatok" - }, - "it-IT": { - "clear": "Cancella", - "invalid": "(non valido)", - "loading": "Caricamento in corso...", - "noResults": "Nessun risultato", - "buttonLabel": "Mostra suggerimenti", - "listboxLabel": "Suggerimenti" - }, - "ja-JP": { - "clear": "クリア", - "invalid": "(無効)", - "loading": "読み込み中...", - "noResults": "結果なし", - "buttonLabel": "候補を表示", - "listboxLabel": "候補" - }, - "ko-KR": { - "clear": "지우기", - "invalid": "(유효하지 않음)", - "loading": "로드 중...", - "noResults": "결과 없음", - "buttonLabel": "제안 사항 표시", - "listboxLabel": "제안" - }, - "lt-LT": { - "clear": "Skaidrus", - "invalid": "(netinkama)", - "loading": "Įkeliama...", - "noResults": "Be rezultatų", - "buttonLabel": "Rodyti pasiūlymus", - "listboxLabel": "Pasiūlymai" - }, - "lv-LV": { - "clear": "Notīrīt", - "invalid": "(nederīgs)", - "loading": "Notiek ielāde...", - "noResults": "Nav rezultātu", - "buttonLabel": "Rādīt ieteikumus", - "listboxLabel": "Ieteikumi" - }, - "nb-NO": { - "clear": "Tøm", - "invalid": "(ugyldig)", - "loading": "Laster inn ...", - "noResults": "Ingen resultater", - "buttonLabel": "Vis forslag", - "listboxLabel": "Forslag" - }, - "nl-NL": { - "clear": "Helder", - "invalid": "(ongeldig)", - "loading": "Laden...", - "noResults": "Geen resultaten", - "buttonLabel": "Suggesties weergeven", - "listboxLabel": "Suggesties" - }, - "pl-PL": { - "clear": "Wyczyść", - "invalid": "(nieprawidłowy)", - "loading": "Trwa ładowanie...", - "noResults": "Brak wyników", - "buttonLabel": "Wyświetlaj sugestie", - "listboxLabel": "Sugestie" - }, - "pt-BR": { - "clear": "Limpar", - "invalid": "(inválido)", - "loading": "Carregando...", - "noResults": "Nenhum resultado", - "buttonLabel": "Mostrar sugestões", - "listboxLabel": "Sugestões" - }, - "pt-PT": { - "clear": "Limpar", - "invalid": "(inválido)", - "loading": "A carregar...", - "noResults": "Sem resultados", - "buttonLabel": "Apresentar sugestões", - "listboxLabel": "Sugestões" - }, - "ro-RO": { - "clear": "Golire", - "invalid": "(nevalid)", - "loading": "Se încarcă...", - "noResults": "Niciun rezultat", - "buttonLabel": "Afișare sugestii", - "listboxLabel": "Sugestii" - }, - "ru-RU": { - "clear": "Очистить", - "invalid": "(недействительно)", - "loading": "Загрузка...", - "noResults": "Результаты отсутствуют", - "buttonLabel": "Показать предложения", - "listboxLabel": "Предложения" - }, - "sk-SK": { - "clear": "Vymazať", - "invalid": "(neplatné)", - "loading": "Načítava sa...", - "noResults": "Žiadne výsledky", - "buttonLabel": "Zobraziť návrhy", - "listboxLabel": "Návrhy" - }, - "sl-SI": { - "clear": "Jasen", - "invalid": "(neveljavno)", - "loading": "Nalaganje...", - "noResults": "Ni rezultatov", - "buttonLabel": "Prikaži predloge", - "listboxLabel": "Predlogi" - }, - "sr-SP": { - "clear": "Izbriši", - "invalid": "(nevažeće)", - "loading": "Učitavam...", - "noResults": "Nema rezultata", - "buttonLabel": "Prikaži predloge", - "listboxLabel": "Predlozi" - }, - "sv-SE": { - "clear": "Rensa", - "invalid": "(ogiltigt)", - "loading": "Läser in...", - "noResults": "Inga resultat", - "buttonLabel": "Visa förslag", - "listboxLabel": "Förslag" - }, - "tr-TR": { - "clear": "Temizle", - "invalid": "(geçersiz)", - "loading": "Yükleniyor...", - "noResults": "Sonuç yok", - "buttonLabel": "Önerileri göster", - "listboxLabel": "Öneriler" - }, - "uk-UA": { - "clear": "Очистити", - "invalid": "(недійсне)", - "loading": "Завантаження...", - "noResults": "Результатів немає", - "buttonLabel": "Показати пропозиції", - "listboxLabel": "Пропозиції" - }, - "zh-CN": { - "clear": "透明", - "invalid": "(无效)", - "loading": "正在加载...", - "noResults": "无结果", - "buttonLabel": "显示建议", - "listboxLabel": "建议" - }, - "zh-TW": { - "clear": "清除", - "invalid": "(無效)", - "loading": "正在載入...", - "noResults": "無任何結果", - "buttonLabel": "顯示建議", - "listboxLabel": "建議" - } -} diff --git a/design-system/pkg/src/combobox/stories/Combobox.stories.tsx b/design-system/pkg/src/combobox/stories/Combobox.stories.tsx index 513c7d069..6ea8745a3 100644 --- a/design-system/pkg/src/combobox/stories/Combobox.stories.tsx +++ b/design-system/pkg/src/combobox/stories/Combobox.stories.tsx @@ -9,7 +9,15 @@ import { Icon } from '@keystar/ui/icon'; import { Flex } from '@keystar/ui/layout'; import { Text } from '@keystar/ui/typography'; -import { Combobox, ComboboxProps, Item, Section } from '..'; +import { + Combobox, + ComboboxCollection, + ComboboxHeader, + ComboboxItem, + ComboboxLoadMoreItem, + ComboboxProps, + ComboboxSection, +} from '..'; let flatItems = [ { id: '1', name: 'Echidna' }, @@ -48,10 +56,10 @@ let manySections: { name: string; children: { name: string }[] }[] = []; for (let i = 0; i < 50; i++) { let children = []; for (let j = 0; j < 50; j++) { - children.push({ name: `Section ${i}, Item ${j}` }); + children.push({ name: `ComboboxSection ${i}, ComboboxItem ${j}` }); } - manySections.push({ name: 'Section ' + i, children }); + manySections.push({ name: 'ComboboxSection ' + i, children }); } const defaultActions = { @@ -73,9 +81,11 @@ export const DefaultWithControls = (args: ArgTypes) => ( {...defaultActions} {...args} > - One - Two - Three has a long label that will wrap + One + Two + + Three has a long label that will wrap + ); @@ -103,31 +113,36 @@ DefaultWithControls.argTypes = { export const Sections = () => ( -
    - Bilby - Kangaroo - Quokka -
    -
    - Echidna - Dingo - Cassowary -
    + + Marsupials + Bilby + Kangaroo + Quokka + + + Other + Echidna + Dingo + Cassowary +
    ); export const Dynamic = () => ( - {item => {item.name}} + {item => {item.name}} ); export const DynamicWithSections = () => ( {section => ( -
    - {item => {item.name}} -
    + + {section.name} + + {item => {item.name}} + + )}
    ); @@ -135,40 +150,45 @@ export const DynamicWithSections = () => ( export const ManySections = () => ( {section => ( -
    - {item => {item.name}} -
    + + {section.name} + + {item => {item.name}} + + )}
    ); export const ComplexItems = () => ( -
    - + + ComboboxSection 1 + Bold - - + + Italic - -
    -
    - + + + + ComboboxSection 3 + Left The description text for left is long - - + + Center has a long label that wraps - - + + Right - -
    + +
    ); @@ -180,9 +200,12 @@ export const DisabledKeys = () => ( {...defaultActions} > {section => ( -
    - {item => {item.name}} -
    + + {section.name} + + {item => {item.name}} + + )} ); @@ -201,12 +224,9 @@ export const AutoFocus = () => render({ autoFocus: true }); export const LoadingState = () => { return ( - {render({ label: 'Combobox (loading)', loadingState: 'loading' })} - {render({ label: 'Combobox (filtering)', loadingState: 'filtering' })} - {render({ - label: 'Combobox (loading more)', - loadingState: 'loadingMore', - })} + {render({ label: 'Combobox (loading)', isLoading: true })} + {render({ label: 'Combobox (filtering)', isLoading: true })} + {render({ label: 'Combobox (loading more)', isLoading: true })} ); }; @@ -219,12 +239,18 @@ export const CustomMenuWidths = () => ( ); -function render(props: Partial>) { +function render( + props: Partial> & { isLoading?: boolean } +) { + let { isLoading, ...comboboxProps } = props; return ( - - One - Two - Three has a long label that will wrap + + One + Two + + Three has a long label that will wrap + + {isLoading && } ); } diff --git a/design-system/pkg/src/combobox/stories/ComboboxMulti.stories.tsx b/design-system/pkg/src/combobox/stories/ComboboxMulti.stories.tsx index c7a6e8c09..b1e37b6eb 100644 --- a/design-system/pkg/src/combobox/stories/ComboboxMulti.stories.tsx +++ b/design-system/pkg/src/combobox/stories/ComboboxMulti.stories.tsx @@ -9,7 +9,15 @@ import { Icon } from '@keystar/ui/icon'; import { Flex } from '@keystar/ui/layout'; import { Text } from '@keystar/ui/typography'; -import { ComboboxMulti, ComboboxMultiProps, Item, Section } from '../index'; +import { + ComboboxCollection, + ComboboxHeader, + ComboboxItem, + ComboboxLoadMoreItem, + ComboboxMulti, + ComboboxMultiProps, + ComboboxSection, +} from '../index'; let flatItems = [ { id: '1', name: 'Echidna' }, @@ -48,10 +56,10 @@ let manySections: { name: string; children: { name: string }[] }[] = []; for (let i = 0; i < 50; i++) { let children = []; for (let j = 0; j < 50; j++) { - children.push({ name: `Section ${i}, Item ${j}` }); + children.push({ name: `ComboboxSection ${i}, ComboboxItem ${j}` }); } - manySections.push({ name: 'Section ' + i, children }); + manySections.push({ name: 'ComboboxSection ' + i, children }); } const defaultActions = { @@ -73,9 +81,11 @@ export const DefaultWithControls = (args: ArgTypes) => ( {...defaultActions} {...args} > - One - Two - Three has a long label that will wrap + One + Two + + Three has a long label that will wrap + ); @@ -103,16 +113,18 @@ DefaultWithControls.argTypes = { export const Sections = () => ( -
    - Bilby - Kangaroo - Quokka -
    -
    - Echidna - Dingo - Cassowary -
    + + Marsupials + Bilby + Kangaroo + Quokka + + + Other + Echidna + Dingo + Cassowary +
    ); @@ -122,7 +134,7 @@ export const Dynamic = () => ( defaultItems={flatItems} {...defaultActions} > - {item => {item.name}} + {item => {item.name}} ); @@ -133,9 +145,12 @@ export const DynamicWithSections = () => ( {...defaultActions} > {section => ( -
    - {item => {item.name}} -
    + + {section.name} + + {item => {item.name}} + + )} ); @@ -143,40 +158,45 @@ export const DynamicWithSections = () => ( export const ManySections = () => ( {section => ( -
    - {item => {item.name}} -
    + + {section.name} + + {item => {item.name}} + + )}
    ); export const ComplexItems = () => ( -
    - + + ComboboxSection 1 + Bold - - + + Italic - -
    -
    - + + + + ComboboxSection 3 + Left The description text for left is long - - + + Center has a long label that wraps - - + + Right - -
    + +
    ); @@ -188,46 +208,53 @@ export const DisabledKeys = () => ( {...defaultActions} > {section => ( -
    - {item => {item.name}} -
    + + {section.name} + + {item => {item.name}} + + )} ); -export const IsDisabled = () => - render({ isDisabled: true, selectedKeys: ['One'] }); +export const IsDisabled = () => render({ isDisabled: true, value: ['One'] }); -export const IsReadOnly = () => - render({ isReadOnly: true, selectedKeys: ['One'] }); +export const IsReadOnly = () => render({ isReadOnly: true, value: ['One'] }); export const IsRequired = () => - render({ isRequired: true, defaultSelectedKeys: ['One'] }); + render({ isRequired: true, defaultValue: ['One'] }); export const AutoFocus = () => render({ autoFocus: true }); export const LoadingState = () => { return ( - {render({ label: 'ComboboxMulti (loading)', loadingState: 'loading' })} + {render({ label: 'ComboboxMulti (loading)', isLoading: true })} {render({ label: 'ComboboxMulti (filtering)', - loadingState: 'filtering', + isLoading: true, })} {render({ label: 'ComboboxMulti (loading more)', - loadingState: 'loadingMore', + isLoading: true, })} ); }; -function render(props: Partial>) { +function render( + props: Partial> & { isLoading?: boolean } +) { + let { isLoading, ...comboboxProps } = props; return ( - - One - Two - Three has a long label that will wrap + + One + Two + + Three has a long label that will wrap + + {isLoading && } ); } diff --git a/design-system/pkg/src/combobox/test/Combobox.test.tsx b/design-system/pkg/src/combobox/test/Combobox.test.tsx index 1a635a15c..bd56da40c 100644 --- a/design-system/pkg/src/combobox/test/Combobox.test.tsx +++ b/design-system/pkg/src/combobox/test/Combobox.test.tsx @@ -4,7 +4,7 @@ import { beforeAll, expect, jest, describe, it } from '@jest/globals'; import { act, fireEvent, firePress, renderWithProvider } from '#test-utils'; -import { Combobox, Item } from '..'; +import { Combobox, ComboboxItem } from '..'; let onChange = jest.fn(); let onOpenChange = jest.fn(); @@ -23,9 +23,9 @@ let defaultProps = { const ExampleCombobox = forwardRef((props = {}, ref: any) => ( - Item one - Item two - Item three + ComboboxItem one + ComboboxItem two + ComboboxItem three )); @@ -139,4 +139,28 @@ describe('combobox/Combobox', () => { expect(onOpenChange).not.toHaveBeenCalled(); expect(onInputChange).not.toHaveBeenCalled(); }); + + it('keeps virtual listbox focus in sync with input keyboard navigation', function () { + let tree = renderCombobox(); + let combobox = tree.getByRole('combobox'); + + act(() => { + combobox.focus(); + fireEvent.keyDown(combobox, { key: 'ArrowDown', code: 40, charCode: 40 }); + fireEvent.keyUp(combobox, { key: 'ArrowDown', code: 40, charCode: 40 }); + jest.runAllTimers(); + }); + + let options = tree.getAllByRole('option'); + expect(options[0]).toHaveAttribute('data-focused', 'true'); + + act(() => { + fireEvent.keyDown(combobox, { key: 'ArrowDown', code: 40, charCode: 40 }); + fireEvent.keyUp(combobox, { key: 'ArrowDown', code: 40, charCode: 40 }); + jest.runAllTimers(); + }); + + expect(options[0]).not.toHaveAttribute('data-focused'); + expect(options[1]).toHaveAttribute('data-focused', 'true'); + }); }); diff --git a/design-system/pkg/src/combobox/types.ts b/design-system/pkg/src/combobox/types.ts index 72ac2d4e8..db78179c2 100644 --- a/design-system/pkg/src/combobox/types.ts +++ b/design-system/pkg/src/combobox/types.ts @@ -1,88 +1,24 @@ -import type { AriaComboBoxProps } from 'react-aria/useComboBox'; -import { - AsyncLoadable, - CollectionBase, - FocusableProps, - Key, - LoadingState, - MultipleSelection, - TextInputBase, - Validation, -} from '@react-types/shared'; +import type { ComboBoxProps as AriaComboBoxProps } from 'react-aria-components/ComboBox'; +import type { ListBoxProps } from '@keystar/ui/listbox'; -import { FieldProps } from '@keystar/ui/field'; -import { BaseStyleProps } from '@keystar/ui/style'; +import type { FieldProps } from '@keystar/ui/field'; +import type { BaseStyleProps } from '@keystar/ui/style'; -type CommonProps = { - /** - * Alignment of the menu, relative to the combobox. Only relevant when - * used in combination with the `menuWidth` prop. - * @default 'start' - */ - align?: 'start' | 'end'; - /** - * Direction the menu will render relative to the combobox. - * @default 'bottom' - */ - direction?: 'bottom' | 'top'; - /** The current loading state of the Combobox. Determines whether or not the - * progress circle should be shown. */ - loadingState?: LoadingState; - /** - * The interaction required to display the menu. - * - * NOTE: this prop has no effect on the mobile experience. - * @default 'input' - */ - menuTrigger?: 'focus' | 'input' | 'manual'; - /** - * A fixed width for the menu. By default, the menu will match the - * width of the combobox. Values less than the width of the combobox are ignored. - * - * NOTE: this prop has no effect on the mobile experience. - */ - menuWidth?: number; - /** - * Whether the menu should automatically flip direction when space is limited. - * @default true - */ - shouldFlip?: boolean; -}; - -export type ComboboxProps = Omit< - AriaComboBoxProps, - 'menuTrigger' | keyof FieldProps +type CommonProps = Omit< + AriaComboBoxProps, + 'children' | 'className' | 'style' | keyof FieldProps > & + Pick, 'children' | 'dependencies'> & FieldProps & - BaseStyleProps & - Omit & - CommonProps; + BaseStyleProps & { + align?: 'start' | 'end'; + direction?: 'bottom' | 'top'; + menuWidth?: number; + placeholder?: string; + shouldFlip?: boolean; + }; -export interface ComboboxMultiValidationValue { - /** The selected key in the ComboBox. */ - selectedKeys: Set; - /** The value of the ComboBox input. */ - inputValue: string; -} -export interface ComboboxMultiProps - extends CollectionBase, - Omit, - Omit, - TextInputBase, - FocusableProps, - Validation, - CommonProps, - FieldProps { - /** The list of ComboBox items (uncontrolled). */ - defaultItems?: Iterable; - /** The list of ComboBox items (controlled). */ - items?: Iterable; - /** Method that is called when the open state of the menu changes. Returns the new open state and the action that caused the opening of the menu. */ - onOpenChange?: (isOpen: boolean) => void; - /** The value of the ComboBox input (controlled). */ - inputValue?: string; - /** The default value of the ComboBox input (uncontrolled). */ - defaultInputValue?: string; - /** Handler that is called when the ComboBox input value changes. */ - onInputChange?: (value: string) => void; -} +export type ComboboxProps = CommonProps; +export type ComboboxMultiProps = CommonProps & { + selectionMode?: 'multiple'; +}; diff --git a/design-system/pkg/src/combobox/useComboboxMulti.ts b/design-system/pkg/src/combobox/useComboboxMulti.ts deleted file mode 100644 index 426348f42..000000000 --- a/design-system/pkg/src/combobox/useComboboxMulti.ts +++ /dev/null @@ -1,286 +0,0 @@ -import { useLocalizedStringFormatter } from 'react-aria/useLocalizedStringFormatter'; -import { useMenuTrigger } from 'react-aria/useMenu'; -import { getItemId, listData } from 'react-aria/private/listbox/utils'; -import { ariaHideOutside } from 'react-aria/private/overlays/ariaHideOutside'; -import { ListKeyboardDelegate } from 'react-aria/ListKeyboardDelegate'; -import { useSelectableCollection } from 'react-aria/private/selection/useSelectableCollection'; -import { useTextField } from 'react-aria/useTextField'; -import { chain } from 'react-aria/chain'; -import { mergeProps } from 'react-aria/mergeProps'; -import { useLabels } from 'react-aria/private/utils/useLabels'; -import { - KeyboardDelegate, - KeyboardEvent, - LayoutDelegate, - RefObject, - PressEvent, -} from '@react-types/shared'; - -import { FocusEvent, useEffect, useMemo, useRef } from 'react'; - -import localizedMessages from './l10n'; -import { ComboboxMultiProps } from './types'; -import { ComboboxMultiState } from './useComboboxMultiState'; - -export interface ComboboxMultiOptions - extends Omit, 'children'> { - /** The ref for the input element. */ - inputRef: RefObject; - /** The ref for the list box popover. */ - popoverRef: RefObject; - /** The ref for the list box. */ - listBoxRef: RefObject; - /** The ref for the optional list box popup trigger button. */ - buttonRef?: RefObject; - /** An optional keyboard delegate implementation, to override the default. */ - keyboardDelegate?: KeyboardDelegate; - /** - * A delegate object that provides layout information for items in the collection. - * By default this uses the DOM, but this can be overridden to implement things like - * virtualized scrolling. - */ - layoutDelegate?: LayoutDelegate; - /** Whether the combo box menu should close on blur. */ - shouldCloseOnBlur?: boolean; -} - -export function useComboboxMulti( - props: ComboboxMultiOptions, - state: ComboboxMultiState -) { - let { - isDisabled, - isReadOnly, - menuTrigger = 'input', - buttonRef: buttonRefProp, - inputRef, - keyboardDelegate, - layoutDelegate, - listBoxRef, - popoverRef, - shouldCloseOnBlur = true, - } = props; - - // combobox doesn't require a button element, so we need a backup ref for the - // menu trigger hook - let backupBtnRef = useRef(null); - let buttonRef = buttonRefProp ?? backupBtnRef; - let { menuTriggerProps, menuProps } = useMenuTrigger( - { - type: 'listbox', - isDisabled: isDisabled || isReadOnly, - }, - state, - buttonRef - ); - - // Set listbox id so it can be used when calling getItemId later - listData.set(state, { id: menuProps.id! }); - - let stringFormatter = useLocalizedStringFormatter(localizedMessages); - - // By default, a KeyboardDelegate is provided which uses the DOM to query - // layout information (e.g. for page up/page down). When virtualized, the - // layout object will be passed in as a prop and override this. - let delegate = useMemo( - () => - keyboardDelegate || - new ListKeyboardDelegate({ - collection: state.collection, - disabledKeys: state.selectionManager.disabledKeys, - ref: listBoxRef, - layoutDelegate, - }), - [ - keyboardDelegate, - layoutDelegate, - state.collection, - state.selectionManager.disabledKeys, - listBoxRef, - ] - ); - - // Use useSelectableCollection to get the keyboard handlers to apply to the textfield - let { collectionProps } = useSelectableCollection({ - selectionManager: state.selectionManager, - keyboardDelegate: delegate, - disallowTypeAhead: true, - disallowEmptySelection: true, - shouldFocusWrap: true, - ref: inputRef, - // This would be nice but it'd have to work with the _filtered_ collection - disallowSelectAll: true, - // Prevent item scroll behavior from being applied here, should be handled in the user's Popover + ListBox component - isVirtualized: true, - }); - - useEffect(() => { - if (state.isOpen) { - return ariaHideOutside( - [inputRef.current, popoverRef.current].filter( - element => element != null - ) - ); - } - }, [state.isOpen, inputRef, popoverRef]); - - // TextField - // --------------------------------------------------------------------------- - - let onKeyDown = (e: KeyboardEvent) => { - // Ignore composition events for CJK input - if (e.nativeEvent.isComposing) { - return; - } - - switch (e.key) { - case 'Enter': - // Prevent form submission when open, assume the intent is selection - if (state.isOpen) { - e.preventDefault(); - } - if (state.selectionManager.focusedKey) { - state.selectionManager.select(state.selectionManager.focusedKey); - } - - // Clear the input value after selection but keep the menu open - state.setInputValue(''); - break; - case 'Escape': - // Propagate the event when closed, assume the intent is to dismiss a - // parental the dialog - if (!state.isOpen) { - e.continuePropagation(); - } - - state.close(); - break; - case 'ArrowDown': - state.open('first'); - break; - case 'ArrowUp': - state.open('last'); - break; - case 'ArrowLeft': - case 'ArrowRight': - state.selectionManager.setFocusedKey(null); - break; - } - }; - let onBlur = (e: FocusEvent) => { - props.onBlur?.(e); - if (shouldCloseOnBlur) { - state.close(); - } - }; - let onFocus = (e: FocusEvent) => { - props.onFocus?.(e); - if (menuTrigger === 'focus' && !props.isReadOnly) { - state.open(); - } - state.selectionManager.setFocused(true); - }; - - let { labelProps, inputProps, descriptionProps, errorMessageProps } = - useTextField( - { - ...props, - onBlur, - onFocus, - onChange: state.setInputValue, - onKeyDown: !props.isReadOnly - ? chain( - state.isOpen && collectionProps.onKeyDown, - onKeyDown, - props.onKeyDown - ) - : props.onKeyDown, - autoComplete: 'off', - validate: undefined, - value: state.inputValue, - }, - inputRef - ); - - // Button - // --------------------------------------------------------------------------- - - let onPress = (e: PressEvent) => { - if (e.pointerType === 'touch') { - // Focus the input field in case it isn't focused yet - inputRef.current?.focus(); - state.toggle(); - } - }; - let onPressStart = (e: PressEvent) => { - if (e.pointerType !== 'touch') { - inputRef.current?.focus(); - let strategy = - e.pointerType === 'keyboard' || e.pointerType === 'virtual' - ? ('first' as const) - : null; - state.toggle(strategy); - } - }; - - // Misc. - // --------------------------------------------------------------------------- - - let { isInvalid, validationErrors, validationDetails } = - state.displayValidation; - let focusedItem = - state.selectionManager.focusedKey != null && state.isOpen - ? state.collection.getItem(state.selectionManager.focusedKey) - : undefined; - - let triggerLabelProps = useLabels({ - id: menuTriggerProps.id, - 'aria-label': stringFormatter.format('buttonLabel'), - 'aria-labelledby': props['aria-labelledby'] || labelProps.id, - }); - let listBoxProps = useLabels({ - id: menuProps.id, - 'aria-label': stringFormatter.format('listboxLabel'), - 'aria-labelledby': props['aria-labelledby'] || labelProps.id, - }); - - return { - buttonProps: { - ...menuTriggerProps, - ...triggerLabelProps, - excludeFromTabOrder: true, - isDisabled: props.isDisabled || props.isReadOnly, - onPress, - onPressStart, - }, - descriptionProps, - errorMessageProps, - inputProps: mergeProps(inputProps, { - role: 'combobox', - 'aria-expanded': menuTriggerProps['aria-expanded'], - 'aria-controls': state.isOpen ? menuProps.id : undefined, - 'aria-autocomplete': 'list', - 'aria-activedescendant': focusedItem - ? getItemId(state, focusedItem.key) - : undefined, - // onTouchEnd, - // This disable's iOS's autocorrect suggestions, since the combo box provides its own suggestions. - autoCorrect: 'off', - // This disable's the macOS Safari spell check auto corrections. - spellCheck: 'false', - }), - listBoxProps: mergeProps(menuProps, listBoxProps, { - onAction: undefined, - // autoFocus: state.focusStrategy, - shouldUseVirtualFocus: true, - shouldSelectOnPressUp: true, - shouldFocusOnHover: true, - // layout: layoutDelegate, - linkBehavior: 'selection' as const, - }), - labelProps, - isInvalid, - validationErrors, - validationDetails, - }; -} diff --git a/design-system/pkg/src/combobox/useComboboxMultiState.ts b/design-system/pkg/src/combobox/useComboboxMultiState.ts deleted file mode 100644 index bbfa3f2a0..000000000 --- a/design-system/pkg/src/combobox/useComboboxMultiState.ts +++ /dev/null @@ -1,272 +0,0 @@ -import { useFilter } from 'react-aria/useFilter'; -import { getChildNodes } from 'react-stately/private/collections/getChildNodes'; -import { - FormValidationState, - useFormValidationState, -} from 'react-stately/private/form/useFormValidationState'; -import { ListCollection } from 'react-stately/private/list/ListCollection'; -import { ListState, useListState } from 'react-stately/useListState'; -import { - MenuTriggerState, - useMenuTriggerState, -} from 'react-stately/useMenuTriggerState'; -import { useControlledState } from 'react-stately/useControlledState'; -import { useCallback, useEffect, useMemo, useState } from 'react'; -import { - Collection, - CollectionStateBase, - FocusStrategy, - Node, -} from '@react-types/shared'; - -import { usePrevious } from '../utils'; -import { ComboboxMultiProps } from './types'; - -export interface ComboboxMultiStateOptions - extends Omit, 'children'>, - CollectionStateBase { - /** Whether the combo box allows the menu to be open when the collection is empty. */ - allowsEmptyCollection?: boolean; -} - -export interface ComboboxMultiState - extends ListState, - MenuTriggerState, - FormValidationState { - /** Whether the select is currently focused. */ - // readonly isFocused: boolean, - - /** Sets whether the select is focused. */ - // setFocused(isFocused: boolean): void, - - /** Controls which item will be auto focused when the menu opens. */ - // readonly focusStrategy: FocusStrategy | null, - - /** Opens the menu. */ - // open(focusStrategy?: FocusStrategy | null): void, - - /** Toggles the menu. */ - // toggle(focusStrategy?: FocusStrategy | null): void - /** The current value of the combo box input. */ - inputValue: string; - /** Sets the value of the combo box input. */ - setInputValue(value: string): void; -} - -export function useComboboxMultiState( - props: ComboboxMultiStateOptions -): ComboboxMultiState { - let { allowsEmptyCollection = false, menuTrigger = 'input' } = props; - let [showAllItems, setShowAllItems] = useState(false); - let listState = useListState({ - ...props, - items: props.items ?? props.defaultItems, - selectionBehavior: 'toggle', - selectionMode: 'multiple', - }); - let triggerState = useMenuTriggerState({ - ...props, - onOpenChange: props.onOpenChange, - isOpen: undefined, - defaultOpen: undefined, - }); - let [inputValue, setInputValue] = useControlledState( - props.inputValue, - props.defaultInputValue ?? '', - props.onInputChange - ); - let lastInputValue = usePrevious(inputValue); - - // Preserve original collection so we can show all items on demand - let { collection } = listState; - let originalCollection = collection; - let { contains } = useFilter({ sensitivity: 'base' }); - let filteredCollection = useMemo(() => { - // No filter if items are controlled - if (props.items != null) { - return collection; - } - - return filterCollection(collection, inputValue, contains); - }, [collection, inputValue, contains, props.items]); - let [lastCollection, setLastCollection] = useState(filteredCollection); - let updateLastCollection = useCallback(() => { - setLastCollection(showAllItems ? originalCollection : filteredCollection); - }, [showAllItems, originalCollection, filteredCollection]); - - let closeMenu = () => { - // keep the old collection while closing the menu - updateLastCollection(); - triggerState.close(); - }; - let close = () => { - // clear the input on user initiated close - setInputValue(''); - closeMenu(); - }; - let open = (focusStrategy: FocusStrategy | null = null) => { - let displayAllItems = menuTrigger === 'focus'; - // Prevent open operations from triggering if there is nothing to display: - // - Also prevent open operations from triggering if items are uncontrolled - // but defaultItems is empty, even if displayAllItems is true. - // - This is to prevent comboboxes with empty defaultItems from opening but - // allow controlled items comboboxes to open even if the inital list is - // empty. - if ( - allowsEmptyCollection || - filteredCollection.size > 0 || - (displayAllItems && originalCollection.size > 0) || - props.items - ) { - if ( - displayAllItems && - !triggerState.isOpen && - props.items === undefined - ) { - // Show all items if menu is manually opened. Ignored if items are controlled - setShowAllItems(true); - } - - triggerState.open(focusStrategy); - } - }; - - let toggle = (focusStrategy: FocusStrategy | null = null) => { - let displayAllItems = menuTrigger === 'focus'; - // If the menu is closed and there is nothing to display, early return so - // toggle isn't called to prevent extraneous onOpenChange - if ( - !( - allowsEmptyCollection || - filteredCollection.size > 0 || - (displayAllItems && originalCollection.size > 0) || - props.items - ) && - !triggerState.isOpen - ) { - return; - } - - if (displayAllItems && !triggerState.isOpen && props.items === undefined) { - // Show all items if menu is toggled open. Ignored if items are controlled - setShowAllItems(true); - } - - triggerState.toggle(focusStrategy); - - // keep the old collection while closing the menu - if (triggerState.isOpen) { - updateLastCollection(); - } - }; - - // commit controlled selection on close - let commit = () => { - if (props.selectedKeys !== undefined && props.inputValue !== undefined) { - props.onSelectionChange?.(listState.selectionManager.selectedKeys); - } - - close(); - }; - - useEffect(() => { - // Open the menu when the input value changes - if (!triggerState.isOpen && inputValue && inputValue !== lastInputValue) { - triggerState.open(); - } - - // Close the menu if the collection is empty. Don't close menu if filtered collection size is 0 - // but we are currently showing all items via button press - if ( - !showAllItems && - !allowsEmptyCollection && - triggerState.isOpen && - filteredCollection.size === 0 - ) { - closeMenu(); - } - }); - - let validation = useFormValidationState({ - ...props, - value: useMemo( - () => ({ - inputValue, - selectedKeys: listState.selectionManager.selectedKeys, - }), - [inputValue, listState.selectionManager.selectedKeys] - ), - }); - - let displayedCollection = useMemo(() => { - if (triggerState.isOpen) { - if (showAllItems) { - return originalCollection; - } else { - return filteredCollection; - } - } else { - return lastCollection; - } - }, [ - triggerState.isOpen, - originalCollection, - filteredCollection, - showAllItems, - lastCollection, - ]); - - return { - ...validation, - focusStrategy: triggerState.focusStrategy, - isOpen: triggerState.isOpen, - setOpen: triggerState.setOpen, - toggle, - open, - close: commit, - selectionManager: listState.selectionManager, - disabledKeys: listState.disabledKeys, - collection: displayedCollection, - inputValue, - setInputValue, - }; -} - -type FilterFn = (textValue: string, inputValue: string) => boolean; - -function filterCollection( - collection: Collection>, - inputValue: string, - filter: FilterFn -): Collection> { - return new ListCollection( - filterNodes(collection, collection, inputValue, filter) - ); -} - -function filterNodes( - collection: Collection>, - nodes: Iterable>, - inputValue: string, - filter: FilterFn -): Iterable> { - let filteredNode: Node[] = []; - for (let node of nodes) { - if (node.type === 'section' && node.hasChildNodes) { - let filtered = filterNodes( - collection, - getChildNodes(node, collection), - inputValue, - filter - ); - if ([...filtered].some(node => node.type === 'item')) { - filteredNode.push({ ...node, childNodes: filtered }); - } - } else if (node.type === 'item' && filter(node.textValue, inputValue)) { - filteredNode.push({ ...node }); - } else if (node.type !== 'item') { - filteredNode.push({ ...node }); - } - } - return filteredNode; -} diff --git a/design-system/pkg/src/core/docs/index.mdoc b/design-system/pkg/src/core/docs/index.mdoc index 9308cbf7a..e75547e79 100644 --- a/design-system/pkg/src/core/docs/index.mdoc +++ b/design-system/pkg/src/core/docs/index.mdoc @@ -42,9 +42,9 @@ return ( > - First - Second - Third + First + Second + Third (props: DateFieldProps) { ? pref.ordering .split(' ') .map(p => calendars.find(c => c.key === p)) - .filter(Boolean) + .filter( + (calendar): calendar is NonNullable => + calendar !== undefined + ) : [calendars[0]], [pref] ); @@ -310,7 +319,7 @@ function Example(props: DateFieldProps) { value={locale} onChange={updateLocale} > - {item => {item.label}} + {item => {item.label}} (props: DateFieldProps) { } }} > -
    - {item => {item?.name || 'ERROR'}} -
    -
    - {item => {item.name}} -
    + + Preferred + + {item => {item.name}} + + + + Other + + {item => {item.name}} + +
    ( - - {element} - {content} - - ), - }; -}; - /** * DialogTrigger serves as a wrapper around a Dialog and its associated trigger, linking the Dialog's * open state with the trigger's press state. Additionally, it allows you to customize the type and * positioning of the Dialog. */ -// We don't want getCollectionNode to show up in the type definition -let _DialogTrigger = DialogTrigger as ( - props: DialogTriggerProps -) => JSX.Element; -export { _DialogTrigger as DialogTrigger }; +export { DialogTrigger }; function PopoverTrigger({ state, diff --git a/design-system/pkg/src/dialog/docs/index.mdoc b/design-system/pkg/src/dialog/docs/index.mdoc index 44ff59dd0..ee65d13f1 100644 --- a/design-system/pkg/src/dialog/docs/index.mdoc +++ b/design-system/pkg/src/dialog/docs/index.mdoc @@ -86,9 +86,9 @@ return ( <> setSize(old => (old ? null : key))}> {['small', 'medium', 'large'].map(key => ( - + {key} - + ))} setSize(null)} isDismissable> diff --git a/design-system/pkg/src/drag-and-drop/docs/index.mdoc b/design-system/pkg/src/drag-and-drop/docs/index.mdoc index 59bfa7db3..2589a3d52 100644 --- a/design-system/pkg/src/drag-and-drop/docs/index.mdoc +++ b/design-system/pkg/src/drag-and-drop/docs/index.mdoc @@ -172,10 +172,10 @@ return ( )} > {item => ( - + {item.name} - + )} diff --git a/design-system/pkg/src/drag-and-drop/stories/DropZone.stories.tsx b/design-system/pkg/src/drag-and-drop/stories/DropZone.stories.tsx index d323d60c7..7d2ff9bc6 100644 --- a/design-system/pkg/src/drag-and-drop/stories/DropZone.stories.tsx +++ b/design-system/pkg/src/drag-and-drop/stories/DropZone.stories.tsx @@ -11,7 +11,7 @@ import { Text } from '@keystar/ui/typography'; import { DropZone, FileDropItem, isFileDropItem } from '../index'; import { FileTrigger } from '../FileTrigger'; import { Image } from '@keystar/ui/image'; -import { ListView, Item } from '@keystar/ui/list-view'; +import { ListView, ListViewItem } from '@keystar/ui/list-view'; export default { title: 'Components/DropZone', @@ -221,10 +221,10 @@ export const RenderingContent = () => { )} > {item => ( - + {item.name} - + )} diff --git a/design-system/pkg/src/drag-and-drop/types.ts b/design-system/pkg/src/drag-and-drop/types.ts index fdb445f65..33d3ebd74 100644 --- a/design-system/pkg/src/drag-and-drop/types.ts +++ b/design-system/pkg/src/drag-and-drop/types.ts @@ -1,91 +1,11 @@ -import { - DraggableCollectionOptions, - DraggableItemProps, - DraggableItemResult, -} from 'react-aria/useDraggableCollection'; -import { DragPreview } from 'react-aria/useDrag'; -import { - DropIndicatorAria, - DropIndicatorProps, - DroppableCollectionOptions, - DroppableCollectionResult, - DroppableItemOptions, - DroppableItemResult, -} from 'react-aria/useDroppableCollection'; -import { - DraggableCollectionState, - DraggableCollectionStateOptions, -} from 'react-stately/useDraggableCollectionState'; -import { - DroppableCollectionState, - DroppableCollectionStateOptions, -} from 'react-stately/useDroppableCollectionState'; -import { - DraggableCollectionProps, - DroppableCollectionProps, - Key, - DragItem, -} from '@react-types/shared'; - -import { RefObject, type JSX } from 'react'; - -interface DraggableCollectionStateOpts - extends Omit {} - -export interface DragHooks { - useDraggableCollectionState?: ( - props: DraggableCollectionStateOpts - ) => DraggableCollectionState; - useDraggableCollection?: ( - props: DraggableCollectionOptions, - state: DraggableCollectionState, - ref: RefObject - ) => void; - useDraggableItem?: ( - props: DraggableItemProps, - state: DraggableCollectionState - ) => DraggableItemResult; - DragPreview?: typeof DragPreview; -} - -export interface DropHooks { - useDroppableCollectionState?: ( - props: DroppableCollectionStateOptions - ) => DroppableCollectionState; - useDroppableCollection?: ( - props: DroppableCollectionOptions, - state: DroppableCollectionState, - ref: RefObject - ) => DroppableCollectionResult; - useDroppableItem?: ( - options: DroppableItemOptions, - state: DroppableCollectionState, - ref: RefObject - ) => DroppableItemResult; - useDropIndicator?: ( - props: DropIndicatorProps, - state: DroppableCollectionState, - ref: RefObject - ) => DropIndicatorAria; -} +import type { + DragAndDropHooks as ReactAriaDragAndDropHooks, + DragAndDropOptions as ReactAriaDragAndDropOptions, +} from 'react-aria-components/useDragAndDrop'; export interface DragAndDropHooks { - /** Drag and drop hooks for the collection element. */ - dragAndDropHooks: DragHooks & - DropHooks & { - isVirtualDragging?: () => boolean; - renderPreview?: (keys: Set, draggedKey: Key | null) => JSX.Element; - }; + /** Drag and drop hooks for the collection element. */ + dragAndDropHooks: ReactAriaDragAndDropHooks; } -export interface DragAndDropOptions - extends Omit, - DroppableCollectionProps { - /** - * A function that returns the items being dragged. If not specified, we assume that the collection is not draggable. - * @default () => [] - */ - getItems?: (keys: Set) => DragItem[]; - /** Provide a custom drag preview. `draggedKey` represents the key of the item the user actually dragged. */ - renderPreview?: (keys: Set, draggedKey: Key | null) => JSX.Element; -} +export interface DragAndDropOptions extends ReactAriaDragAndDropOptions {} diff --git a/design-system/pkg/src/drag-and-drop/useDragAndDrop.ts b/design-system/pkg/src/drag-and-drop/useDragAndDrop.ts index fbbd6d2fa..32006d746 100644 --- a/design-system/pkg/src/drag-and-drop/useDragAndDrop.ts +++ b/design-system/pkg/src/drag-and-drop/useDragAndDrop.ts @@ -1,102 +1,13 @@ -/* eslint-disable react-compiler/react-compiler */ -import { DragPreview } from 'react-aria/useDrag'; -import { - DroppableCollectionOptions, - useDropIndicator, - useDroppableCollection, - useDroppableItem, -} from 'react-aria/useDroppableCollection'; -import { isVirtualDragging } from 'react-aria/private/dnd/DragManager'; -import { - useDraggableCollection, - useDraggableItem, -} from 'react-aria/useDraggableCollection'; -import { - DraggableCollectionStateOptions, - useDraggableCollectionState, -} from 'react-stately/useDraggableCollectionState'; -import { - DroppableCollectionState, - DroppableCollectionStateOptions, - useDroppableCollectionState, -} from 'react-stately/useDroppableCollectionState'; -import type { Key } from '@react-types/shared'; -import { RefObject, useMemo, type JSX } from 'react'; +import { useDragAndDrop as useReactAriaDragAndDrop } from 'react-aria-components/useDragAndDrop'; -import { - DragAndDropHooks, - DragAndDropOptions, - DragHooks, - DropHooks, -} from './types'; +import type { DragAndDropHooks, DragAndDropOptions } from './types'; /** - * Provides the hooks required to enable drag and drop behavior for a drag and drop compatible React Spectrum component. + * Provides the hooks required to enable drag and drop behavior for a drag and + * drop compatible collection component. + * + * This exposes the public React Aria Components drag-and-drop API. */ export function useDragAndDrop(options: DragAndDropOptions): DragAndDropHooks { - let dragAndDropHooks = useMemo(() => { - let { - onDrop, - onInsert, - onItemDrop, - onReorder, - onRootDrop, - getItems, - renderPreview, - } = options; - - let isDraggable = !!getItems; - let isDroppable = !!( - onDrop || - onInsert || - onItemDrop || - onReorder || - onRootDrop - ); - - let hooks = {} as DragHooks & - DropHooks & { - isVirtualDragging?: () => boolean; - renderPreview?: (keys: Set, draggedKey: Key | null) => JSX.Element; - }; - if (isDraggable) { - // @ts-expect-error - hooks.useDraggableCollectionState = - function useDraggableCollectionStateOverride( - props: DraggableCollectionStateOptions - ) { - return useDraggableCollectionState({ ...props, ...options }); - }; - hooks.useDraggableCollection = useDraggableCollection; - hooks.useDraggableItem = useDraggableItem; - hooks.DragPreview = DragPreview; - hooks.renderPreview = renderPreview; - } - - if (isDroppable) { - hooks.useDroppableCollectionState = - function useDroppableCollectionStateOverride( - props: DroppableCollectionStateOptions - ) { - return useDroppableCollectionState({ ...props, ...options }); - }; - hooks.useDroppableItem = useDroppableItem; - hooks.useDroppableCollection = function useDroppableCollectionOverride( - props: DroppableCollectionOptions, - state: DroppableCollectionState, - ref: RefObject - ) { - return useDroppableCollection({ ...props, ...options }, state, ref); - }; - hooks.useDropIndicator = useDropIndicator; - } - - if (isDraggable || isDroppable) { - hooks.isVirtualDragging = isVirtualDragging; - } - - return hooks; - }, [options]); - - return { dragAndDropHooks }; + return useReactAriaDragAndDrop(options); } diff --git a/design-system/pkg/src/editor/EditorAutocomplete.tsx b/design-system/pkg/src/editor/EditorAutocomplete.tsx new file mode 100644 index 000000000..a1ca2e6ad --- /dev/null +++ b/design-system/pkg/src/editor/EditorAutocomplete.tsx @@ -0,0 +1,29 @@ +import { + Autocomplete, + FieldInputContext, +} from 'react-aria-components/Autocomplete'; +import { useSlottedContext } from 'react-aria-components/slots'; +import type { AriaAttributes, KeyboardEventHandler } from 'react'; + +export { Autocomplete as EditorAutocomplete }; +export type { AutocompleteProps as EditorAutocompleteProps } from 'react-aria-components/Autocomplete'; + +export function useEditorAutocompleteInputProps(): Pick< + AriaAttributes, + 'aria-activedescendant' | 'aria-autocomplete' | 'aria-controls' +> & { + onKeyDown: KeyboardEventHandler; +} { + let inputProps = useSlottedContext(FieldInputContext); + let onKeyDown = inputProps?.onKeyDown as + | KeyboardEventHandler + | undefined; + return { + 'aria-activedescendant': inputProps?.['aria-activedescendant'], + 'aria-autocomplete': inputProps?.['aria-autocomplete'], + 'aria-controls': inputProps?.['aria-controls'], + onKeyDown(event) { + onKeyDown?.(event); + }, + }; +} diff --git a/design-system/pkg/src/editor/EditorListbox.tsx b/design-system/pkg/src/editor/EditorListbox.tsx index 45a2baf2b..dd9163499 100644 --- a/design-system/pkg/src/editor/EditorListbox.tsx +++ b/design-system/pkg/src/editor/EditorListbox.tsx @@ -1,97 +1,20 @@ -import { ListKeyboardDelegate } from 'react-aria/ListKeyboardDelegate'; -import { useSelectableCollection } from 'react-aria/private/selection/useSelectableCollection'; -import { chain } from 'react-aria/chain'; -import { useListState } from 'react-stately/useListState'; -import { - AriaLabelingProps, - CollectionBase, - MultipleSelection, -} from '@react-types/shared'; -import { Key, RefObject, useEffect, useMemo, useRef } from 'react'; +import type { Key } from '@react-types/shared'; -import { ListBoxBase, listStyles, useListBoxLayout } from '@keystar/ui/listbox'; -import { BaseStyleProps } from '@keystar/ui/style'; +import { ListBox, type ListBoxProps } from '@keystar/ui/listbox'; -export type EditorListboxProps = { - listenerRef: RefObject; - scrollRef?: RefObject; +export interface EditorListboxProps + extends Omit, 'onAction'> { onAction?: (key: Key) => void; - onEscape?: () => void; -} & CollectionBase & - AriaLabelingProps & - MultipleSelection & - Pick< - BaseStyleProps, - | 'height' - | 'width' - | 'maxHeight' - | 'maxWidth' - | 'minHeight' - | 'minWidth' - | 'UNSAFE_className' - | 'UNSAFE_style' - >; +} export function EditorListbox(props: EditorListboxProps) { - let { listenerRef, onEscape, scrollRef, ...otherProps } = props; - let state = useListState(props); - let layout = useListBoxLayout(); - let listboxRef = useRef(null); - let delegate = useMemo( - () => - new ListKeyboardDelegate({ - collection: state.collection, - ref: listboxRef, - layoutDelegate: layout, - }), - [layout, state.collection] - ); - - // keyboard and selection management - let { collectionProps } = useSelectableCollection({ - keyboardDelegate: delegate, - ref: listenerRef, - scrollRef: scrollRef ?? listboxRef, - selectionManager: state.selectionManager, - disallowEmptySelection: true, - disallowTypeAhead: true, - isVirtualized: true, - shouldFocusWrap: true, - }); - - let onKeyDown = (e: KeyboardEvent) => { - switch (e.key) { - case 'Enter': - if (state.selectionManager.focusedKey) { - state.selectionManager.select(state.selectionManager.focusedKey); - props.onAction?.(state.selectionManager.focusedKey); - } - break; - case 'Escape': - onEscape?.(); - break; - } - }; - - let keydownListener = chain(onKeyDown, collectionProps.onKeyDown); - - useEffect(() => { - let domNode = listenerRef.current; - domNode?.addEventListener('keydown', keydownListener); - return () => domNode?.removeEventListener('keydown', keydownListener); - }, [keydownListener, listenerRef]); - + let { onAction, ...listBoxProps } = props; return ( - ); } diff --git a/design-system/pkg/src/editor/index.ts b/design-system/pkg/src/editor/index.ts index 3c2142192..38dcb4e91 100644 --- a/design-system/pkg/src/editor/index.ts +++ b/design-system/pkg/src/editor/index.ts @@ -1,7 +1,19 @@ 'use client'; export { EditorListbox } from './EditorListbox'; -export { Item, Section } from '@keystar/ui/listbox'; +export type { EditorListboxProps } from './EditorListbox'; +export { + EditorAutocomplete, + useEditorAutocompleteInputProps, +} from './EditorAutocomplete'; +export type { EditorAutocompleteProps } from './EditorAutocomplete'; +export { + ListBoxHeader as EditorListboxHeader, + ListBoxItem as EditorListboxItem, + ListBoxLoadMoreItem as EditorListboxLoadMoreItem, + ListBoxSection as EditorListboxSection, +} from '@keystar/ui/listbox'; +export { Collection as EditorListboxCollection } from 'react-aria-components/Collection'; export { EditorPopover } from './EditorPopover'; export { EditorToolbar, diff --git a/design-system/pkg/src/editor/stories/EditorListbox.stories.tsx b/design-system/pkg/src/editor/stories/EditorListbox.stories.tsx index 473cb96ac..37609c6d0 100644 --- a/design-system/pkg/src/editor/stories/EditorListbox.stories.tsx +++ b/design-system/pkg/src/editor/stories/EditorListbox.stories.tsx @@ -1,5 +1,6 @@ import { action } from '@keystar/ui-storybook'; -import { Key, ReactElement, useEffect, useRef } from 'react'; +import { ReactElement, ReactNode } from 'react'; +import type { Key } from '@react-types/shared'; import { Icon } from '@keystar/ui/icon'; import { fileCodeIcon } from '@keystar/ui/icon/icons/fileCodeIcon'; @@ -17,7 +18,15 @@ import { tableIcon } from '@keystar/ui/icon/icons/tableIcon'; import { separatorHorizontalIcon } from '@keystar/ui/icon/icons/separatorHorizontalIcon'; import { Kbd, KbdProps, Text } from '@keystar/ui/typography'; -import { EditorListbox, Item, Section } from '..'; +import { + EditorAutocomplete, + EditorListbox, + EditorListboxCollection, + EditorListboxHeader, + EditorListboxItem, + EditorListboxSection, + useEditorAutocompleteInputProps, +} from '..'; type KbdOption = 'alt' | 'meta' | 'shift'; type KbdOptions = KbdOption[]; @@ -43,7 +52,7 @@ let basicItems = [ ]; let manyItems: any[] = []; for (let i = 0; i < 50; i++) { - manyItems.push({ label: 'Item ' + i, id: i }); + manyItems.push({ label: 'EditorListboxItem ' + i, id: i }); } export default { @@ -51,16 +60,16 @@ export default { }; export const Default = () => { - let listenerRef = useListenerRef(); return ( - + + + ); }; @@ -69,17 +78,17 @@ Default.story = { }; export const ManyItems = () => { - let listenerRef = useListenerRef(); return ( - + + + ); }; @@ -88,19 +97,22 @@ ManyItems.story = { }; export const ComplexItems = () => { - let listenerRef = useListenerRef(); return ( - ( -
    - {section.children.map(childRenderer)} -
    - )} - listenerRef={listenerRef} - width="container.xsmall" - /> + + ( + + {section.label} + + {childRenderer} + + + )} + width="container.xsmall" + /> + ); }; @@ -111,22 +123,30 @@ ComplexItems.story = { // Utils // ----------------------------------------------------------------------------- -function useListenerRef() { - let listenerRef = useRef(null); - useEffect(() => { - listenerRef.current = document.documentElement; - }, []); - return listenerRef; +function EditorListboxExample({ children }: { children: ReactNode }) { + return ( + + + {children} + + ); +} + +function EditorInput() { + let inputProps = useEditorAutocompleteInputProps(); + return ( +
    + ); } function childRenderer(item: ItemType) { return ( - + {item.label} {item.description && {item.description}} {item.kbd && } {item.icon && } - + ); } function getKbdProps(format: KbdFormat) { diff --git a/design-system/pkg/src/editor/test/EditorListbox.test.tsx b/design-system/pkg/src/editor/test/EditorListbox.test.tsx new file mode 100644 index 000000000..bef8d1c24 --- /dev/null +++ b/design-system/pkg/src/editor/test/EditorListbox.test.tsx @@ -0,0 +1,49 @@ +import { fireEvent, renderWithProvider } from '#test-utils'; +import { beforeAll, describe, expect, it, jest } from '@jest/globals'; + +import { + EditorAutocomplete, + EditorListbox, + EditorListboxItem, + useEditorAutocompleteInputProps, +} from '..'; + +describe('editor/EditorListbox', () => { + beforeAll(() => { + jest + .spyOn(window.HTMLElement.prototype, 'clientWidth', 'get') + .mockImplementation(() => 1000); + jest + .spyOn(window.HTMLElement.prototype, 'clientHeight', 'get') + .mockImplementation(() => 1000); + window.HTMLElement.prototype.scrollIntoView = jest.fn(); + }); + + it('connects RAC autocomplete keyboard semantics to a contenteditable', () => { + function EditorInput() { + let inputProps = useEditorAutocompleteInputProps(); + return
    ; + } + + function Example() { + return ( + + + + Paragraph + Heading + + + ); + } + + let result = renderWithProvider(); + let editor = result.getByRole('textbox'); + let listbox = result.getByRole('listbox', { name: 'Insert block' }); + + expect(editor).toHaveAttribute('aria-autocomplete', 'list'); + expect(editor).toHaveAttribute('aria-controls', listbox.id); + fireEvent.keyDown(editor, { key: 'ArrowDown' }); + expect(editor).toHaveAttribute('aria-activedescendant'); + }); +}); diff --git a/design-system/pkg/src/list-view/DragPreview.tsx b/design-system/pkg/src/list-view/DragPreview.tsx deleted file mode 100644 index 7cf6c669a..000000000 --- a/design-system/pkg/src/list-view/DragPreview.tsx +++ /dev/null @@ -1,149 +0,0 @@ -import { GridNode } from 'react-stately/private/grid/GridCollection'; - -import { Flex, Grid } from '@keystar/ui/layout'; -import { SlotProvider } from '@keystar/ui/slots'; -import { - classNames, - css, - toDataAttributes, - tokenSchema, -} from '@keystar/ui/style'; -import { Text } from '@keystar/ui/typography'; -import { isReactText } from '@keystar/ui/utils'; - -import { listViewItemClassList } from './class-list'; -import type { ListViewProps } from './types'; - -interface DragPreviewProps { - item: GridNode; - itemCount: number; - itemHeight: number; - density: ListViewProps['density']; -} - -export function DragPreview(props: DragPreviewProps) { - let { item, itemCount, itemHeight, density } = props; - - let isDraggingMultiple = itemCount > 1; - - return ( -
    - - - {isReactText(item.rendered) ? ( - {item.rendered} - ) : ( - item.rendered - )} - - {isDraggingMultiple && ( - - - {itemCount} - - - )} - - -
    - ); -} diff --git a/design-system/pkg/src/list-view/InsertionIndicator.tsx b/design-system/pkg/src/list-view/InsertionIndicator.tsx deleted file mode 100644 index b98e4d60b..000000000 --- a/design-system/pkg/src/list-view/InsertionIndicator.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { useVisuallyHidden } from 'react-aria/VisuallyHidden'; -import { ItemDropTarget } from '@react-types/shared'; -import { assert } from 'emery'; -import { useRef } from 'react'; - -import { InsertionIndicatorPrimitive } from '@keystar/ui/drag-and-drop'; - -import { useListViewContext } from './context'; - -interface InsertionIndicatorProps { - target: ItemDropTarget; - isPresentationOnly?: boolean; -} - -export function InsertionIndicator(props: InsertionIndicatorProps) { - let { dropState, dragAndDropHooks } = useListViewContext(); - const { target, isPresentationOnly } = props; - - assert( - !!dragAndDropHooks.useDropIndicator, - 'dragAndDropHooks.useDropIndicator is not defined.' - ); - - let ref = useRef(null); - // eslint-disable-next-line react-compiler/react-compiler - let { dropIndicatorProps } = dragAndDropHooks.useDropIndicator( - props, - dropState, - ref - ); - let { visuallyHiddenProps } = useVisuallyHidden(); - - let isDropTarget = dropState.isDropTarget(target); - - if (!isDropTarget && dropIndicatorProps['aria-hidden']) { - return null; - } - - return ( -
    - - {!isPresentationOnly && ( -
    - )} - -
    - ); -} diff --git a/design-system/pkg/src/list-view/ListView.tsx b/design-system/pkg/src/list-view/ListView.tsx index 789609e8d..78a23fcab 100644 --- a/design-system/pkg/src/list-view/ListView.tsx +++ b/design-system/pkg/src/list-view/ListView.tsx @@ -1,29 +1,21 @@ -import { useGridList } from 'react-aria/useGridList'; -import type { DroppableCollectionResult } from 'react-aria/useDroppableCollection'; -import { FocusScope } from 'react-aria/FocusScope'; -import { useLocalizedStringFormatter } from 'react-aria/useLocalizedStringFormatter'; -import { Virtualizer } from 'react-aria/private/virtualizer/Virtualizer'; -import { filterDOMProps } from 'react-aria/filterDOMProps'; -import { mergeProps } from 'react-aria/mergeProps'; -import { useObjectRef } from 'react-aria/useObjectRef'; -import type { DraggableCollectionState } from 'react-stately/useDraggableCollectionState'; -import type { DroppableCollectionState } from 'react-stately/useDroppableCollectionState'; -import { ListState, useListState } from 'react-stately/useListState'; -import { assert } from 'emery'; +import { GridList, GridListLoadMoreItem } from 'react-aria-components/GridList'; +import { + ListLayout, + type ListLayoutOptions, + Virtualizer, +} from 'react-aria-components/Virtualizer'; import React, { - Key, - PropsWithChildren, - ReactElement, - RefObject, - useEffect, + createContext, + type ForwardedRef, + type ReactElement, + type ReactNode, useMemo, - useRef, } from 'react'; +import { useLocalizedStringFormatter } from 'react-aria/useLocalizedStringFormatter'; import { useProvider } from '@keystar/ui/core'; import { ProgressCircle } from '@keystar/ui/progress'; import { - FocusRing, classNames, css, toDataAttributes, @@ -32,385 +24,156 @@ import { } from '@keystar/ui/style'; import { listViewClassList } from './class-list'; -import { ListViewProvider, useListViewContext } from './context'; import localizedMessages from './l10n'; -import { DragPreview as DragPreviewElement } from './DragPreview'; -import { InsertionIndicator } from './InsertionIndicator'; -import { ListViewItem } from './ListViewItem'; -import { ListViewLayout } from './ListViewLayout'; -import RootDropIndicator from './RootDropIndicator'; -import { ListViewProps } from './types'; -import { ListKeyboardDelegate } from 'react-aria/ListKeyboardDelegate'; +import type { ListViewLoadMoreItemProps, ListViewProps } from './types'; const ROW_HEIGHTS = { - compact: { - medium: 32, - large: 40, - }, - regular: { - medium: 40, - large: 50, - }, - spacious: { - medium: 48, - large: 60, - }, + compact: { medium: 32, large: 40 }, + regular: { medium: 40, large: 50 }, + spacious: { medium: 48, large: 60 }, } as const; -function useListLayout( - state: ListState, - density: NonNullable['density']>, - overflowMode: ListViewProps['overflowMode'] -) { - let { scale } = useProvider(); - let layout = useMemo( - () => - new ListViewLayout({ - estimatedRowHeight: - overflowMode === 'wrap' ? undefined : ROW_HEIGHTS[density][scale], - }), - [scale, density, overflowMode] - ); - - return layout; -} +export const ListViewContext = createContext({ + density: 'regular' as NonNullable['density']>, + hasActions: false, + overflowMode: 'truncate' as NonNullable< + ListViewProps['overflowMode'] + >, +}); function ListView( props: ListViewProps, - ref: RefObject + ref: ForwardedRef ) { let { density = 'regular', - loadingState, - onLoadMore, - isQuiet, overflowMode = 'truncate', onAction, dragAndDropHooks, renderEmptyState, + selectionStyle = 'checkbox', ...otherProps } = props; - - let isListDraggable = !!dragAndDropHooks?.useDraggableCollectionState; - let isListDroppable = !!dragAndDropHooks?.useDroppableCollectionState; - - let dragHooksProvided = useRef(isListDraggable); - let dropHooksProvided = useRef(isListDroppable); - - useEffect(() => { - if (dragHooksProvided.current !== isListDraggable) { - console.warn( - 'Drag hooks were provided during one render, but not another. This should be avoided as it may produce unexpected behavior.' - ); - } - if (dropHooksProvided.current !== isListDroppable) { - console.warn( - 'Drop hooks were provided during one render, but not another. This should be avoided as it may produce unexpected behavior.' - ); - } - }, [isListDraggable, isListDroppable]); - - let domRef = useObjectRef(ref); - let state = useListState({ - ...props, - selectionBehavior: - props.selectionStyle === 'highlight' ? 'replace' : 'toggle', - }); - let { collection, selectionManager } = state; - let stringFormatter = useLocalizedStringFormatter(localizedMessages); - let isLoading = loadingState === 'loading' || loadingState === 'loadingMore'; - + let selectionBehavior: 'replace' | 'toggle' = + selectionStyle === 'highlight' ? 'replace' : 'toggle'; + let { layout, layoutOptions } = useListLayout(density, overflowMode); let styleProps = useStyleProps(props); - let preview = useRef(null); - - // DraggableCollectionState; - let dragState!: DraggableCollectionState; - if ( - isListDraggable && - dragAndDropHooks?.useDraggableCollectionState && - dragAndDropHooks?.useDraggableCollection - ) { - // consumers are warned when hooks change between renders - // eslint-disable-next-line react-compiler/react-compiler - dragState = dragAndDropHooks.useDraggableCollectionState({ - collection, - selectionManager, - preview, - }); - // eslint-disable-next-line react-compiler/react-compiler - dragAndDropHooks.useDraggableCollection({}, dragState, domRef); - } - let layout = useListLayout(state, props.density || 'regular', overflowMode); - - let DragPreview = dragAndDropHooks?.DragPreview; - let dropState!: DroppableCollectionState; - let droppableCollection: DroppableCollectionResult; - let isRootDropTarget: boolean; - if ( - isListDroppable && - dragAndDropHooks?.useDroppableCollectionState && - dragAndDropHooks?.useDroppableCollection - ) { - // consumers are warned when hooks change between renders - // eslint-disable-next-line react-compiler/react-compiler - dropState = dragAndDropHooks.useDroppableCollectionState({ - collection, - selectionManager, - }); - // eslint-disable-next-line react-compiler/react-compiler - droppableCollection = dragAndDropHooks.useDroppableCollection( - { - keyboardDelegate: new ListKeyboardDelegate({ - collection, - disabledKeys: dragState?.draggingKeys.size - ? undefined - : selectionManager.disabledKeys, - ref: domRef, - layoutDelegate: layout, - }), - dropTargetDelegate: layout, - }, - dropState, - domRef - ); - - isRootDropTarget = dropState.isDropTarget({ type: 'root' }); - } - - let { gridProps } = useGridList( - { - ...props, - isVirtualized: true, - layoutDelegate: layout, - onAction, - }, - state, - domRef - ); - - let focusedKey = selectionManager.focusedKey; - let dropTargetKey: Key | null = null; - if (dropState?.target?.type === 'item') { - dropTargetKey = dropState.target.key; - if (dropState.target.dropPosition === 'after') { - // Normalize to the "before" drop position since we only render those in the DOM. - dropTargetKey = - state.collection.getKeyAfter(dropTargetKey) ?? dropTargetKey; - } - } - - let persistedKeys = useMemo(() => { - return new Set([focusedKey, dropTargetKey].filter(k => k !== null)); - }, [focusedKey, dropTargetKey]); - - let hasAnyChildren = useMemo( - () => [...collection].some(item => item.hasChildNodes), - [collection] - ); - let { onScroll: _onScroll, ...virtualizerGridProps } = gridProps; return ( - - - - ({ isLoading }), [isLoading])} - collection={collection} - className={classNames( - listViewClassList.element('root'), - css({ - backgroundColor: tokenSchema.color.background.canvas, - border: `${tokenSchema.size.border.regular} solid ${tokenSchema.color.border.neutral}`, - borderRadius: tokenSchema.size.radius.medium, - boxSizing: 'content-box', // resolves measurement/scroll issues related to border - outline: 0, - overflow: 'auto', - position: 'relative', - transform: 'translate3d(0, 0, 0)', - userSelect: 'none', - - '&[data-drop-target=true]': { - borderColor: tokenSchema.color.alias.focusRing, - backgroundColor: tokenSchema.color.alias.backgroundSelected, - boxShadow: `inset 0 0 0 1px ${tokenSchema.color.alias.focusRing}`, - }, - '&[data-focus=visible]': { - borderColor: tokenSchema.color.alias.focusRing, - boxShadow: `inset 0 0 0 1px ${tokenSchema.color.alias.focusRing}`, - }, - }), - styleProps.className - )} - > - {(type, item) => { - if (type === 'item') { - return ( - <> - {isListDroppable && - collection.getKeyBefore(item.key) == null && ( - - )} - {isListDroppable && ( - - )} - - {isListDroppable && ( - - )} - - ); - } else if (type === 'loader') { - return ( - - 0 - ? stringFormatter.format('loadingMore') - : stringFormatter.format('loading') - } - /> - - ); - } else if (type === 'placeholder') { - let emptyState = props.renderEmptyState - ? props.renderEmptyState() - : null; - if (emptyState == null) { - return null; - } - - return {emptyState}; - } - }} - - - - {DragPreview && isListDraggable && ( - - {() => { - // @ts-expect-error FIXME - let item = state.collection.getItem(dragState.draggedKey); + + ( + {renderEmptyState(states)} + ) + : undefined + } + /> + + + ); +} - assert(item != null, 'Dragged item must exist in collection.'); +const _ListView = React.forwardRef(ListView) as ( + props: ListViewProps & { ref?: ForwardedRef } +) => ReactElement; - let itemCount = dragState.draggingKeys.size; - // @ts-expect-error - let itemHeight = layout.getLayoutInfo(dragState.draggedKey).rect - .height; +export { _ListView as ListView }; - return ( - - ); - }} - - )} - +export function ListViewLoadMoreItem(props: ListViewLoadMoreItemProps) { + let stringFormatter = useLocalizedStringFormatter(localizedMessages); + let { + children, + isLoading, + 'aria-label': ariaLabel = stringFormatter.format('loadingMore'), + ...otherProps + } = props; + let styleProps = useStyleProps(props); + return ( + + {children ?? + (isLoading ? ( + + + + ) : null)} + ); } -function CenteredWrapper({ children }: PropsWithChildren) { - let { state } = useListViewContext(); +function useListLayout( + density: NonNullable['density']>, + overflowMode: ListViewProps['overflowMode'] +) { + let { scale } = useProvider(); + return useMemo(() => { + let layoutOptions: ListLayoutOptions = { + estimatedRowSize: + overflowMode === 'wrap' ? undefined : ROW_HEIGHTS[density][scale], + }; + return { + layout: new ListLayout(layoutOptions), + layoutOptions, + }; + }, [scale, density, overflowMode]); +} + +function CenteredWrapper({ children }: { children: ReactNode }) { return (
    0} - className={classNames( - listViewClassList.element('centered-wrapper'), - css({ - display: 'flex', - justifyContent: 'center', - alignItems: 'center', - height: '100%', - width: '100%', - - // if there's items it must be loading—add a gutter between the items - // and the loading indicator - '&[data-has-items=true]': { - paddingTop: tokenSchema.size.space.regular, - }, - }) - )} + className={css({ + alignItems: 'center', + display: 'flex', + height: '100%', + justifyContent: 'center', + width: '100%', + })} > -
    {children}
    + {children}
    ); } - -/** - * Displays a list of interactive items, and allows a user to navigate, select, - * or perform an action. - */ -const _ListView = React.forwardRef(ListView as any) as ( - props: ListViewProps & { ref?: RefObject } -) => ReactElement; -export { _ListView as ListView }; diff --git a/design-system/pkg/src/list-view/ListViewItem.tsx b/design-system/pkg/src/list-view/ListViewItem.tsx index 3e249fed5..781a940e3 100644 --- a/design-system/pkg/src/list-view/ListViewItem.tsx +++ b/design-system/pkg/src/list-view/ListViewItem.tsx @@ -1,478 +1,317 @@ -import type { DraggableItemResult } from 'react-aria/useDraggableCollection'; -import type { - DropIndicatorAria, - DroppableItemResult, -} from 'react-aria/useDroppableCollection'; - -import { useButton } from 'react-aria/useButton'; -import { useFocusRing } from 'react-aria/useFocusRing'; -import { useHover } from 'react-aria/useHover'; import { - useGridListItem, - useGridListSelectionCheckbox, -} from 'react-aria/useGridList'; + Button as AriaButton, + Checkbox as AriaCheckbox, + GridListItem, + Text as AriaText, + type GridListItemRenderProps, +} from 'react-aria-components/GridList'; import { useLocale } from 'react-aria/I18nProvider'; -import { mergeProps } from 'react-aria/mergeProps'; -import { useVisuallyHidden } from 'react-aria/VisuallyHidden'; -import { DropTarget, Node } from '@react-types/shared'; - -import { assert } from 'emery'; -import React, { HTMLAttributes, useRef } from 'react'; +import { + Children, + type ForwardedRef, + type ReactElement, + isValidElement, + type ReactNode, + forwardRef, + useContext, +} from 'react'; -import { Checkbox } from '@keystar/ui/checkbox'; import { KeystarProvider } from '@keystar/ui/core'; import { Icon } from '@keystar/ui/icon'; import { chevronLeftIcon } from '@keystar/ui/icon/icons/chevronLeftIcon'; import { chevronRightIcon } from '@keystar/ui/icon/icons/chevronRightIcon'; import { gripVerticalIcon } from '@keystar/ui/icon/icons/gripVerticalIcon'; -import { Flex, Grid } from '@keystar/ui/layout'; +import { Grid } from '@keystar/ui/layout'; +import { CheckboxIndicator } from '../checkbox/Checkbox'; import { ClearSlots, SlotProvider } from '@keystar/ui/slots'; import { - FocusRing, classNames, css, toDataAttributes, tokenSchema, - transition, + useStyleProps, } from '@keystar/ui/style'; import { Text } from '@keystar/ui/typography'; import { isReactText } from '@keystar/ui/utils'; import { listViewClassList, listViewItemClassList } from './class-list'; -import { useListViewContext } from './context'; - -interface ListViewItemProps { - item: Node; - isEmphasized: boolean; - hasActions: boolean; -} +import { ListViewContext } from './ListView'; +import type { ListViewItemProps } from './types'; -export function ListViewItem(props: ListViewItemProps) { - let { item } = props; - let { - density, - dragAndDropHooks, - dragState, - dropState, - isListDraggable, - isListDroppable, - layout, - loadingState, - overflowMode, - state, - } = useListViewContext(); +function ListViewItem( + props: ListViewItemProps, + forwardedRef: ForwardedRef +) { + let { children, hasChildItems, ...otherProps } = props; + let { density, overflowMode, hasActions } = useContext(ListViewContext); + let styleProps = useStyleProps(props); let { direction } = useLocale(); - let rowRef = useRef(null); - let { isFocusVisible: isFocusVisibleWithin, focusProps: focusWithinProps } = - useFocusRing({ within: true }); - let { isFocusVisible, focusProps } = useFocusRing(); - let { - rowProps, - gridCellProps, - isPressed, - descriptionProps, - isDisabled, - allowsSelection, - hasAction, - } = useGridListItem( - { - node: item, - isVirtualized: true, - shouldSelectOnPressUp: isListDraggable, - }, - state, - rowRef - ); - let isDroppable = isListDroppable && !isDisabled; - let { hoverProps, isHovered } = useHover({ - isDisabled: !allowsSelection && !hasAction, - }); - - let { checkboxProps } = useGridListSelectionCheckbox( - { key: item.key }, - state - ); - - let draggableItem: DraggableItemResult | null; - if (isListDraggable) { - // FIXME: improve implementation/types such that these guards aren't necessary - assert( - !!(dragAndDropHooks && dragAndDropHooks.useDraggableItem), - 'useDraggableItem is missing from dragAndDropHooks' - ); - draggableItem = dragAndDropHooks.useDraggableItem( - { key: item.key, hasDragButton: true }, - dragState - ); - if (isDisabled) { - draggableItem = null; - } - } - let droppableItem: DroppableItemResult; - let isDropTarget: boolean; - let dropIndicator: DropIndicatorAria; - let dropIndicatorRef = useRef(null); - if (isListDroppable) { - let target = { - type: 'item', - key: item.key, - dropPosition: 'on', - } as DropTarget; - isDropTarget = dropState.isDropTarget(target); - // FIXME: improve implementation/types such that these guards aren't necessary - assert( - !!(dragAndDropHooks && dragAndDropHooks.useDropIndicator), - 'useDropIndicator is missing from dragAndDropHooks' - ); - dropIndicator = dragAndDropHooks.useDropIndicator( - { target }, - dropState, - dropIndicatorRef - ); - } - - let dragButtonRef = useRef(null); - let { buttonProps } = useButton( - { - // @ts-expect-error - ...draggableItem?.dragButtonProps, - elementType: 'div', - }, - dragButtonRef - ); - let chevron = ( -