diff --git a/.changeset/five-taxis-perform.md b/.changeset/five-taxis-perform.md new file mode 100644 index 000000000..844a00b32 --- /dev/null +++ b/.changeset/five-taxis-perform.md @@ -0,0 +1,5 @@ +--- +'@keystar/ui': patch +--- + +New package "@keystar/ui/tag" exports `TagGroup` component. diff --git a/design-system/pkg/package.json b/design-system/pkg/package.json index 0e3bf057b..096453311 100644 --- a/design-system/pkg/package.json +++ b/design-system/pkg/package.json @@ -10,6 +10,7 @@ "type": "module", "exports": { "./icon/all": "./dist/keystar-ui-icon-all.js", + "./tag": "./dist/keystar-ui-tag.js", "./core": "./dist/keystar-ui-core.js", "./icon": "./dist/keystar-ui-icon.js", "./link": "./dist/keystar-ui-link.js", @@ -1505,6 +1506,7 @@ "@react-aria/switch": "^3.6.7", "@react-aria/table": "^3.15.1", "@react-aria/tabs": "^3.9.5", + "@react-aria/tag": "^3.4.5", "@react-aria/textfield": "^3.14.8", "@react-aria/toast": "3.0.0-beta.15", "@react-aria/tooltip": "^3.7.7", diff --git a/design-system/pkg/src/avatar/Avatar.tsx b/design-system/pkg/src/avatar/Avatar.tsx index 633c2a292..93947b022 100644 --- a/design-system/pkg/src/avatar/Avatar.tsx +++ b/design-system/pkg/src/avatar/Avatar.tsx @@ -7,6 +7,7 @@ import { Ref, } from 'react'; +import { useSlotProps } from '@keystar/ui/slots'; import { BaseStyleProps, classNames, @@ -52,6 +53,7 @@ export const Avatar: ForwardRefExoticComponent< props: AvatarProps, forwardedRef: ForwardedRef ) { + props = useSlotProps(props, 'avatar'); const { alt, size = 'regular', ...otherProps } = props; const styleProps = useStyleProps(otherProps); diff --git a/design-system/pkg/src/menu/types.ts b/design-system/pkg/src/menu/types.ts index 9396a32ab..53b66b73e 100644 --- a/design-system/pkg/src/menu/types.ts +++ b/design-system/pkg/src/menu/types.ts @@ -46,12 +46,10 @@ export type MenuTriggerProps = { } & _MenuTriggerProps; export type ActionMenuProps = { - /** Whether the button is disabled. */ - isDisabled?: boolean; - /** Whether the button should be displayed with a [quiet style](https://spectrum.adobe.com/page/action-button/#Quiet). */ - isQuiet?: boolean; /** Whether the element should receive focus on render. */ autoFocus?: boolean; + /** Whether the button is disabled. */ + isDisabled?: boolean; /** Handler that is called when an item is selected. */ onAction?: (key: Key) => void; } & CollectionBase & diff --git a/design-system/pkg/src/table/Resizer.tsx b/design-system/pkg/src/table/Resizer.tsx index 52add7079..d51593eeb 100644 --- a/design-system/pkg/src/table/Resizer.tsx +++ b/design-system/pkg/src/table/Resizer.tsx @@ -60,10 +60,7 @@ function Resizer( // in order to get around that and cause a rerender here, we use context // but we don't actually need any value, they are available on the layout object useVirtualizerContext(); - let stringFormatter = useLocalizedStringFormatter( - localizedMessages, - '@react-spectrum/table' - ); + let stringFormatter = useLocalizedStringFormatter(localizedMessages); let { direction } = useLocale(); let [isPointerDown, setIsPointerDown] = useState(false); diff --git a/design-system/pkg/src/tag/Tag.tsx b/design-system/pkg/src/tag/Tag.tsx new file mode 100644 index 000000000..506d144b0 --- /dev/null +++ b/design-system/pkg/src/tag/Tag.tsx @@ -0,0 +1,197 @@ +import React, { useMemo, useRef } from 'react'; +import { useFocusRing } from '@react-aria/focus'; +import { useHover } from '@react-aria/interactions'; +import { useLink } from '@react-aria/link'; +import { type AriaTagProps, useTag } from '@react-aria/tag'; +import { mergeProps } from '@react-aria/utils'; +import type { ListState } from '@react-stately/list'; + +import { ClearButton } from '@keystar/ui/button'; +import { ClearSlots, SlotProvider } from '@keystar/ui/slots'; +import { + classNames, + css, + toDataAttributes, + tokenSchema, + transition, + useStyleProps, +} from '@keystar/ui/style'; +import { Text } from '@keystar/ui/typography'; +import { isReactText } from '@keystar/ui/utils'; +import { gapVar, heightVar } from './styles'; + +export interface TagProps extends AriaTagProps { + state: ListState; +} + +/** @private Internal use only: rendered via `Item` by consumer. */ +export function Tag(props: TagProps) { + const { item, state, ...otherProps } = props; + + let styleProps = useStyleProps(otherProps); + let { hoverProps, isHovered } = useHover({}); + let { isFocused, isFocusVisible, focusProps } = useFocusRing({ + within: true, + }); + let domRef = useRef(null); + let linkRef = useRef(null); + let { + removeButtonProps, + gridCellProps, + rowProps, + allowsRemoving: isRemovable, + } = useTag(stripSyntheticLinkProps({ ...props, item }), state, domRef); + const slots = useMemo( + () => + ({ + avatar: { + UNSAFE_className: css({ + marginInlineStart: tokenSchema.size.space.regular, + }), + size: 'xsmall', + }, + icon: { + UNSAFE_className: css({ + marginInlineStart: tokenSchema.size.space.regular, + }), + size: 'small', + }, + text: { + color: 'inherit', + size: 'small', + truncate: true, + trim: false, + UNSAFE_className: css({ + display: 'block', + paddingInline: tokenSchema.size.space.regular, + }), + }, + }) as const, + [] + ); + + const isLink = 'href' in item.props; + const { linkProps } = useLink(item.props, linkRef); + const contents = isReactText(item.rendered) ? ( + {item.rendered} + ) : ( + item.rendered + ); + + return ( +
+
+ + {/* TODO: review accessibility */} + {isLink ? ( + + {contents} + + ) : ( + contents + )} + + + {isRemovable && ( + + )} + + +
+
+ ); +} + +const SYNTHETIC_LINK_ATTRS = new Set([ + 'data-download', + 'data-href', + 'data-ping', + 'data-referrer-policy', + 'data-rel', + 'data-target', +]); + +/** + * Circumvent react-aria synthetic link and implement real anchor, so users can + * right-click and open in new tab, etc. + */ +function stripSyntheticLinkProps(props: any): AriaTagProps { + const safeProps = { ...props }; + for (const attr of SYNTHETIC_LINK_ATTRS) { + delete safeProps[attr]; + } + return safeProps; +} diff --git a/design-system/pkg/src/tag/TagGroup.tsx b/design-system/pkg/src/tag/TagGroup.tsx new file mode 100644 index 000000000..0d02458dd --- /dev/null +++ b/design-system/pkg/src/tag/TagGroup.tsx @@ -0,0 +1,360 @@ +import React, { + type ForwardedRef, + type ReactElement, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; + +import { FocusScope } from '@react-aria/focus'; +import { useLocale, useLocalizedStringFormatter } from '@react-aria/i18n'; +import { ListKeyboardDelegate } from '@react-aria/selection'; +import { AriaTagGroupProps, useTagGroup } from '@react-aria/tag'; +import { + useId, + useLayoutEffect, + useObjectRef, + useResizeObserver, + useValueEffect, +} from '@react-aria/utils'; +import { ListCollection, useListState } from '@react-stately/list'; +import type { Collection, Node } from '@react-types/shared'; + +import { + type BaseStyleProps, + FocusRing, + css, + tokenSchema, +} from '@keystar/ui/style'; + +import { ActionButton } from '@keystar/ui/button'; +import { KeystarProvider, useProviderProps } from '@keystar/ui/core'; +import { type FieldProps, FieldPrimitive } from '@keystar/ui/field'; +import { SlotProvider } from '@keystar/ui/slots'; +import { Text } from '@keystar/ui/typography'; + +import localizedMessages from './l10n'; +import { gapVar, heightVar, radiusVar, tokenValues } from './styles'; +import { Tag } from './Tag'; + +export interface TagGroupProps + extends Omit< + AriaTagGroupProps, + | 'defaultSelectedKeys' + | 'disallowEmptySelection' + | 'onSelectionChange' + | 'selectedKeys' + | 'selectionBehavior' + | 'selectionMode' + >, + BaseStyleProps, + FieldProps { + /** The label to display on the action button. */ + actionLabel?: string; + /** Handler that is called when the action button is pressed. */ + onAction?: () => void; + /** Sets what the TagGroup should render when there are no tags to display. */ + renderEmptyState?: () => ReactElement; + /** Limit the number of rows initially shown. This will render a button that allows the user to expand to show all tags. */ + maxRows?: number; +} + +function TagGroup( + props: TagGroupProps, + forwardedRef: ForwardedRef +) { + props = useProviderProps(props); + // props = useFormProps(props); + let { + maxRows, + children, + actionLabel, + onAction, + renderEmptyState: renderEmptyStateProp, + } = props; + let domRef = useObjectRef(forwardedRef); + let containerRef = useRef(null); + let tagsRef = useRef(null); + let { direction } = useLocale(); + + let stringFormatter = useLocalizedStringFormatter(localizedMessages); + let [isCollapsed, setIsCollapsed] = useState(maxRows != null); + let state = useListState(props); + let [tagState, setTagState] = useValueEffect({ + visibleTagCount: state.collection.size, + showCollapseButton: false, + }); + let renderEmptyState = useMemo(() => { + if (renderEmptyStateProp) { + return renderEmptyStateProp; + } + return () => ( + + {stringFormatter.format('noTags')} + + ); + }, [stringFormatter, renderEmptyStateProp]); + let keyboardDelegate = useMemo(() => { + let collection = ( + isCollapsed + ? new ListCollection( + [...state.collection].slice(0, tagState.visibleTagCount) + ) + : new ListCollection([...state.collection]) + ) as Collection>; + return new ListKeyboardDelegate({ + collection, + ref: domRef, + direction, + orientation: 'horizontal', + }); + }, [ + direction, + isCollapsed, + state.collection, + tagState.visibleTagCount, + domRef, + ]) as ListKeyboardDelegate; + // Remove onAction from props so it doesn't make it into useGridList. + delete props.onAction; + const { gridProps, labelProps, descriptionProps, errorMessageProps } = + useTagGroup({ ...props, keyboardDelegate }, state, tagsRef); + const actionsId = useId(); + const actionsRef = useRef(null); + + let updateVisibleTagCount = useCallback(() => { + if (maxRows && maxRows > 0) { + let computeVisibleTagCount = () => { + const containerEl = containerRef.current; + const tagsEl = tagsRef.current; + const actionsEl = actionsRef.current; + if ( + !containerEl || + !tagsEl || + !actionsEl || + state.collection.size === 0 + ) { + return { + visibleTagCount: 0, + showCollapseButton: false, + }; + } + + // Count rows and show tags until we hit the maxRows. + let tags = [...tagsEl.children]; + let currY = -Infinity; + let rowCount = 0; + let index = 0; + const tagWidths: number[] = []; + for (let tag of tags) { + let { width, y } = tag.getBoundingClientRect(); + + if (y !== currY) { + currY = y; + rowCount++; + } + + if (maxRows && rowCount > maxRows) { + break; + } + tagWidths.push(width); + index++; + } + + // Remove tags until there is space for the collapse button and action button (if present) on the last row. + let buttons = [...actionsEl.children]; + if (maxRows && buttons.length > 0 && rowCount >= maxRows) { + let buttonsWidth = buttons.reduce( + (acc, curr) => (acc += curr.getBoundingClientRect().width), + 0 + ); + buttonsWidth += tokenValues.gap * buttons.length; + let end: 'left' | 'right' = direction === 'ltr' ? 'right' : 'left'; + let containerEnd = + containerEl.parentElement?.getBoundingClientRect()[end] ?? 0; + let lastTagEnd = tags[index - 1]?.getBoundingClientRect()[end]; + lastTagEnd += tokenValues.gap / 2; + let availableWidth = containerEnd - lastTagEnd; + + while (availableWidth < buttonsWidth && index > 0) { + // ceremony for TS to understand that tagWidths.pop() is not undefined + let nextAvailableWidth = tagWidths.pop(); + if (nextAvailableWidth) { + availableWidth += nextAvailableWidth; + } + index--; + } + } + + return { + visibleTagCount: Math.max(index, 1), + showCollapseButton: index < state.collection.size, + }; + }; + + setTagState(function* () { + // Update to show all items. + yield { + visibleTagCount: state.collection.size, + showCollapseButton: true, + }; + + // Measure, and update to show the items until maxRows is reached. + yield computeVisibleTagCount(); + }); + } + }, [maxRows, setTagState, direction, state.collection.size]); + + useResizeObserver({ ref: containerRef, onResize: updateVisibleTagCount }); + + // we only want this effect to run when children change + // eslint-disable-next-line react-compiler/react-compiler + // eslint-disable-next-line react-hooks/exhaustive-deps + useLayoutEffect(updateVisibleTagCount, [children]); + + useEffect(() => { + // Recalculate visible tags when fonts are loaded. + document.fonts?.ready.then(() => updateVisibleTagCount()); + + // we strictly want this effect to only run once + // eslint-disable-next-line react-compiler/react-compiler + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + let visibleTags = useMemo( + () => + [...state.collection].slice( + 0, + isCollapsed ? tagState.visibleTagCount : state.collection.size + ), + [isCollapsed, state.collection, tagState.visibleTagCount] + ); + + let handlePressCollapse = () => { + // Prevents button from losing focus if focusedKey got collapsed. + state.selectionManager.setFocusedKey(null); + setIsCollapsed(prevCollapsed => !prevCollapsed); + }; + + let showActions = tagState.showCollapseButton || (actionLabel && onAction); + let isEmpty = state.collection.size === 0; + + let containerStyle = useMemo(() => { + if (maxRows == null || !isCollapsed || isEmpty) { + return undefined; + } + let maxHeight = (tokenValues.height + tokenValues.gap) * maxRows; + return { maxHeight, overflow: 'hidden' }; + }, [isCollapsed, maxRows, isEmpty]); + + return ( + + +
+ +
+ {visibleTags.map(item => ( + + {item.rendered} + + ))} + {isEmpty && ( +
+ {renderEmptyState()} +
+ )} +
+
+ {showActions && !isEmpty && ( + + +
+ {tagState.showCollapseButton && ( + + {isCollapsed + ? stringFormatter.format('showAllButtonLabel', { + tagCount: state.collection.size, + }) + : stringFormatter.format('hideButtonLabel')} + + )} + {actionLabel && onAction && ( + onAction?.()} + UNSAFE_className={css({ + borderRadius: radiusVar, + height: heightVar, + margin: `calc(${gapVar} / 2)`, + paddingInline: tokenSchema.size.space.small, + })} + > + {actionLabel} + + )} +
+
+
+ )} +
+
+
+ ); +} + +/** Tags allow users to categorize content. They can represent keywords or people, and are grouped to describe an item or a search request. */ +const _TagGroup = React.forwardRef(TagGroup) as ( + props: TagGroupProps & { ref?: ForwardedRef } +) => ReactElement; +export { _TagGroup as TagGroup }; diff --git a/design-system/pkg/src/tag/docs/index.mdoc b/design-system/pkg/src/tag/docs/index.mdoc new file mode 100644 index 000000000..3280daf8b --- /dev/null +++ b/design-system/pkg/src/tag/docs/index.mdoc @@ -0,0 +1,184 @@ +--- +title: TagGroup +description: Tags allow users to categorise content. They can represent keywords or people, and are grouped to describe an item or a search request. +category: Navigation +--- + +## Example + +```jsx {% live=true %} + + Bilby + Kangaroo + Quokka + Echidna + +``` + +## Patterns + +### Collections + +Picker implements the `react-stately` +[collection component](https://react-spectrum.adobe.com/react-stately/collections.html) `` for dynamic and static 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, can be used when the options come from an external data source such as an API call, or update over time. Providing the data in this way allows the tag group to cache the rendering of each item, which improves performance. + +```jsx {% live=true %} +let items = [ + { id: 1, name: 'Bilby' }, + { id: 2, name: 'Kangaroo' }, + { id: 3, name: 'Quokka' }, + { id: 4, name: 'Echidna' }, +]; + +return ( + + {item => {item.name}} + +); +``` + +### Slots + +[Icons](/package/icon) and [avatars](/package/avatar) can be added as `children` of an item, to better communicate a tag's value among the group. + +```jsx {% live=true %} + + + + Healthy + + + + Fast food + + + + Dessert + + +``` + +Extra visual details increase the cognitive load on users. Include additional elements only when it improves clarity and will contribute positively to the understanding of an interface. + +### Links + +Tags may be links to another page or website. This can be achieved by passing the `href` prop to each item. + +```jsx {% live=true %} + + Thinkmill + Keystatic + Keystone + +``` + + +## Events + +### onRemove + +Enable removable tags by providing the `onRemove` prop to a tag group, which will receive the set of keys to remove. + +```jsx {% live=true %} +let [items, setItems] = React.useState(() => [ + { id: 1, name: 'Bilby' }, + { id: 2, name: 'Kangaroo' }, + { id: 3, name: 'Quokka' }, + { id: 4, name: 'Echidna' }, +]); + +const onRemove = keys => { + setItems(items.filter(item => !keys.has(item.id))) +}; + +return ( + + {item => {item.name}} + +); +``` + +### onAction + +The tag group supports an `onAction` handler that, when used with the `actionLabel` prop, will add an action button at the end of the tags that can be used to perform a custom action. + +```jsx {% live=true %} + alert('Clear action triggered')} + aria-label="action example" +> + Bilby + Kangaroo + Quokka + Echidna + +``` + +## Props + +### Label + +Each `TagGroup` should be labelled using the `label` prop. If a visible label isn’t appropriate, use the `aria-label` prop to identify the control for accessibility. + +```jsx {% live=true %} + + Bilby + Kangaroo + Quokka + Echidna + +``` + +### Max rows + +Limit the number of rows initially shown by providing a `maxRows` prop. This will append an action button that can be pressed to show the remaining tags. + +```jsx {% live=true %} +let items = [ + { id: 1, name: 'Bilby' }, + { id: 2, name: 'Kangaroo' }, + { id: 3, name: 'Quokka' }, + { id: 4, name: 'Echidna' }, + { id: 5, name: 'Dingo' }, + { id: 6, name: 'Cassowary' }, + { id: 7, name: 'Koala' }, + { id: 8, name: 'Wombat' }, + { id: 9, name: 'Platypus' }, + { id: 10, name: 'Tasmanian Devil' }, +]; + +return ( + + + {item => {item.name}} + + +); +``` + +### Empty state + +Use the `renderEmptyState` prop to customise what the tag group will display if there are no tags provided. + +```jsx {% live=true %} + ( + + No tags. Click here to add some. + + )} +> + {[]} + +``` \ No newline at end of file diff --git a/design-system/pkg/src/tag/index.ts b/design-system/pkg/src/tag/index.ts new file mode 100644 index 000000000..2b8eb941e --- /dev/null +++ b/design-system/pkg/src/tag/index.ts @@ -0,0 +1,9 @@ +'use client'; + +export { Item } from '@react-stately/collections'; + +export { Tag } from './Tag'; +export { TagGroup } from './TagGroup'; + +export type { TagProps } from './Tag'; +export type { TagGroupProps } from './TagGroup'; diff --git a/design-system/pkg/src/tag/l10n.json b/design-system/pkg/src/tag/l10n.json new file mode 100644 index 000000000..fa904b56d --- /dev/null +++ b/design-system/pkg/src/tag/l10n.json @@ -0,0 +1,206 @@ +{ + "ar-AE": { + "actions": "الإجراءات", + "hideButtonLabel": "إظهار أقل", + "noTags": "بدون", + "showAllButtonLabel": "عرض الكل ({tagCount, number})" + }, + "bg-BG": { + "actions": "Действия", + "hideButtonLabel": "Показване на по-малко", + "noTags": "Нито един", + "showAllButtonLabel": "Показване на всички ({tagCount, number})" + }, + "cs-CZ": { + "actions": "Akce", + "hideButtonLabel": "Zobrazit méně", + "noTags": "Žádný", + "showAllButtonLabel": "Zobrazit vše ({tagCount, number})" + }, + "da-DK": { + "actions": "Handlinger", + "hideButtonLabel": "Vis mindre", + "noTags": "Ingen", + "showAllButtonLabel": "Vis alle ({tagCount, number})" + }, + "de-DE": { + "actions": "Aktionen", + "hideButtonLabel": "Weniger zeigen", + "noTags": "Keine", + "showAllButtonLabel": "Alle anzeigen ({tagCount, number})" + }, + "el-GR": { + "actions": "Ενέργειες", + "hideButtonLabel": "Εμφάνιση λιγότερων", + "noTags": "Κανένα", + "showAllButtonLabel": "Εμφάνιση όλων ({tagCount, number})" + }, + "en-US": { + "showAllButtonLabel": "Show all ({tagCount, number})", + "hideButtonLabel": "Show less", + "actions": "Actions", + "noTags": "None" + }, + "es-ES": { + "actions": "Acciones", + "hideButtonLabel": "Mostrar menos", + "noTags": "Ninguno", + "showAllButtonLabel": "Mostrar todo ({tagCount, number})" + }, + "et-EE": { + "actions": "Toimingud", + "hideButtonLabel": "Kuva vähem", + "noTags": "Puudub", + "showAllButtonLabel": "Kuva kõik ({tagCount, number})" + }, + "fi-FI": { + "actions": "Toiminnot", + "hideButtonLabel": "Näytä vähemmän", + "noTags": "Ei mitään", + "showAllButtonLabel": "Näytä kaikki ({tagCount, number})" + }, + "fr-FR": { + "actions": "Actions", + "hideButtonLabel": "Afficher moins", + "noTags": "Aucun", + "showAllButtonLabel": "Tout afficher ({tagCount, number})" + }, + "he-IL": { + "actions": "פעולות", + "hideButtonLabel": "הצג פחות", + "noTags": "ללא", + "showAllButtonLabel": "הצג הכל ({tagCount, number})" + }, + "hr-HR": { + "actions": "Radnje", + "hideButtonLabel": "Prikaži manje", + "noTags": "Nema", + "showAllButtonLabel": "Prikaži sve ({tagCount, number})" + }, + "hu-HU": { + "actions": "Műveletek", + "hideButtonLabel": "Mutass kevesebbet", + "noTags": "Egyik sem", + "showAllButtonLabel": "Az összes megjelenítése ({tagCount, number})" + }, + "it-IT": { + "actions": "Azioni", + "hideButtonLabel": "Mostra meno", + "noTags": "Nessuno", + "showAllButtonLabel": "Mostra tutto ({tagCount, number})" + }, + "ja-JP": { + "actions": "アクション", + "hideButtonLabel": "表示を減らす", + "noTags": "なし", + "showAllButtonLabel": "すべての ({tagCount, number}) を表示" + }, + "ko-KR": { + "actions": "액션", + "hideButtonLabel": "간단히 표시", + "noTags": "없음", + "showAllButtonLabel": "모두 표시 ({tagCount, number})" + }, + "lt-LT": { + "actions": "Veiksmai", + "hideButtonLabel": "Rodyti mažiau", + "noTags": "Nėra", + "showAllButtonLabel": "Rodyti viską ({tagCount, number})" + }, + "lv-LV": { + "actions": "Darbības", + "hideButtonLabel": "Rādīt mazāk", + "noTags": "Nav", + "showAllButtonLabel": "Rādīt visu ({tagCount, number})" + }, + "nb-NO": { + "actions": "Handlinger", + "hideButtonLabel": "Vis mindre", + "noTags": "Ingen", + "showAllButtonLabel": "Vis alle ({tagCount, number})" + }, + "nl-NL": { + "actions": "Acties", + "hideButtonLabel": "Minder weergeven", + "noTags": "Geen", + "showAllButtonLabel": "Alles tonen ({tagCount, number})" + }, + "pl-PL": { + "actions": "Działania", + "hideButtonLabel": "Wyświetl mniej", + "noTags": "Brak", + "showAllButtonLabel": "Pokaż wszystko ({tagCount, number})" + }, + "pt-BR": { + "actions": "Ações", + "hideButtonLabel": "Mostrar menos", + "noTags": "Nenhum", + "showAllButtonLabel": "Mostrar tudo ({tagCount, number})" + }, + "pt-PT": { + "actions": "Ações", + "hideButtonLabel": "Mostrar menos", + "noTags": "Nenhum", + "showAllButtonLabel": "Mostrar tudo ({tagCount, number})" + }, + "ro-RO": { + "actions": "Acțiuni", + "hideButtonLabel": "Se afișează mai puțin", + "noTags": "Niciuna", + "showAllButtonLabel": "Se afișează tot ({tagCount, number})" + }, + "ru-RU": { + "actions": "Действия", + "hideButtonLabel": "Показать меньше", + "noTags": "Нет", + "showAllButtonLabel": "Показать все ({tagCount, number})" + }, + "sk-SK": { + "actions": "Akcie", + "hideButtonLabel": "Zobraziť menej", + "noTags": "Žiadne", + "showAllButtonLabel": "Zobraziť všetko ({tagCount, number})" + }, + "sl-SI": { + "actions": "Dejanja", + "hideButtonLabel": "Prikaži manj", + "noTags": "Nič", + "showAllButtonLabel": "Prikaž vse ({tagCount, number})" + }, + "sr-SP": { + "actions": "Radnje", + "hideButtonLabel": "Prikaži manje", + "noTags": "Ne postoji", + "showAllButtonLabel": "Prikaži sve ({tagCount, number})" + }, + "sv-SE": { + "actions": "Åtgärder", + "hideButtonLabel": "Visa mindre", + "noTags": "Ingen", + "showAllButtonLabel": "Visa alla ({tagCount, number})" + }, + "tr-TR": { + "actions": "Eylemler", + "hideButtonLabel": "Daha az göster", + "noTags": "Hiçbiri", + "showAllButtonLabel": "Tümünü göster ({tagCount, number})" + }, + "uk-UA": { + "actions": "Дії", + "hideButtonLabel": "Показувати менше", + "noTags": "Немає", + "showAllButtonLabel": "Показати всі ({tagCount, number})" + }, + "zh-CN": { + "actions": "操作", + "hideButtonLabel": "显示更少", + "noTags": "无", + "showAllButtonLabel": "全部显示 ({tagCount, number})" + }, + "zh-TW": { + "actions": "動作", + "hideButtonLabel": "顯示較少", + "noTags": "無", + "showAllButtonLabel": "顯示全部 ({tagCount, number})" + } +} diff --git a/design-system/pkg/src/tag/stories/TagGroup.stories.tsx b/design-system/pkg/src/tag/stories/TagGroup.stories.tsx new file mode 100644 index 000000000..075e8bca4 --- /dev/null +++ b/design-system/pkg/src/tag/stories/TagGroup.stories.tsx @@ -0,0 +1,292 @@ +import { + type ArgTypes, + type Meta, + type StoryObj, + action, +} from '@keystar/ui-storybook'; +import { type Key } from '@react-types/shared'; +import React, { type PropsWithChildren, useState } from 'react'; + +import { Avatar } from '@keystar/ui/avatar'; +import { ContextualHelp } from '@keystar/ui/contextual-help'; +import { Icon } from '@keystar/ui/icon'; +import { saladIcon } from '@keystar/ui/icon/icons/saladIcon'; +import { pizzaIcon } from '@keystar/ui/icon/icons/pizzaIcon'; +import { dessertIcon } from '@keystar/ui/icon/icons/dessertIcon'; +import { VStack } from '@keystar/ui/layout'; +import { TextLink } from '@keystar/ui/link'; +import { Content } from '@keystar/ui/slots'; +import { Heading, Text } from '@keystar/ui/typography'; + +import { Item, TagGroup } from '../index'; + +let manyItems: { key: number }[] = []; +for (let i = 0; i < 50; i++) { + let item = { key: i + 1 }; + manyItems.push(item); +} + +function ResizableContainer(props: PropsWithChildren) { + return ( + + {props.children} + Use the resize handle to resize the container. + + ); +} + +function render(props: ArgTypes) { + return ( + + Cool Tag 1 + Cool Tag 2 + Cool Tag 3 + Cool Tag 4 + Cool Tag 5 + Cool Tag 6 + + ); +} + +export default { + title: 'Components/TagGroup', + component: TagGroup, + argTypes: { + contextualHelp: { table: { disable: true } }, + items: { table: { disable: true } }, + onAction: { table: { disable: true } }, + onRemove: { table: { disable: true } }, + maxRows: { + type: 'number', + }, + isRequired: { + control: 'boolean', + }, + description: { + control: 'text', + }, + errorMessage: { + control: 'text', + }, + }, + render, +} as Meta; + +export type TagGroupStory = StoryObj; + +export const Default: TagGroupStory = {}; + +export const WithIcons: TagGroupStory = { + args: { + items: [ + { key: '1', label: 'Healthy', icon: saladIcon }, + { key: '2', label: 'Fast food', icon: pizzaIcon }, + { key: '3', label: 'Dessert', icon: dessertIcon }, + ], + }, + render: args => ( + + {(item: any) => ( + + + {item.label} + + )} + + ), +}; + +export const OnRemove: TagGroupStory = { + render: args => , + name: 'onRemove', +}; + +export const Wrapping: TagGroupStory = { + decorators: [Story => {}], +}; + +export const LabelTruncation: TagGroupStory = { + render: args => ( +
+ + Cool Tag 1 with a really long label + Another long cool tag label + This tag + +
+ ), +}; + +export const MaxRows: TagGroupStory = { + args: { maxRows: 2 }, + decorators: [Story => {}], + name: 'maxRows', +}; + +export const MaxRowsManyTags: TagGroupStory = { + args: { maxRows: 2 }, + render: args => ( + + {(item: any) => {`Tag ${item.key}`}} + + ), + decorators: [Story => {}], + name: 'maxRows with many tags', +}; + +export const MaxRowsOnRemove: TagGroupStory = { + args: { maxRows: 2 }, + render: args => , + decorators: [Story => {}], + name: 'maxRows + onRemove', +}; + +export const WithAvatar: TagGroupStory = { + args: { + items: [ + { key: '1', label: 'Cool Person 1' }, + { key: '2', label: 'Cool Person 2' }, + ], + }, + render: args => ( + + {(item: any) => ( + + + {item.label} + + )} + + ), + name: 'with avatar', +}; + +export const WithAvatarOnRemove: TagGroupStory = { + render: args => , + name: 'with avatar + onRemove', +}; + +export const WithAction: TagGroupStory = { + args: { onAction: action('clear'), actionLabel: 'Clear' }, + name: 'with action', +}; + +export const WithActionAndMaxRows: TagGroupStory = { + args: { + maxRows: 2, + onAction: action('clear'), + actionLabel: 'Clear', + }, + decorators: [Story => {}], + name: 'with action and maxRows', +}; + +export const WithFieldElements: TagGroupStory = { + args: { + onAction: action('clear'), + actionLabel: 'Clear', + label: 'Some sample tags', + description: 'Here is a description about the tag group.', + contextualHelp: ( + + What are these tags? + Here is more information about the tag group. + + ), + }, + decorators: [Story => {}], + name: 'with field elements', +}; + +export const EmptyState: TagGroupStory = { + render: args => ( + + {[]} + + ), + name: 'Empty state', +}; + +export const CustomEmptyState: TagGroupStory = { + ...EmptyState, + args: { + renderEmptyState: () => ( + + No tags. Click here to add some. + + ), + }, + name: 'Custom empty state', +}; + +function OnRemoveExample(props: any) { + let { withAvatar, ...otherProps } = props; + 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' }, + ]); + + let onRemove = (key: Key) => { + setItems(prevItems => prevItems.filter(item => key !== item.id)); + action('onRemove')(key); + }; + + return ( + onRemove(keys.values().next().value)} + {...otherProps} + > + {(item: any) => ( + + {withAvatar && ( + + )} + {item.label} + + )} + + ); +} + +export const Links: TagGroupStory = { + render: args => ( + + Thinkmill + Keystatic + Keystone + + ), +}; + +export const LinksWithRemove: TagGroupStory = { + render: args => ( + action('onRemove')(keys.values().next().value)} + {...args} + > + Thinkmill + Keystatic + Keystone + + ), +}; diff --git a/design-system/pkg/src/tag/styles.ts b/design-system/pkg/src/tag/styles.ts new file mode 100644 index 000000000..1f4ab41b8 --- /dev/null +++ b/design-system/pkg/src/tag/styles.ts @@ -0,0 +1,24 @@ +import { tokenSchema } from '@keystar/ui/style'; + +export const gapVar = tokenSchema.size.space.regular; +export const heightVar = tokenSchema.size.element.small; +export const radiusVar = tokenSchema.size.radius.small; + +export const tokenValues = { + gap: 8, + height: 24, +}; + +// TODO: revisit this approach, so we can keep things in-sync +// export function getNumericTokenValues() { +// const computedStyle = window.getComputedStyle(document.body); +// const gap = computedStyle.getPropertyValue(unwrapCssVar(gapVar)); +// const height = computedStyle.getPropertyValue(unwrapCssVar(heightVar)); +// return { +// gap: parseInt(gap, 10), +// height: parseInt(height, 10), +// }; +// } +// function unwrapCssVar(value: string) { +// return value.slice(4, -1); +// } diff --git a/design-system/pkg/src/tag/test/TagGroup.test.tsx b/design-system/pkg/src/tag/test/TagGroup.test.tsx new file mode 100644 index 000000000..e0c2499ca --- /dev/null +++ b/design-system/pkg/src/tag/test/TagGroup.test.tsx @@ -0,0 +1,71 @@ +import { + afterEach, + beforeAll, + describe, + expect, + it, + jest, +} from '@jest/globals'; +import { act, fireEvent, renderWithProvider } from '#test-utils'; + +import React from 'react'; + +import { Item, TagGroup } from '../index'; + +// TODO: revisit once keystone refurb is done +describe('tag/TagGroup', function () { + let onRemoveSpy = jest.fn(); + + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + act(() => { + jest.runAllTimers(); + }); + jest.restoreAllMocks(); + }); + + it('provides context for Tag component', function () { + let { getAllByRole } = renderWithProvider( + + Tag 1 + Tag 2 + Tag 3 + + ); + + 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 { getByRole, getAllByRole } = renderWithProvider( + + Tag 1 + + ); + + let tagGroup = getByRole('grid'); + expect(tagGroup).toBeVisible(); + let tags = getAllByRole('row'); + let cells = getAllByRole('gridcell'); + expect(tags).toHaveLength(cells.length); + }); + + it('has correct tab index', () => { + let { getAllByRole } = renderWithProvider( + + Tag 1 + + ); + + let tags = getAllByRole('row'); + expect(tags[0]).toHaveAttribute('tabIndex', '0'); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index afa15c63f..afd1f22ed 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -353,6 +353,9 @@ importers: '@react-aria/tabs': specifier: ^3.9.5 version: 3.9.5(react-dom@18.2.0)(react@18.2.0) + '@react-aria/tag': + specifier: ^3.4.5 + version: 3.4.5(react-dom@18.2.0)(react@18.2.0) '@react-aria/textfield': specifier: ^3.14.8 version: 3.14.8(react@18.2.0) @@ -8911,6 +8914,26 @@ packages: react-dom: 18.2.0(react@18.2.0) dev: false + /@react-aria/tag@3.4.5(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-iyJuATQ8t2cdLC7hiZm143eeZze/MtgxaMq0OewlI9TUje54bkw2Q+CjERdgisIo3Eemf55JJgylGrTcalEJAg==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 + dependencies: + '@react-aria/gridlist': 3.9.3(react-dom@18.2.0)(react@18.2.0) + '@react-aria/i18n': 3.12.2(react@18.2.0) + '@react-aria/interactions': 3.22.2(react@18.2.0) + '@react-aria/label': 3.7.11(react@18.2.0) + '@react-aria/selection': 3.19.3(react-dom@18.2.0)(react@18.2.0) + '@react-aria/utils': 3.25.2(react@18.2.0) + '@react-stately/list': 3.10.8(react@18.2.0) + '@react-types/button': 3.9.6(react@18.2.0) + '@react-types/shared': 3.24.1(react@18.2.0) + '@swc/helpers': 0.5.12 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + /@react-aria/textfield@3.14.8(react@18.2.0): resolution: {integrity: sha512-FHEvsHdE1cMR2B7rlf+HIneITrC40r201oLYbHAp3q26jH/HUujzFBB9I20qhXjyBohMWfQLqJhSwhs1VW1RJQ==} peerDependencies: