Skip to content

Commit

Permalink
Merge pull request #308 from City-of-Helsinki/feature/finalize-tag
Browse files Browse the repository at this point in the history
[Feature] Finalize tag implementation
  • Loading branch information
aleksik committed Nov 13, 2020
2 parents c8c9432 + b582c46 commit 1d83040
Show file tree
Hide file tree
Showing 13 changed files with 327 additions and 37 deletions.
14 changes: 13 additions & 1 deletion packages/core/src/components/tag/tag.css
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,26 @@
flex-direction: row-reverse;
font-size: var(--fontsize-body-s);
outline: none;
padding: 0 var(--spacing-2-xs) 0 0;
}

.hds-tag:focus,
.hds-tag:focus-within {
box-shadow: 0 0 0 3px var(--tag-focus-outline-color);
}

.hds-tag[tabindex='0'] {
cursor: pointer;
}

.hds-tag__label {
line-height: var(--lineheight-m);
padding: var(--spacing-3-xs) var(--spacing-2-xs);
}

.hds-tag__label:not(:only-child) {
padding: 0 var(--spacing-2-xs) 0 0;
}

.hds-tag__delete-button {
display: flex;
outline: none;
Expand Down
14 changes: 13 additions & 1 deletion packages/core/src/components/tag/tag.stories.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,19 @@ export default {

export const Default = () => `
<div class="hds-tag">
<span>Label</span>
<span class="hds-tag__label">Label</span>
</div>
`;

export const Clickable = () => `
<div class="hds-tag" role="link" tabindex="0" onclick="">
<span class="hds-tag__label">Label</span>
</div>
`;

export const Deletable = () => `
<div class="hds-tag">
<span class="hds-tag__label">Label</span>
<button aria-label="Delete item" class="hds-tag__delete-button button-reset">
<span aria-hidden="true" class="hds-icon hds-icon--cross"></span>
</button>
Expand Down
1 change: 1 addition & 0 deletions packages/react/rollup.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ export default [
'components/Section/index': 'src/components/section/index.ts',
'components/Select/index': 'src/components/dropdown/select/index.ts',
'components/StatusLabel/index': 'src/components/statusLabel/index.ts',
'components/Tag/index': 'src/components/tag/index.ts',
'components/TextInput/index': 'src/components/textInput/index.ts',
'components/Textarea/index': 'src/components/textarea/index.ts',
'components/Tooltip/index': 'src/components/tooltip/index.ts',
Expand Down
4 changes: 4 additions & 0 deletions packages/react/src/components/tag/Tag.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@
composes: hds-tag from 'hds-core/lib/components/tag/tag.css';
}

.label {
composes: hds-tag__label from 'hds-core/lib/components/tag/tag.css';
}

.deleteButton {
@extend %buttonReset;
composes: hds-tag__delete-button from 'hds-core/lib/components/tag/tag.css';
Expand Down
54 changes: 51 additions & 3 deletions packages/react/src/components/tag/Tag.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,57 @@ import { Tag } from './Tag';
export default {
component: Tag,
title: 'Components/Tag',
parameters: {
controls: { expanded: true },
},
args: {
children: 'Americum',
},
};

export const Example = () => {
const label = 'Americum';
return <Tag deleteButtonAriaLabel={`Delete: ${label}`} onDelete={() => action(`Delete: ${label}`)} label={label} />;
export const Default = (args) => <Tag {...args} />;

export const Clickable = (args) => (
<>
<Tag {...args} label="Link" role="link" id="link" onClick={() => action(`Click: ${args.children}`)()}>
{args.children}
</Tag>
<Tag
{...args}
label="Button"
role="button"
id="button"
style={{ marginLeft: 'var(--spacing-s)' }}
onClick={() => action(`Click: ${args.children}`)()}
>
{args.children}
</Tag>
</>
);
Clickable.storyName = 'Clickable tag';

export const Deletable = (args) => {
return (
<Tag
{...args}
deleteButtonAriaLabel={`Delete: ${args.label}`}
onDelete={() => action(`Delete: ${args.children}`)()}
>
{args.children}
</Tag>
);
};
Deletable.storyName = 'Deletable tag';

export const CustomTheme = (args) => (
<Tag {...args} onClick={() => action(`Click: ${args.children}`)()}>
{args.children}
</Tag>
);
CustomTheme.args = {
theme: {
'--tag-background': 'var(--color-engel)',
'--tag-color': 'var(--color-black-90)',
'--tag-focus-outline-color': 'var(--color-black-90)',
},
};
2 changes: 1 addition & 1 deletion packages/react/src/components/tag/Tag.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { Tag } from './Tag';

describe('<Tag /> spec', () => {
it('renders the component', () => {
const { asFragment } = render(<Tag />);
const { asFragment } = render(<Tag>Foo</Tag>);
expect(asFragment()).toMatchSnapshot();
});
});
93 changes: 67 additions & 26 deletions packages/react/src/components/tag/Tag.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,20 @@ import 'hds-core';
import styles from './Tag.module.scss';
import { IconCross } from '../../icons';
import classNames from '../../utils/classNames';
import { useTheme } from '../../hooks/useTheme';
import getModulesClassName from '../../utils/getModulesClassName';

export interface TagCustomTheme {
'--tag-background'?: string;
'--tag-color'?: string;
'--tag-focus-outline-color'?: string;
}

export type TagProps = {
/**
* The label for the tag
*/
children: React.ReactNode;
/**
* Additional class names to apply to the tag
*/
Expand All @@ -25,56 +37,85 @@ export type TagProps = {
* Used to generate the first part of the id on the elements.
*/
id?: string;
/**
* The label for the tag
*/
label: React.ReactNode;
/**
* Props that will be passed to the label `<span>` element.
*/
labelProps?: React.ComponentPropsWithoutRef<'span'>;
/**
* Callback function fired when the delete icon is clicked. If set, the delete icon will be shown.
* Callback function fired when the tag is clicked. If set, the tag will be clickable.
*/
onClick?: (event: React.MouseEvent<HTMLDivElement, MouseEvent> | React.KeyboardEvent<HTMLDivElement>) => void;
/**
* Callback function fired when the delete icon is clicked. If set, a delete button will be shown.
*/
onDelete?: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
/**
* Sets the role of the tag when it's clickable. Uses 'link' by default.
*/
role?: 'link' | 'button';
/**
* Label that is only visible to screen readers. Can be used to to give screen reader users additional information about the tag.
*/
srOnlyLabel?: string;
/**
* Custom theme styles
*/
theme?: TagCustomTheme;
};

export const Tag = React.forwardRef<HTMLDivElement, TagProps>(
(
{
children,
className,
deleteButtonAriaLabel,
deleteButtonProps,
id = 'hds-tag',
label,
labelProps,
onClick,
onDelete,
role = 'link',
srOnlyLabel,
theme,
...rest
}: TagProps,
ref: React.Ref<HTMLDivElement>,
) => (
<div id={id} className={classNames(styles.tag, className)} ref={ref} {...rest}>
<span id={id && `${id}-label`} {...labelProps}>
{srOnlyLabel && <span className={styles.visuallyHidden}>{srOnlyLabel}</span>}
<span aria-hidden={!!srOnlyLabel}>{label}</span>
</span>
{typeof onDelete === 'function' && (
<button
{...deleteButtonProps}
id={id && `${id}-delete-button`}
type="button"
className={styles.deleteButton}
aria-label={deleteButtonAriaLabel}
onClick={onDelete}
>
<IconCross className={styles.icon} />
</button>
)}
</div>
),
) => {
// custom theme class that is applied to the root element
const customThemeClass = useTheme<TagCustomTheme>(getModulesClassName(styles.tag), theme);
const clickable = typeof onClick === 'function';
const deletable = typeof onDelete === 'function';

// handle key down
const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === 'Enter' || event.key === ' ') onClick(event);
};

return (
<div
id={id}
className={classNames(styles.tag, customThemeClass, className)}
ref={ref}
{...(clickable && { tabIndex: 0, role, onClick, onKeyDown })}
{...rest}
>
<span id={id && `${id}-label`} className={styles.label} {...labelProps}>
{srOnlyLabel && <span className={styles.visuallyHidden}>{srOnlyLabel}</span>}
<span aria-hidden={!!srOnlyLabel}>{children}</span>
</span>
{deletable && (
<button
{...deleteButtonProps}
id={id && `${id}-delete-button`}
type="button"
className={styles.deleteButton}
aria-label={deleteButtonAriaLabel}
onClick={onDelete}
>
<IconCross className={styles.icon} aria-hidden />
</button>
)}
</div>
);
},
);
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,14 @@ exports[`<Tag /> spec renders the component 1`] = `
id="hds-tag"
>
<span
class="label"
id="hds-tag-label"
>
<span
aria-hidden="false"
/>
>
Foo
</span>
</span>
</div>
</DocumentFragment>
Expand Down
5 changes: 3 additions & 2 deletions packages/react/src/hooks/useTheme.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,14 @@ const setComponentTheme = <T,>(selector: string, theme: T, customClass: string):
if (typeof window === 'undefined') return;

// checks if the given css rule contains the custom class selector
const hasCustomRule = (rule: CSSRule): boolean => rule.cssText.includes(`${selector}.${customClass}`);
const hasCustomRule = (rule: CSSStyleRule): boolean => rule.selectorText?.includes(`${selector}.${customClass}`);

try {
const { styleSheets } = document;
// the index of the parent stylesheet
const parentIndex = [...styleSheets].findIndex(
(styleSheet) => [...styleSheet.cssRules].findIndex((rule) => rule.cssText.includes(selector)) >= 0,
(styleSheet) =>
[...styleSheet.cssRules].findIndex((rule: CSSStyleRule) => rule.selectorText?.includes(selector)) >= 0,
);
// style sheet containing the css rules for the selector
const parentStyleSheet = styleSheets[parentIndex];
Expand Down
5 changes: 3 additions & 2 deletions packages/react/src/internal/selectedItems/SelectedItems.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,6 @@ export const SelectedItems = <OptionType,>({
key={selectedItemLabel}
className={styles.tag}
id={tagId}
label={selectedItemLabel}
labelProps={{ 'aria-labelledby': `${dropdownId}-label ${tagId}-label` }}
deleteButtonAriaLabel={replaceTokenWithValue(removeButtonAriaLabel, selectedItemLabel)}
// remove delete button from focus order
Expand All @@ -243,7 +242,9 @@ export const SelectedItems = <OptionType,>({
},
onFocus: () => setActiveIndex(index),
})}
/>
>
{selectedItemLabel}
</Tag>
);
})}
<span
Expand Down
6 changes: 6 additions & 0 deletions packages/react/src/utils/getModulesClassName.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/**
* Helper that returns the css/scss module class name without other classes names.
* E.g. using composes in a rule adds the composed class name to the selector.
* @param className
*/
export default (className: string): string => className.substring(0, className.indexOf(' '));
1 change: 1 addition & 0 deletions site/docs/components/component_overview.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ Dropdown | <StatusLabel
[Radio button](/components/radio-button) | <StatusLabel type="info">Stable</StatusLabel> | [<IconCheck/>](/storybook/core/?path=/story/components-radio-button--default) | [<IconCheck/>](/storybook/react/?path=/story/components-radiobutton--default)
[Select](/components/dropdown) | <StatusLabel type="info">Stable</StatusLabel> | | [<IconCheck/>](/storybook/react/?path=/story/components-dropdowns-select--default)
[Status label](/components/status-label) | <StatusLabel type="info">Stable</StatusLabel> | [<IconCheck/>](/storybook/core/?path=/story/components-status-label--default) | [<IconCheck/>](/storybook/react/?path=/story/components-status-label)
[Tag](/components/tag) | <StatusLabel type="alert">Pre-release</StatusLabel> | [<IconCheck/>](/storybook/core/?path=/story/components-tag--default) | [<IconCheck/>](/storybook/react/?path=/story/components-tag--default)
[Text input](/components/text-field) | <StatusLabel type="info">Stable</StatusLabel> | [<IconCheck/>](/storybook/core/?path=/story/components-textinput--default) | [<IconCheck/>](/storybook/react/?path=/story/components-textinput--default)
[Text area](/components/text-field#text-area) | <StatusLabel type="info">Stable</StatusLabel> | [<IconCheck/>](/storybook/core/?path=/story/components-text-input--default) | [<IconCheck/>](/storybook/react/?path=/story/components-textarea--default)
[Tooltip](/components/tooltip) | <StatusLabel type="alert">Pre-release</StatusLabel> | | [<IconCheck/>](/storybook/react/?path=/story/components-tooltip--default)
Expand Down

0 comments on commit 1d83040

Please sign in to comment.