diff --git a/.eslintignore b/.eslintignore index a9bfa7b1ede..ee0245f39b0 100644 --- a/.eslintignore +++ b/.eslintignore @@ -7,4 +7,4 @@ node_modules packages/*/*/dist packages/react-aria/dist packages/react-stately/dist -packages/dev/storybook-builder-preview/preview.js +packages/dev/storybook-builder-parcel/preview.js diff --git a/.storybook/custom-addons/strictmode/index.js b/.storybook/custom-addons/strictmode/index.js new file mode 100644 index 00000000000..49415f62acf --- /dev/null +++ b/.storybook/custom-addons/strictmode/index.js @@ -0,0 +1,37 @@ +import {addons, makeDecorator} from '@storybook/addons'; +import {getQueryParams} from '@storybook/client-api'; +import React, {StrictMode, useEffect, useState} from 'react'; + +function StrictModeDecorator(props) { + let {children} = props; + let [isStrict, setStrict] = useState(getQueryParams()?.strict === 'true' || false); + + useEffect(() => { + let channel = addons.getChannel(); + let updateStrict = (val) => { + setStrict(val); + }; + channel.on('strict/updated', updateStrict); + return () => { + channel.removeListener('strict/updated', updateStrict); + }; + }, []); + + return isStrict ? ( + + {children} + + ) : children; +} + +export const withStrictModeSwitcher = makeDecorator({ + name: 'withStrictModeSwitcher', + parameterName: 'strictModeSwitcher', + wrapper: (getStory, context) => { + return ( + + {getStory(context)} + + ); + } +}); diff --git a/.storybook/custom-addons/strictmode/register.js b/.storybook/custom-addons/strictmode/register.js new file mode 100644 index 00000000000..b226fe1e0b3 --- /dev/null +++ b/.storybook/custom-addons/strictmode/register.js @@ -0,0 +1,40 @@ +import {addons, types} from '@storybook/addons'; +import {getQueryParams} from '@storybook/client-api'; +import React, {useEffect, useState} from 'react'; + +const StrictModeToolBar = ({api}) => { + let channel = addons.getChannel(); + let [isStrict, setStrict] = useState(getQueryParams()?.strict === 'true' || false); + let onChange = () => { + setStrict((old) => { + channel.emit('strict/updated', !old); + return !old; + }) + }; + + useEffect(() => { + api.setQueryParams({ + 'strict': isStrict + }); + }); + + return ( +
+
+ +
+
+ ); +}; + +addons.register('StrictModeSwitcher', (api) => { + addons.add('StrictModeSwitcher', { + title: 'Strict mode switcher', + type: types.TOOL, + //👇 Shows the Toolbar UI element if either the Canvas or Docs tab is active + match: ({ viewMode }) => !!(viewMode && viewMode.match(/^(story|docs)$/)), + render: () => + }); +}); diff --git a/.storybook/main.js b/.storybook/main.js index 24a0f04b09d..5d73778c058 100644 --- a/.storybook/main.js +++ b/.storybook/main.js @@ -11,13 +11,11 @@ module.exports = { 'storybook-dark-mode', './custom-addons/provider/register', './custom-addons/descriptions/register', - './custom-addons/theme/register' + './custom-addons/theme/register', + './custom-addons/strictmode/register' ], typescript: { check: false, reactDocgen: false - }, - reactOptions: { - strictMode: process.env.STRICT_MODE - }, + } }; diff --git a/.storybook/preview.js b/.storybook/preview.js index 0b268d77a1b..bc696a5a6b5 100644 --- a/.storybook/preview.js +++ b/.storybook/preview.js @@ -2,6 +2,7 @@ import {configureActions} from '@storybook/addon-actions'; import React from 'react'; import {VerticalCenter} from './layout'; import {withProviderSwitcher} from './custom-addons/provider'; +import {withStrictModeSwitcher} from './custom-addons/strictmode'; // decorator order matters, the last one will be the outer most @@ -29,5 +30,6 @@ export const decorators = [ ), + withStrictModeSwitcher, withProviderSwitcher ]; diff --git a/package.json b/package.json index 049bc351fb4..5679a401113 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,6 @@ "install-16": "yarn add -W react@^16.8.0 react-dom@^16.8.0 @testing-library/react@^12 @testing-library/react-hooks@^8", "install-17": "yarn add -W react@^17 react-dom@^17 @testing-library/react@^12 @testing-library/react-hooks@^8", "start": "cross-env NODE_ENV=storybook start-storybook -p 9003 --ci -c '.storybook'", - "start-strict": "cross-env NODE_ENV=storybook STRICT_MODE=1 start-storybook -p 9003 --ci -c '.storybook'", "build:storybook": "build-storybook -c .storybook -o dist/$(git rev-parse HEAD)/storybook", "build:storybook-16": "build-storybook -c .storybook -o dist/$(git rev-parse HEAD)/storybook-16", "build:storybook-17": "build-storybook -c .storybook -o dist/$(git rev-parse HEAD)/storybook-17", diff --git a/packages/@adobe/spectrum-css-temp/components/tags/index.css b/packages/@adobe/spectrum-css-temp/components/tags/index.css index 82cffe91b12..28d36d50e42 100644 --- a/packages/@adobe/spectrum-css-temp/components/tags/index.css +++ b/packages/@adobe/spectrum-css-temp/components/tags/index.css @@ -13,7 +13,7 @@ governing permissions and limitations under the License. @import '../commons/index.css'; .spectrum-Tags { - display: inline-flex; + display: flex; flex-wrap: wrap; margin: 0; @@ -27,7 +27,7 @@ governing permissions and limitations under the License. --spectrum-focus-ring-border-size: var(--spectrum-tag-border-size); display: grid; - grid-template-columns: 1fr auto; + grid-template-columns: auto 1fr auto; grid-template-areas: "icon content action"; align-items: center; box-sizing: border-box; @@ -58,28 +58,36 @@ governing permissions and limitations under the License. height: calc(var(--spectrum-tag-height) - (2 * var(--spectrum-tag-border-size))); width: var(--spectrum-global-dimension-size-300); } -} -.spectrum-Tag-icon { - grid-area: icon; - margin-inline-end: var(--spectrum-global-dimension-size-100); -} + .spectrum-Tag-cell { + overflow: hidden; + text-overflow: ellipsis; + display: flex; + align-items: center; + } -.spectrum-Tag-content { - grid-area: content; - block-size: 100%; - line-height: calc(var(--spectrum-tag-height) - calc(var(--spectrum-tag-border-size) * 2)); - margin-inline-end: var(--spectrum-tag-padding-x); - flex: 1 1 auto; - font-size: var(--spectrum-tag-text-size); - cursor: default; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - outline: none; -} + .spectrum-Tag-icon { + grid-area: icon; + margin-inline-end: var(--spectrum-global-dimension-size-100); + } + + .spectrum-Tag-content { + grid-area: content; + line-height: calc(var(--spectrum-tag-height) - calc(var(--spectrum-tag-border-size) * 2)); + margin-inline-end: var(--spectrum-tag-padding-x); + flex: 1 1 auto; + font-size: var(--spectrum-tag-text-size); + cursor: default; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + outline: none; + } -.tags-removable { - margin-inline-end: 0; + &.is-removable { + .spectrum-Tag-content { + margin-inline-end: 0; + } + } } diff --git a/packages/@react-aria/table/src/useTableColumnResize.ts b/packages/@react-aria/table/src/useTableColumnResize.ts index 5cc67a338b1..8f2c3b65850 100644 --- a/packages/@react-aria/table/src/useTableColumnResize.ts +++ b/packages/@react-aria/table/src/useTableColumnResize.ts @@ -12,7 +12,7 @@ import {ChangeEvent, Key, RefObject, useCallback, useRef} from 'react'; import {ColumnSize} from '@react-types/table'; -import {DOMAttributes, MoveEndEvent, MoveMoveEvent} from '@react-types/shared'; +import {DOMAttributes} from '@react-types/shared'; import {focusSafely} from '@react-aria/focus'; import {focusWithoutScrolling, mergeProps, useId} from '@react-aria/utils'; import {getColumnHeaderId} from './utils'; @@ -40,13 +40,6 @@ export interface AriaTableColumnResizeProps { triggerRef?: RefObject, /** If resizing is disabled. */ isDisabled?: boolean, - /** If the resizer was moved. Different from onResize because it is always called. */ - onMove?: (e: MoveMoveEvent) => void, - /** - * If the resizer was moved. Different from onResizeEnd because it is always called. - * It also carries the interaction details in the object. - * */ - onMoveEnd?: (e: MoveEndEvent) => void, /** Called when resizing starts. */ onResizeStart?: (widths: Map) => void, /** Called for every resize event that results in new column sizes. */ @@ -138,7 +131,6 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st } deltaX *= 10; } - props.onMove?.(e); // if moving up/down only, no need to resize if (deltaX !== 0) { columnResizeWidthRef.current += deltaX; @@ -148,7 +140,6 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st onMoveEnd(e) { let {pointerType} = e; columnResizeWidthRef.current = 0; - props.onMoveEnd?.(e); if (pointerType === 'mouse') { endResize(item); } @@ -186,8 +177,6 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st } else { nextValue = currentWidth - 10; } - props.onMove({pointerType: 'virtual'} as MoveMoveEvent); - props.onMoveEnd({pointerType: 'virtual'} as MoveEndEvent); resize(item, nextValue); }; diff --git a/packages/@react-aria/tag/intl/en-US.json b/packages/@react-aria/tag/intl/en-US.json index 67ff0e1312a..f7a6cb22c7c 100644 --- a/packages/@react-aria/tag/intl/en-US.json +++ b/packages/@react-aria/tag/intl/en-US.json @@ -1,3 +1,3 @@ { - "remove": "Remove" + "remove": "Press Space or Delete to remove tag." } diff --git a/packages/@react-aria/tag/package.json b/packages/@react-aria/tag/package.json index 7b7f54219c7..f8a69edc127 100644 --- a/packages/@react-aria/tag/package.json +++ b/packages/@react-aria/tag/package.json @@ -17,12 +17,12 @@ "url": "https://github.com/adobe/react-spectrum" }, "dependencies": { - "@react-aria/grid": "^3.5.2", + "@react-aria/gridlist": "^3.1.1", "@react-aria/i18n": "^3.6.3", "@react-aria/interactions": "^3.13.1", "@react-aria/utils": "^3.14.2", - "@react-stately/grid": "^3.4.2", - "@react-types/grid": "^3.1.5", + "@react-stately/tag": "3.0.0-alpha.1", + "@react-types/button": "^3.7.0", "@react-types/shared": "^3.16.0", "@react-types/tag": "3.0.0-beta.1", "@swc/helpers": "^0.4.14" diff --git a/packages/@react-aria/tag/src/TagKeyboardDelegate.ts b/packages/@react-aria/tag/src/TagKeyboardDelegate.ts index 3a3ed95f5b8..3f78d483f10 100644 --- a/packages/@react-aria/tag/src/TagKeyboardDelegate.ts +++ b/packages/@react-aria/tag/src/TagKeyboardDelegate.ts @@ -10,25 +10,26 @@ * governing permissions and limitations under the License. */ -import {GridCollection} from '@react-types/grid'; -import {GridKeyboardDelegate} from '@react-aria/grid'; +import {Collection, Direction, KeyboardDelegate} from '@react-types/shared'; import {Key} from 'react'; -export class TagKeyboardDelegate extends GridKeyboardDelegate> { - getFirstKey() { - let key = this.collection.getFirstKey(); - let item = this.collection.getItem(key); +export class TagKeyboardDelegate implements KeyboardDelegate { + private collection: Collection; + private direction: Direction; - return [...item.childNodes][0].key; + constructor(collection: Collection, direction: Direction) { + this.collection = collection; + this.direction = direction; } - getLastKey() { - let key = this.collection.getLastKey(); - let item = this.collection.getItem(key); - - return [...item.childNodes][0].key; + getFirstKey() { + return this.collection.getFirstKey(); } + getLastKey() { + return this.collection.getLastKey(); + } + getKeyRightOf(key: Key) { return this.direction === 'rtl' ? this.getKeyAbove(key) : this.getKeyBelow(key); } @@ -43,27 +44,12 @@ export class TagKeyboardDelegate extends GridKeyboardDelegate extends GridKeyboardDelegate + clearButtonProps: AriaButtonProps } -export function useTag(props: TagProps, state: GridState): TagAria { - let {isFocused} = props; - const { +/** + * Provides the behavior and accessibility implementation for a tag component. + * @param props - Props to be applied to the tag. + * @param state - State for the tag group, as returned by `useTagGroupState`. + */ +export function useTag(props: TagProps, state: TagGroupState): TagAria { + let { + isFocused, allowsRemoving, - onRemove, item, - tagRef, tagRowRef } = props; - const stringFormatter = useLocalizedStringFormatter(intlMessages); - const removeString = stringFormatter.format('remove'); - const labelId = useId(); - const buttonId = useId(); + let stringFormatter = useLocalizedStringFormatter(intlMessages); + let removeString = stringFormatter.format('remove'); + let labelId = useId(); + let buttonId = useId(); - let {rowProps} = useGridRow({ + let {rowProps, gridCellProps} = useGridListItem({ node: item }, state, tagRowRef); - // Don't want the row to be focusable or accessible via keyboard - // eslint-disable-next-line @typescript-eslint/no-unused-vars - let {tabIndex, ...otherRowProps} = rowProps; - let {gridCellProps} = useGridCell({ - node: [...item.childNodes][0], - focusMode: 'cell' - }, state, tagRef); + // We want the group to handle keyboard navigation between tags. + delete rowProps.onKeyDownCapture; + + let onRemove = chain(props.onRemove, state.onRemove); - function onKeyDown(e: KeyboardEvent) { + let onKeyDown = (e: KeyboardEvent) => { if (e.key === 'Delete' || e.key === 'Backspace' || e.key === ' ') { - onRemove(item.childNodes[0].key); + onRemove(item.key); e.preventDefault(); } - } - const pressProps = { - onPress: () => onRemove?.(item.childNodes[0].key) }; - isFocused = isFocused || state.selectionManager.focusedKey === item.childNodes[0].key; + isFocused = isFocused || state.selectionManager.focusedKey === item.key; let domProps = filterDOMProps(props); return { - clearButtonProps: mergeProps(pressProps, { + clearButtonProps: { 'aria-label': removeString, 'aria-labelledby': `${buttonId} ${labelId}`, - id: buttonId - }), + id: buttonId, + onPress: () => allowsRemoving && onRemove ? onRemove(item.key) : null + }, labelProps: { id: labelId }, - tagRowProps: otherRowProps, + tagRowProps: { + ...rowProps, + tabIndex: (isFocused || state.selectionManager.focusedKey == null) ? 0 : -1, + onKeyDown: allowsRemoving ? onKeyDown : null + }, tagProps: mergeProps(domProps, gridCellProps, { 'aria-errormessage': props['aria-errormessage'], - 'aria-label': props['aria-label'], - onKeyDown: allowsRemoving ? onKeyDown : null, - tabIndex: (isFocused || state.selectionManager.focusedKey == null) ? 0 : -1 + 'aria-label': props['aria-label'] }) }; } diff --git a/packages/@react-aria/tag/src/useTagGroup.ts b/packages/@react-aria/tag/src/useTagGroup.ts index c6ca99cefe0..8c94126cc72 100644 --- a/packages/@react-aria/tag/src/useTagGroup.ts +++ b/packages/@react-aria/tag/src/useTagGroup.ts @@ -10,29 +10,43 @@ * governing permissions and limitations under the License. */ -import {DOMAttributes, DOMProps} from '@react-types/shared'; +import {AriaTagGroupProps} from '@react-types/tag'; +import {DOMAttributes} from '@react-types/shared'; import {filterDOMProps, mergeProps} from '@react-aria/utils'; -import {ReactNode, useState} from 'react'; +import {RefObject, useState} from 'react'; +import type {TagGroupState} from '@react-stately/tag'; +import {TagKeyboardDelegate} from './TagKeyboardDelegate'; import {useFocusWithin} from '@react-aria/interactions'; - -export interface AriaTagGroupProps extends DOMProps { - children: ReactNode, - isReadOnly?: boolean, // removes close button - validationState?: 'valid' | 'invalid' -} +import {useGridList} from '@react-aria/gridlist'; +import {useLocale} from '@react-aria/i18n'; export interface TagGroupAria { tagGroupProps: DOMAttributes } -export function useTagGroup(props: AriaTagGroupProps): TagGroupAria { +/** + * Provides the behavior and accessibility implementation for a tag group component. + * Tags allow users to categorize content. They can represent keywords or people, and are grouped to describe an item or a search request. + * @param props - Props to be applied to the tag group. + * @param state - State for the tag group, as returned by `useTagGroupState`. + * @param ref - A ref to a DOM element for the tag group. + */ +export function useTagGroup(props: AriaTagGroupProps, state: TagGroupState, ref: RefObject): TagGroupAria { + let {direction} = useLocale(); + let keyboardDelegate = new TagKeyboardDelegate(state.collection, direction); + let {gridProps} = useGridList({...props, keyboardDelegate}, state, ref); + + // Don't want the grid to be focusable or accessible via keyboard + delete gridProps.role; + delete gridProps.tabIndex; + let [isFocusWithin, setFocusWithin] = useState(false); let {focusWithinProps} = useFocusWithin({ onFocusWithinChange: setFocusWithin }); let domProps = filterDOMProps(props); return { - tagGroupProps: mergeProps(domProps, { + tagGroupProps: mergeProps(gridProps, domProps, { 'aria-atomic': false, 'aria-relevant': 'additions', 'aria-live': isFocusWithin ? 'polite' : 'off', diff --git a/packages/@react-spectrum/overlays/src/Modal.tsx b/packages/@react-spectrum/overlays/src/Modal.tsx index 48f75e1cc9b..499d30bd65e 100644 --- a/packages/@react-spectrum/overlays/src/Modal.tsx +++ b/packages/@react-spectrum/overlays/src/Modal.tsx @@ -88,6 +88,7 @@ let ModalWrapper = forwardRef(function (props: ModalWrapperProps, ref: RefObject '--spectrum-visual-viewport-height': viewport.height + 'px' }; + // Attach Transition's nodeRef to outer most wrapper for node.reflow: https://github.com/reactjs/react-transition-group/blob/c89f807067b32eea6f68fd6c622190d88ced82e2/src/Transition.js#L231 return (
diff --git a/packages/@react-spectrum/overlays/src/Tray.tsx b/packages/@react-spectrum/overlays/src/Tray.tsx index daf8b634feb..149a9d6a648 100644 --- a/packages/@react-spectrum/overlays/src/Tray.tsx +++ b/packages/@react-spectrum/overlays/src/Tray.tsx @@ -94,6 +94,7 @@ let TrayWrapper = forwardRef(function (props: TrayWrapperProps, ref: RefObject diff --git a/packages/@react-spectrum/overlays/src/Underlay.tsx b/packages/@react-spectrum/overlays/src/Underlay.tsx index 04aed249546..d5d7f5e163c 100644 --- a/packages/@react-spectrum/overlays/src/Underlay.tsx +++ b/packages/@react-spectrum/overlays/src/Underlay.tsx @@ -11,7 +11,7 @@ */ import {classNames} from '@react-spectrum/utils'; -import React, {Ref} from 'react'; +import React from 'react'; import underlayStyles from '@adobe/spectrum-css-temp/components/underlay/vars.css'; interface UnderlayProps { @@ -19,11 +19,8 @@ interface UnderlayProps { isTransparent?: boolean } -function Underlay({isOpen, isTransparent}: UnderlayProps, ref: Ref) { +export function Underlay({isOpen, isTransparent}: UnderlayProps) { return ( -
+
); } - -const _Underlay = React.forwardRef(Underlay); -export {_Underlay as Underlay}; diff --git a/packages/@react-spectrum/provider/src/Provider.tsx b/packages/@react-spectrum/provider/src/Provider.tsx index ac1a29401a0..7243ae74f5c 100644 --- a/packages/@react-spectrum/provider/src/Provider.tsx +++ b/packages/@react-spectrum/provider/src/Provider.tsx @@ -18,6 +18,7 @@ import { useStyleProps } from '@react-spectrum/utils'; import clsx from 'clsx'; +import {Context} from './context'; import {DOMRef} from '@react-types/shared'; import {filterDOMProps} from '@react-aria/utils'; import {I18nProvider, useLocale} from '@react-aria/i18n'; @@ -30,9 +31,6 @@ import {useColorScheme, useScale} from './mediaQueries'; // @ts-ignore import {version} from '../package.json'; -const Context = React.createContext(null); -Context.displayName = 'ProviderContext'; - const DEFAULT_BREAKPOINTS = {S: 640, M: 768, L: 1024, XL: 1280, XXL: 1536}; function Provider(props: ProviderProps, ref: DOMRef) { diff --git a/packages/@react-spectrum/provider/src/context.ts b/packages/@react-spectrum/provider/src/context.ts new file mode 100644 index 00000000000..2c104719b04 --- /dev/null +++ b/packages/@react-spectrum/provider/src/context.ts @@ -0,0 +1,19 @@ +/* + * Copyright 2020 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {ProviderContext} from '@react-types/provider'; +import React from 'react'; + +// Context is placed in a separate file to avoid fast refresh issue where the old provider context values +// are immediately replaced with the null default. Stopgap solution until we fix this in parcel. +export const Context = React.createContext(null); +Context.displayName = 'ProviderContext'; diff --git a/packages/@react-spectrum/table/src/Resizer.tsx b/packages/@react-spectrum/table/src/Resizer.tsx index 7a360ad618a..ffc6fdf1dfa 100644 --- a/packages/@react-spectrum/table/src/Resizer.tsx +++ b/packages/@react-spectrum/table/src/Resizer.tsx @@ -2,10 +2,11 @@ import {classNames} from '@react-spectrum/utils'; import {ColumnSize} from '@react-types/table'; import {FocusRing} from '@react-aria/focus'; +import {getInteractionModality} from '@react-aria/interactions'; import {GridNode} from '@react-types/grid'; // @ts-ignore import intlMessages from '../intl/*.json'; -import {MoveMoveEvent} from '@react-types/shared'; +import {mergeProps} from '@react-aria/utils'; import React, {Key, RefObject} from 'react'; import styles from '@adobe/spectrum-css-temp/components/table/vars.css'; import {useLocale, useLocalizedStringFormatter} from '@react-aria/i18n'; @@ -19,8 +20,7 @@ interface ResizerProps { triggerRef: RefObject, onResizeStart: (widths: Map) => void, onResize: (widths: Map) => void, - onResizeEnd: (widths: Map) => void, - onMoveResizer: (e: MoveMoveEvent) => void + onResizeEnd: (widths: Map) => void } function Resizer(props: ResizerProps, ref: RefObject) { @@ -33,29 +33,30 @@ function Resizer(props: ResizerProps, ref: RefObject) { let stringFormatter = useLocalizedStringFormatter(intlMessages); let {direction} = useLocale(); - let {inputProps, resizerProps} = useTableColumnResize({ - ...props, - label: stringFormatter.format('columnResizer'), - isDisabled: isEmpty, - onMove: (e) => { - document.body.classList.remove(classNames(styles, 'resize-ew')); - document.body.classList.remove(classNames(styles, 'resize-e')); - document.body.classList.remove(classNames(styles, 'resize-w')); - if (layout.getColumnMinWidth(column.key) >= layout.getColumnWidth(column.key)) { - document.body.classList.add(direction === 'rtl' ? classNames(styles, 'resize-w') : classNames(styles, 'resize-e')); - } else if (layout.getColumnMaxWidth(column.key) <= layout.getColumnWidth(column.key)) { - document.body.classList.add(direction === 'rtl' ? classNames(styles, 'resize-e') : classNames(styles, 'resize-w')); - } else { - document.body.classList.add(classNames(styles, 'resize-ew')); + let {inputProps, resizerProps} = useTableColumnResize( + mergeProps(props, { + label: stringFormatter.format('columnResizer'), + isDisabled: isEmpty, + onResize: () => { + document.body.classList.remove(classNames(styles, 'resize-ew')); + document.body.classList.remove(classNames(styles, 'resize-e')); + document.body.classList.remove(classNames(styles, 'resize-w')); + if (getInteractionModality() === 'pointer') { + if (layout.getColumnMinWidth(column.key) >= layout.getColumnWidth(column.key)) { + document.body.classList.add(direction === 'rtl' ? classNames(styles, 'resize-w') : classNames(styles, 'resize-e')); + } else if (layout.getColumnMaxWidth(column.key) <= layout.getColumnWidth(column.key)) { + document.body.classList.add(direction === 'rtl' ? classNames(styles, 'resize-e') : classNames(styles, 'resize-w')); + } else { + document.body.classList.add(classNames(styles, 'resize-ew')); + } + } + }, + onResizeEnd: () => { + document.body.classList.remove(classNames(styles, 'resize-ew')); + document.body.classList.remove(classNames(styles, 'resize-e')); + document.body.classList.remove(classNames(styles, 'resize-w')); } - props.onMoveResizer(e); - }, - onMoveEnd: () => { - document.body.classList.remove(classNames(styles, 'resize-ew')); - document.body.classList.remove(classNames(styles, 'resize-e')); - document.body.classList.remove(classNames(styles, 'resize-w')); - } - }, state, layout, ref); + }), state, layout, ref); let style = { cursor: undefined, diff --git a/packages/@react-spectrum/table/src/TableView.tsx b/packages/@react-spectrum/table/src/TableView.tsx index 364274c3193..635b038c66c 100644 --- a/packages/@react-spectrum/table/src/TableView.tsx +++ b/packages/@react-spectrum/table/src/TableView.tsx @@ -23,7 +23,7 @@ import { useUnwrapDOMRef } from '@react-spectrum/utils'; import {ColumnSize, SpectrumColumnProps, SpectrumTableProps} from '@react-types/table'; -import {DOMRef, FocusableRef, MoveMoveEvent} from '@react-types/shared'; +import {DOMRef, FocusableRef} from '@react-types/shared'; import {FocusRing, FocusScope, useFocusRing} from '@react-aria/focus'; import {getInteractionModality, useHover, usePress} from '@react-aria/interactions'; import {GridNode} from '@react-types/grid'; @@ -97,7 +97,6 @@ interface TableContextValue { onResizeStart: (widths: Map) => void, onResize: (widths: Map) => void, onResizeEnd: (widths: Map) => void, - onMoveResizer: (e: MoveMoveEvent) => void, headerMenuOpen: boolean, setHeaderMenuOpen: (val: boolean) => void } @@ -361,14 +360,6 @@ function TableView(props: SpectrumTableProps, ref: DOMRef { - if (e.pointerType === 'keyboard') { - lastResizeInteractionModality.current = e.pointerType; - } else { - lastResizeInteractionModality.current = undefined; - } - }; let onResizeStart = useCallback((widths) => { setIsResizing(true); propsOnResizeStart?.(widths); @@ -380,7 +371,7 @@ function TableView(props: SpectrumTableProps, ref: DOMRef + (props: SpectrumTableProps, ref: DOMRef @@ -419,7 +409,7 @@ function TableView(props: SpectrumTableProps, ref: DOMRef { - if (lastResizeInteractionModality.current === 'keyboard' && headerRef.current.contains(document.activeElement)) { + if (getInteractionModality() === 'keyboard' && headerRef.current.contains(document.activeElement)) { document.activeElement?.scrollIntoView?.({block: 'nearest', inline: 'nearest'}); bodyRef.current.scrollLeft = headerRef.current.scrollLeft; } - }, [state.contentSize, headerRef, bodyRef, lastResizeInteractionModality]); + }, [state.contentSize, headerRef, bodyRef]); let headerHeight = layout.getLayoutInfo('header')?.rect.height || 0; let visibleRect = state.virtualizer.visibleRect; @@ -662,7 +652,6 @@ function ResizableTableColumnHeader(props) { setIsInResizeMode, isEmpty, onFocusedResizer, - onMoveResizer, isInResizeMode, headerMenuOpen, setHeaderMenuOpen @@ -807,8 +796,7 @@ function ResizableTableColumnHeader(props) { onResizeStart={onResizeStart} onResize={onResize} onResizeEnd={onResizeEnd} - triggerRef={useUnwrapDOMRef(triggerRef)} - onMoveResizer={onMoveResizer} /> + triggerRef={useUnwrapDOMRef(triggerRef)} />
extends TagProps { + state: TagGroupState +} + export function Tag(props: SpectrumTagProps) { const { children, @@ -35,7 +40,6 @@ export function Tag(props: SpectrumTagProps) { let {styleProps} = useStyleProps(otherProps); let {hoverProps, isHovered} = useHover({}); let {isFocused, isFocusVisible, focusProps} = useFocusRing({within: true}); - let tagRef = useRef(); let tagRowRef = useRef(); let {clearButtonProps, labelProps, tagProps, tagRowProps} = useTag({ ...props, @@ -43,35 +47,36 @@ export function Tag(props: SpectrumTagProps) { allowsRemoving, item, onRemove, - tagRef, tagRowRef }, state); return (
-
+ ref={tagRowRef}> +
- {typeof children === 'string' ? {children} : children} - {allowsRemoving && } + + {allowsRemoving && } +
@@ -79,14 +84,11 @@ export function Tag(props: SpectrumTagProps) { } function TagRemoveButton(props) { - props = useSlotProps(props, 'tagRemoveButton'); let {styleProps} = useStyleProps(props); - let clearBtnRef = useRef(); return ( + {...styleProps}> diff --git a/packages/@react-spectrum/tag/src/TagGroup.tsx b/packages/@react-spectrum/tag/src/TagGroup.tsx index 29b6df9ee21..d3f1fe0834b 100644 --- a/packages/@react-spectrum/tag/src/TagGroup.tsx +++ b/packages/@react-spectrum/tag/src/TagGroup.tsx @@ -12,18 +12,14 @@ import {classNames, useDOMRef, useStyleProps} from '@react-spectrum/utils'; import {DOMRef} from '@react-types/shared'; -import {GridCollection, useGridState} from '@react-stately/grid'; import {mergeProps} from '@react-aria/utils'; -import React, {ReactElement, useMemo} from 'react'; +import React, {ReactElement} from 'react'; import {SpectrumTagGroupProps} from '@react-types/tag'; import styles from '@adobe/spectrum-css-temp/components/tags/vars.css'; import {Tag} from './Tag'; -import {TagKeyboardDelegate, useTagGroup} from '@react-aria/tag'; -import {useGrid} from '@react-aria/grid'; -import {useListState} from '@react-stately/list'; -import {useLocale} from '@react-aria/i18n'; import {useProviderProps} from '@react-spectrum/provider'; - +import {useTagGroup} from '@react-aria/tag'; +import {useTagGroupState} from '@react-stately/tag'; function TagGroup(props: SpectrumTagGroupProps, ref: DOMRef) { props = useProviderProps(props); @@ -34,48 +30,11 @@ function TagGroup(props: SpectrumTagGroupProps, ref: DOMRef } = props; let domRef = useDOMRef(ref); let {styleProps} = useStyleProps(otherProps); - let {direction} = useLocale(); - let listState = useListState(props); - let gridCollection = useMemo(() => new GridCollection({ - columnCount: 1, // unused, but required for grid collections - items: [...listState.collection].map(item => { - let childNodes = [{ - ...item, - index: 0, - type: 'cell' - }]; - - return { - type: 'item', - childNodes - }; - }) - // eslint-disable-next-line react-hooks/exhaustive-deps - }), [listState.collection, allowsRemoving]); - let state = useGridState({ - ...props, - collection: gridCollection, - focusMode: 'cell' - }); - let keyboardDelegate = new TagKeyboardDelegate({ - collection: state.collection, - disabledKeys: new Set(), - ref: domRef, - direction, - focusMode: 'cell' - }); - let {gridProps} = useGrid({ - ...props, - keyboardDelegate - }, state, domRef); - const {tagGroupProps} = useTagGroup(props); - - // Don't want the grid to be focusable or accessible via keyboard - // eslint-disable-next-line @typescript-eslint/no-unused-vars - let {tabIndex, role, ...otherGridProps} = gridProps; + let state = useTagGroupState(props); + let {tagGroupProps} = useTagGroup(props, state, domRef); return (
(props: SpectrumTagGroupProps, ref: DOMRef } role={state.collection.size ? 'grid' : null} ref={domRef}> - {[...gridCollection].map(item => ( + {[...state.collection].map(item => ( - {item.childNodes[0].rendered} + {item.rendered} - ))} + ))}
); } diff --git a/packages/@react-spectrum/tag/stories/TagGroup.stories.tsx b/packages/@react-spectrum/tag/stories/TagGroup.stories.tsx index 6974b152867..84df182a7b7 100644 --- a/packages/@react-spectrum/tag/stories/TagGroup.stories.tsx +++ b/packages/@react-spectrum/tag/stories/TagGroup.stories.tsx @@ -10,26 +10,24 @@ * governing permissions and limitations under the License. */ -import {action} from '@storybook/addon-actions'; import Audio from '@spectrum-icons/workflow/Audio'; -import {Icon} from '@react-spectrum/icon'; import {Item, TagGroup} from '../src'; import React, {useState} from 'react'; import {storiesOf} from '@storybook/react'; import {Text} from '@react-spectrum/text'; +let items = [{key: '1', label: 'Cool Tag 1'}, {key: '2', label: 'Cool Tag 2'}]; + storiesOf('TagGroup', module) .add( 'default', () => render({}) ) .add('icons', () => ( - + {item => ( - - + )} @@ -38,30 +36,29 @@ storiesOf('TagGroup', module) .add( 'onRemove', () => { - const [items, setItems] = useState([ - {key: 1, label: 'Cool Tag 1'}, - {key: 2, label: 'Another cool tag'}, - {key: 3, label: 'This tag'}, - {key: 4, label: 'What tag?'}, - {key: 5, label: 'This tag is cool too'}, - {key: 6, label: 'Shy tag'} + let [items, setItems] = useState([ + {id: 1, label: 'Cool Tag 1'}, + {id: 2, label: 'Another cool tag'}, + {id: 3, label: 'This tag'}, + {id: 4, label: 'What tag?'}, + {id: 5, label: 'This tag is cool too'}, + {id: 6, label: 'Shy tag'} ]); - const onRemove = (key) => { - const newItems = [...items].filter((item) => key !== item.key.toString()); - setItems(newItems); - action('onRemove')(key); + + let removeItem = (key) => { + setItems(prevItems => prevItems.filter((item) => key !== item.id)); }; - return ( onRemove(key)}> - {item => ( - {item.label} - )} - ); + return ( + + {item => {item.label}} + + ); } ) .add('wrapping', () => (
- + Cool Tag 1 Another cool tag This tag @@ -74,7 +71,7 @@ storiesOf('TagGroup', module) ) .add('label truncation', () => (
- + Cool Tag 1 with a really long label Another long cool tag label This tag @@ -83,19 +80,17 @@ storiesOf('TagGroup', module) ) ) .add( - 'using items prop', + 'dynamic items', () => ( - - {item => - {item.label} - } + + {item => {item.label}} ) ); function render(props: any = {}) { return ( - + Cool Tag 1 Cool Tag 2 Cool Tag 3 diff --git a/packages/@react-spectrum/tag/test/TagGroup.test.js b/packages/@react-spectrum/tag/test/TagGroup.test.js index 37335c931ca..d5bfdf1dcca 100644 --- a/packages/@react-spectrum/tag/test/TagGroup.test.js +++ b/packages/@react-spectrum/tag/test/TagGroup.test.js @@ -10,8 +10,9 @@ * governing permissions and limitations under the License. */ -import {act, fireEvent, render} from '@react-spectrum/test-utils'; +import {act, fireEvent, render, triggerPress, within} from '@react-spectrum/test-utils'; import {Button} from '@react-spectrum/button'; +import {chain} from '@react-aria/utils'; import {Item} from '@react-stately/collections'; import {Provider} from '@react-spectrum/provider'; import React from 'react'; @@ -22,6 +23,7 @@ import userEvent from '@testing-library/user-event'; function pressKeyOnButton(key) { return (button) => { fireEvent.keyDown(button, {key}); + fireEvent.keyUp(button, {key}); }; } @@ -55,7 +57,7 @@ describe('TagGroup', function () { }); it('provides context for Tag component', function () { - let {container} = render( + let {getAllByRole} = render( Tag 1 Tag 2 @@ -63,25 +65,26 @@ describe('TagGroup', function () { ); - let tags = container.querySelectorAll('[role="gridcell"]'); + let tags = getAllByRole('row'); expect(tags.length).toBe(3); fireEvent.keyDown(tags[1], {key: 'Delete'}); + fireEvent.keyUp(tags[1], {key: 'Delete'}); expect(onRemoveSpy).toHaveBeenCalledTimes(1); }); it('has correct accessibility roles', () => { - let tree = render( + let {getByRole, getAllByRole} = render( Tag 1 ); - let tagGroup = tree.getByRole('grid'); + let tagGroup = getByRole('grid'); expect(tagGroup).toBeInTheDocument(); - let tags = tree.getAllByRole('row'); - let cells = tree.getAllByRole('gridcell'); + let tags = getAllByRole('row'); + let cells = getAllByRole('gridcell'); expect(tags).toHaveLength(cells.length); }); @@ -93,7 +96,7 @@ describe('TagGroup', function () { ); - let tags = getAllByRole('gridcell'); + let tags = getAllByRole('row'); expect(tags[0]).toHaveAttribute('tabIndex', '0'); }); @@ -104,7 +107,7 @@ describe('TagGroup', function () { ${'(up/down arrows, ltr + horizontal) TagGroup'} | ${{locale: 'de-DE'}} | ${[{action: () => {userEvent.tab();}, index: 0}, {action: pressArrowDown, index: 1}, {action: pressArrowUp, index: 0}, {action: pressArrowUp, index: 2}]} ${'(up/down arrows, rtl + horizontal) TagGroup'} | ${{locale: 'ar-AE'}} | ${[{action: () => {userEvent.tab();}, index: 0}, {action: pressArrowUp, index: 2}, {action: pressArrowDown, index: 0}, {action: pressArrowDown, index: 1}]} `('$Name shifts button focus in the correct direction on key press', function ({Name, props, orders}) { - let tree = render( + let {getAllByRole} = render( Tag 1 @@ -114,7 +117,7 @@ describe('TagGroup', function () { ); - let tags = tree.getAllByRole('gridcell'); + let tags = getAllByRole('row'); orders.forEach(({action, index}, i) => { action(document.activeElement); expect(document.activeElement).toBe(tags[index]); @@ -172,7 +175,7 @@ describe('TagGroup', function () { let buttonBefore = getByLabelText('ButtonBefore'); let buttonAfter = getByLabelText('ButtonAfter'); - let tags = getAllByRole('gridcell'); + let tags = getAllByRole('row'); act(() => {buttonBefore.focus();}); userEvent.tab(); @@ -202,7 +205,7 @@ describe('TagGroup', function () { let buttonBefore = getByLabelText('ButtonBefore'); let buttonAfter = getByLabelText('ButtonAfter'); - let tags = getAllByRole('gridcell'); + let tags = getAllByRole('row'); act(() => {buttonBefore.focus();}); expect(buttonBefore).toHaveFocus(); userEvent.tab(); @@ -225,7 +228,7 @@ describe('TagGroup', function () { let buttonBefore = getByLabelText('ButtonBefore'); let buttonAfter = getByLabelText('ButtonAfter'); - let tags = getAllByRole('gridcell'); + let tags = getAllByRole('row'); act(() => {buttonAfter.focus();}); userEvent.tab({shift: true}); expect(document.activeElement).toBe(tags[1]); @@ -244,13 +247,12 @@ describe('TagGroup', function () { ); let tagGroup = getByRole('grid'); - let tagRow = tagGroup.children[0]; - let tag = tagRow.children[0]; + let tag = tagGroup.children[0]; expect(tag).not.toHaveAttribute('icon'); expect(tag).not.toHaveAttribute('unsafe_classname'); expect(tag).toHaveAttribute('class', expect.stringContaining('test-class')); expect(tag).toHaveAttribute('class', expect.stringContaining('-item')); - expect(tag).toHaveAttribute('role', 'gridcell'); + expect(tag).toHaveAttribute('role', 'row'); expect(tag).toHaveAttribute('tabIndex', '0'); }); @@ -260,22 +262,49 @@ describe('TagGroup', function () { Tag 1 Tag 2 + Tag 3 + Tag 4 ); - let tags = getAllByRole('gridcell'); - expect(tags.length).toBe(2); + let tags = getAllByRole('row'); + expect(tags.length).toBe(4); expect(tags[0]).toHaveAttribute('tabIndex', '0'); expect(tags[1]).toHaveAttribute('tabIndex', '0'); + expect(tags[2]).toHaveAttribute('tabIndex', '0'); + expect(tags[3]).toHaveAttribute('tabIndex', '0'); - act(() => tags[0].focus()); + userEvent.tab(); expect(tags[0]).toHaveAttribute('tabIndex', '0'); expect(tags[1]).toHaveAttribute('tabIndex', '-1'); + expect(tags[2]).toHaveAttribute('tabIndex', '-1'); + expect(tags[3]).toHaveAttribute('tabIndex', '-1'); + expect(document.activeElement).toBe(tags[0]); - pressArrowRight(tags[0]); - expect(tags[0]).toHaveAttribute('tabIndex', '-1'); - expect(tags[1]).toHaveAttribute('tabIndex', '0'); + fireEvent.keyDown(document.activeElement, {key: 'ArrowRight'}); + fireEvent.keyUp(document.activeElement, {key: 'ArrowRight'}); + expect(document.activeElement).toBe(tags[1]); + + fireEvent.keyDown(document.activeElement, {key: 'ArrowLeft'}); + fireEvent.keyUp(document.activeElement, {key: 'ArrowLeft'}); + expect(document.activeElement).toBe(tags[0]); + + fireEvent.keyDown(document.activeElement, {key: 'End'}); + fireEvent.keyUp(document.activeElement, {key: 'End'}); + expect(document.activeElement).toBe(tags[3]); + + fireEvent.keyDown(document.activeElement, {key: 'Home'}); + fireEvent.keyUp(document.activeElement, {key: 'Home'}); + expect(document.activeElement).toBe(tags[0]); + + fireEvent.keyDown(document.activeElement, {key: 'PageDown'}); + fireEvent.keyUp(document.activeElement, {key: 'PageDown'}); + expect(document.activeElement).toBe(tags[1]); + + fireEvent.keyDown(document.activeElement, {key: 'PageUp'}); + fireEvent.keyUp(document.activeElement, {key: 'PageUp'}); + expect(document.activeElement).toBe(tags[0]); }); it.each` @@ -296,44 +325,76 @@ describe('TagGroup', function () { let tag = getByText('Tag 1'); fireEvent.keyDown(tag, {key: props.keyPress}); + fireEvent.keyUp(tag, {key: props.keyPress}); + expect(onRemoveSpy).toHaveBeenCalledTimes(1); + expect(onRemoveSpy).toHaveBeenCalledWith('1'); + }); + + it('should remove tag when remove button is clicked', function () { + let {getAllByRole} = render( + + + Tag 1 + Tag 2 + Tag 3 + + + ); + + let tags = getAllByRole('row'); + triggerPress(tags[0]); + expect(onRemoveSpy).not.toHaveBeenCalled(); + + let removeButton = within(tags[0]).getByRole('button'); + triggerPress(removeButton); + expect(onRemoveSpy).toHaveBeenCalledTimes(1); expect(onRemoveSpy).toHaveBeenCalledWith('1'); }); + it.each` + Name | props + ${'on `Delete` keypress'} | ${{keyPress: 'Delete'}} + ${'on `Backspace` keypress'} | ${{keyPress: 'Backspace'}} + ${'on `space` keypress'} | ${{keyPress: ' '}} + `('Can move focus after removing tag $Name', function ({Name, props}) { + + function TagGroupWithDelete(props) { + let [items, setItems] = React.useState([ + {id: 1, label: 'Cool Tag 1'}, + {id: 2, label: 'Another cool tag'}, + {id: 3, label: 'This tag'}, + {id: 4, label: 'What tag?'}, + {id: 5, label: 'This tag is cool too'}, + {id: 6, label: 'Shy tag'} + ]); + + let removeItem = (key) => { + setItems(prevItems => prevItems.filter((item) => key !== item.id)); + }; + + return ( + + + {item => {item.label}} + + + ); + } + + let {getAllByRole} = render( + + ); - // Commented out until spectrum can provide use case for these scenarios - // it.each` - // Name | Component | TagComponent | props - // ${'TagGroup'} | ${TagGroup} | ${Item} | ${{isReadOnly: true, isRemovable: true, onRemove: onRemoveSpy}} - // `('$Name is read only', ({Component, TagComponent, props}) => { - // let {getByText} = render( - // - // Tag 1 - // - // ); - // let tag = getByText('Tag 1'); - // fireEvent.keyDown(tag, {key: 'Delete', keyCode: 46}); - // expect(onRemoveSpy).not.toHaveBeenCalledWith('Tag 1', expect.anything()); - // }); - // - // it.each` - // Name | Component | TagComponent | props - // ${'Tag'} | ${TagGroup} | ${Item} | ${{validationState: 'invalid'}} - // `('$Name can be invalid', function ({Component, TagComponent, props}) { - // let {getByRole, debug} = render( - // - // Tag 1 - // - // ); - // - // debug(); - // - // let tag = getByRole('row'); - // expect(tag).toHaveAttribute('aria-invalid', 'true'); - // }); + let tags = getAllByRole('row'); + userEvent.tab(); + expect(document.activeElement).toBe(tags[0]); + fireEvent.keyDown(document.activeElement, {key: props.keyPress}); + fireEvent.keyUp(document.activeElement, {key: props.keyPress}); + expect(onRemoveSpy).toHaveBeenCalledTimes(1); + expect(onRemoveSpy).toHaveBeenCalledWith(1); + tags = getAllByRole('row'); + expect(document.activeElement).toBe(tags[0]); + pressArrowRight(tags[0]); + expect(document.activeElement).toBe(tags[1]); + }); }); - -// need to add test for focus after onremove diff --git a/packages/@react-stately/tag/README.md b/packages/@react-stately/tag/README.md new file mode 100644 index 00000000000..49c22e51ddb --- /dev/null +++ b/packages/@react-stately/tag/README.md @@ -0,0 +1,3 @@ +# @react-stately/tag + +This package is part of [react-spectrum](https://github.com/adobe/react-spectrum). See the repo for more details. diff --git a/packages/@react-stately/tag/index.ts b/packages/@react-stately/tag/index.ts new file mode 100644 index 00000000000..4e9931530d8 --- /dev/null +++ b/packages/@react-stately/tag/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright 2022 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +export * from './src'; diff --git a/packages/@react-stately/tag/package.json b/packages/@react-stately/tag/package.json new file mode 100644 index 00000000000..ce9b085151f --- /dev/null +++ b/packages/@react-stately/tag/package.json @@ -0,0 +1,31 @@ +{ + "name": "@react-stately/tag", + "version": "3.0.0-alpha.1", + "description": "Spectrum UI components in React", + "license": "Apache-2.0", + "main": "dist/main.js", + "module": "dist/module.js", + "types": "dist/types.d.ts", + "source": "src/index.ts", + "files": [ + "dist", + "src" + ], + "sideEffects": false, + "repository": { + "type": "git", + "url": "https://github.com/adobe/react-spectrum" + }, + "dependencies": { + "@babel/runtime": "^7.6.2", + "@react-stately/list": "^3.6.0", + "@react-types/tag": "3.0.0-beta.1", + "@swc/helpers": "^0.4.14" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/@react-stately/tag/src/index.ts b/packages/@react-stately/tag/src/index.ts new file mode 100644 index 00000000000..a01676123b2 --- /dev/null +++ b/packages/@react-stately/tag/src/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright 2022 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +export * from './useTagGroupState'; diff --git a/packages/@react-stately/tag/src/useTagGroupState.ts b/packages/@react-stately/tag/src/useTagGroupState.ts new file mode 100644 index 00000000000..4d0663dc692 --- /dev/null +++ b/packages/@react-stately/tag/src/useTagGroupState.ts @@ -0,0 +1,37 @@ +/* + * Copyright 2022 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {Key} from 'react'; +import {ListState, useListState} from '@react-stately/list'; +import {TagGroupProps} from '@react-types/tag'; + +export interface TagGroupState extends ListState{ + onRemove?: (key: Key) => void +} + +/** + * Provides state management for a TagGroup component. + */ +export function useTagGroupState(props: TagGroupProps): TagGroupState { + let state = useListState(props); + + let onRemove = (key) => { + // If a tag is removed, restore focus to the tag after, or tag before if no tag after. + let restoreKey = state.collection.getKeyAfter(key) || state.collection.getKeyBefore(key); + state.selectionManager.setFocusedKey(restoreKey); + }; + + return { + onRemove, + ...state + }; +} diff --git a/packages/@react-types/list/package.json b/packages/@react-types/list/package.json index 7bec3c04578..cdc6534b883 100644 --- a/packages/@react-types/list/package.json +++ b/packages/@react-types/list/package.json @@ -10,7 +10,8 @@ }, "dependencies": { "@react-aria/gridlist": "^3.1.2", - "@react-spectrum/list": "^3.2.2" + "@react-spectrum/list": "^3.2.2", + "@react-stately/list": "^3.6.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0" diff --git a/packages/@react-types/tag/package.json b/packages/@react-types/tag/package.json index b56e4a6907c..c2ccd24b075 100644 --- a/packages/@react-types/tag/package.json +++ b/packages/@react-types/tag/package.json @@ -9,7 +9,6 @@ "url": "https://github.com/adobe/react-spectrum" }, "dependencies": { - "@react-stately/grid": "^3.4.2", "@react-types/shared": "^3.16.0" }, "peerDependencies": { diff --git a/packages/@react-types/tag/src/index.d.ts b/packages/@react-types/tag/src/index.d.ts index 8c6aef4ac68..abf451cf028 100644 --- a/packages/@react-types/tag/src/index.d.ts +++ b/packages/@react-types/tag/src/index.d.ts @@ -11,7 +11,6 @@ */ import {AriaLabelingProps, CollectionBase, DOMProps, ItemProps, Node, StyleProps} from '@react-types/shared'; -import {GridState} from '@react-stately/grid'; import {Key, RefObject} from 'react'; export interface TagGroupProps extends Omit, 'disabledKeys'> { @@ -21,17 +20,14 @@ export interface TagGroupProps extends Omit, 'disabledKeys' onRemove?: (key: Key) => void } -export interface SpectrumTagGroupProps extends TagGroupProps, DOMProps, StyleProps, AriaLabelingProps {} +export interface AriaTagGroupProps extends TagGroupProps, DOMProps, AriaLabelingProps {} + +export interface SpectrumTagGroupProps extends AriaTagGroupProps, StyleProps {} export interface TagProps extends ItemProps { isFocused: boolean, allowsRemoving?: boolean, item: Node, onRemove?: (key: Key) => void, - tagRef: RefObject, tagRowRef: RefObject } - -interface SpectrumTagProps extends TagProps { - state: GridState -} diff --git a/yarn.lock b/yarn.lock index 4dfcd8d0f4d..4f05e65c303 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1132,6 +1132,13 @@ dependencies: regenerator-runtime "^0.13.11" +"@babel/runtime@^7.6.2": + version "7.20.7" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.20.7.tgz#fcb41a5a70550e04a7b708037c7c32f7f356d8fd" + integrity sha512-UF0tvkUtxwAgZ5W/KrkHf0Rn0fdnLDU9ScxBrEVNUprE/MzirjK4MJUX1/BVDv00Sv8cljtukVK1aky++X1SjQ== + dependencies: + regenerator-runtime "^0.13.11" + "@babel/template@^7.10.4", "@babel/template@^7.12.13", "@babel/template@^7.12.7", "@babel/template@^7.16.7", "@babel/template@^7.3.3": version "7.16.7" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.16.7.tgz#8d126c8701fde4d66b264b3eba3d96f07666d155"