Skip to content

Commit

Permalink
Tag group (#1295)
Browse files Browse the repository at this point in the history
  • Loading branch information
jossmac authored Sep 6, 2024
1 parent 58a49e4 commit 650633c
Show file tree
Hide file tree
Showing 14 changed files with 1,378 additions and 8 deletions.
5 changes: 5 additions & 0 deletions .changeset/five-taxis-perform.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@keystar/ui': patch
---

New package "@keystar/ui/tag" exports `TagGroup` component.
2 changes: 2 additions & 0 deletions design-system/pkg/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions design-system/pkg/src/avatar/Avatar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
Ref,
} from 'react';

import { useSlotProps } from '@keystar/ui/slots';
import {
BaseStyleProps,
classNames,
Expand Down Expand Up @@ -52,6 +53,7 @@ export const Avatar: ForwardRefExoticComponent<
props: AvatarProps,
forwardedRef: ForwardedRef<HTMLDivElement>
) {
props = useSlotProps(props, 'avatar');
const { alt, size = 'regular', ...otherProps } = props;
const styleProps = useStyleProps(otherProps);

Expand Down
6 changes: 2 additions & 4 deletions design-system/pkg/src/menu/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,10 @@ export type MenuTriggerProps = {
} & _MenuTriggerProps;

export type ActionMenuProps<T> = {
/** 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<T> &
Expand Down
5 changes: 1 addition & 4 deletions design-system/pkg/src/table/Resizer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,10 +60,7 @@ function Resizer<T>(
// 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);
Expand Down
197 changes: 197 additions & 0 deletions design-system/pkg/src/tag/Tag.tsx
Original file line number Diff line number Diff line change
@@ -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<T> extends AriaTagProps<T> {
state: ListState<T>;
}

/** @private Internal use only: rendered via `Item` by consumer. */
export function Tag<T>(props: TagProps<T>) {
const { item, state, ...otherProps } = props;

let styleProps = useStyleProps(otherProps);
let { hoverProps, isHovered } = useHover({});
let { isFocused, isFocusVisible, focusProps } = useFocusRing({
within: true,
});
let domRef = useRef<HTMLDivElement>(null);
let linkRef = useRef<HTMLAnchorElement>(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) ? (
<Text>{item.rendered}</Text>
) : (
item.rendered
);

return (
<div
{...mergeProps(rowProps, hoverProps, focusProps)}
{...toDataAttributes(
{
isFocused,
isFocusVisible,
isHovered,
isLink,
isRemovable,
},
{
omitFalsyValues: true,
trimBooleanKeys: true,
}
)}
className={classNames(
css({
backgroundColor: tokenSchema.color.alias.backgroundIdle,
border: `${tokenSchema.size.border.regular} solid ${tokenSchema.color.alias.borderIdle}`,
borderRadius: tokenSchema.size.radius.small,
color: tokenSchema.color.alias.foregroundIdle,
cursor: 'default',
display: 'inline-flex',
height: heightVar,
margin: `calc(${gapVar} / 2)`,
maxInlineSize: '100%',
outline: `0 solid transparent`,
position: 'relative',
transition: transition(['outline-color', 'outline-width'], {
duration: 'short',
}),
userSelect: 'none',

'&[data-href]': {
cursor: 'pointer',

'&[data-hovered]': {
backgroundColor: tokenSchema.color.alias.backgroundHovered,
borderColor: tokenSchema.color.alias.borderHovered,
color: tokenSchema.color.alias.foregroundHovered,
},
},

'&[data-focus-visible]': {
outlineColor: tokenSchema.color.alias.focusRing,
outlineWidth: tokenSchema.size.alias.focusRing,
outlineOffset: `calc(${tokenSchema.size.border.regular} * -1)`,
},
}),
styleProps.className
)}
ref={domRef}
>
<div
className={css({ alignItems: 'center', display: 'flex' })}
{...gridCellProps}
>
<SlotProvider slots={slots}>
{/* TODO: review accessibility */}
{isLink ? (
<a
{...linkProps}
tabIndex={-1}
ref={linkRef}
className={css({
color: 'inherit',
outline: 'none',
textDecoration: 'none',

'&::before': { content: '""', inset: 0, position: 'absolute' },
})}
>
{contents}
</a>
) : (
contents
)}

<ClearSlots>
{isRemovable && (
<ClearButton
{...removeButtonProps}
UNSAFE_className={css({
marginInlineStart: `calc(${tokenSchema.size.space.regular} * -1)`,
height: heightVar,
width: heightVar,
})}
/>
)}
</ClearSlots>
</SlotProvider>
</div>
</div>
);
}

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<T>(props: any): AriaTagProps<T> {
const safeProps = { ...props };
for (const attr of SYNTHETIC_LINK_ATTRS) {
delete safeProps[attr];
}
return safeProps;
}
Loading

0 comments on commit 650633c

Please sign in to comment.