Skip to content

Commit 3c2c2d2

Browse files
enguerranwsjjpxiiijjpxiii
authored
Feature/add tag component (#69)
* Enable a11y addon for accessibility testing (#67) Co-authored-by: jjpxiii <jjpxiii@github.com> * rm unused imports * rename dismiss -> isDismissible * mark Tag as done in doc * DataAttribute type is more readable * added type test on Tag component * review fixes: isDismissible, isSmall, test against undefined for non-bool values --------- Co-authored-by: Jean-Jérôme Pantalacci <jjpxiii@gmail.com> Co-authored-by: jjpxiii <jjpxiii@github.com>
1 parent 0869365 commit 3c2c2d2

File tree

5 files changed

+381
-1
lines changed

5 files changed

+381
-1
lines changed

COMPONENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
- [ ] Translate
3737
- [x] Summary
3838
- [ ] Table
39-
- [ ] Tag
39+
- [x] Tag
4040
- [x] Download
4141
- [ ] Transcription
4242
- [x] Tile

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@
138138
"./ToggleSwitchGroup": "./dist/ToggleSwitchGroup.js",
139139
"./ToggleSwitch": "./dist/ToggleSwitch.js",
140140
"./Tile": "./dist/Tile.js",
141+
"./Tag": "./dist/Tag.js",
141142
"./Tabs": "./dist/Tabs.js",
142143
"./Summary": "./dist/Summary.js",
143144
"./Stepper": "./dist/Stepper.js",

src/Tag.tsx

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
import React, {
2+
memo,
3+
forwardRef,
4+
type ReactNode,
5+
type RefAttributes,
6+
type MemoExoticComponent,
7+
type ForwardRefExoticComponent,
8+
type CSSProperties
9+
} from "react";
10+
import { fr } from "./fr";
11+
import { cx } from "./tools/cx";
12+
import type { FrIconClassName, RiIconClassName } from "./fr/generatedFromCss/classNames";
13+
import { getLink } from "./link";
14+
import type { RegisteredLinkProps } from "./link";
15+
import { assert } from "tsafe/assert";
16+
import type { Equals } from "tsafe";
17+
import { symToStr } from "tsafe/symToStr";
18+
19+
type DataAttribute = Record<`data-${string}`, string | boolean | null | undefined>;
20+
21+
export type TagProps = TagProps.Common &
22+
(TagProps.WithIcon | TagProps.WithoutIcon) &
23+
(TagProps.AsAnchor | TagProps.AsButton | TagProps.AsSpan);
24+
export namespace TagProps {
25+
export type Common = {
26+
className?: string;
27+
/** Default: false */
28+
small?: boolean;
29+
style?: CSSProperties;
30+
title?: string;
31+
children: ReactNode;
32+
};
33+
34+
export type WithIcon = {
35+
/** Function of the button, to provide if the label isn't explicit */
36+
iconId: FrIconClassName | RiIconClassName;
37+
};
38+
39+
export type WithoutIcon = {
40+
iconId?: never;
41+
};
42+
43+
export type AsAnchor = {
44+
linkProps: RegisteredLinkProps;
45+
onClick?: never;
46+
nativeButtonProps?: never;
47+
nativeSpanProps?: never;
48+
dismissible?: never;
49+
pressed?: never;
50+
};
51+
export type AsButton = {
52+
linkProps?: never;
53+
nativeSpanProps?: never;
54+
/** Default: false */
55+
dismissible?: boolean;
56+
pressed?: boolean;
57+
onClick?: React.MouseEventHandler<HTMLButtonElement>;
58+
nativeButtonProps?: React.DetailedHTMLProps<
59+
React.ButtonHTMLAttributes<HTMLButtonElement>,
60+
HTMLButtonElement
61+
> &
62+
DataAttribute;
63+
};
64+
export type AsSpan = {
65+
linkProps?: never;
66+
onClick?: never;
67+
dismissible?: never;
68+
pressed?: never;
69+
nativeButtonProps?: never;
70+
nativeSpanProps?: React.DetailedHTMLProps<
71+
React.HTMLAttributes<HTMLSpanElement>,
72+
HTMLSpanElement
73+
> &
74+
DataAttribute;
75+
};
76+
}
77+
78+
/** @see <https://react-dsfr-components.etalab.studio/?path=/docs/components-tag> */
79+
export const Tag = memo(
80+
forwardRef<HTMLButtonElement | HTMLAnchorElement | HTMLSpanElement, TagProps>((props, ref) => {
81+
const {
82+
className: prop_className,
83+
children,
84+
title,
85+
iconId,
86+
small = false,
87+
pressed,
88+
dismissible = false,
89+
linkProps,
90+
nativeButtonProps,
91+
nativeSpanProps,
92+
style,
93+
onClick,
94+
...rest
95+
} = props;
96+
97+
assert<Equals<keyof typeof rest, never>>();
98+
99+
const { Link } = getLink();
100+
101+
const className = cx(
102+
fr.cx(
103+
"fr-tag",
104+
small && `fr-tag--sm`,
105+
iconId,
106+
iconId && "fr-tag--icon-left", // actually, it's always left but we need it in order to have the icon rendering
107+
dismissible && "fr-tag--dismiss"
108+
),
109+
linkProps !== undefined && linkProps.className,
110+
prop_className
111+
);
112+
113+
return (
114+
<>
115+
{linkProps !== undefined && (
116+
<Link
117+
{...linkProps}
118+
title={title ?? linkProps.title}
119+
className={cx(linkProps?.className, className)}
120+
style={{
121+
...linkProps?.style,
122+
...style
123+
}}
124+
ref={ref as React.ForwardedRef<HTMLAnchorElement>}
125+
{...rest}
126+
>
127+
{children}
128+
</Link>
129+
)}
130+
{nativeButtonProps !== undefined && (
131+
<button
132+
{...nativeButtonProps}
133+
className={cx(nativeButtonProps?.className, className)}
134+
style={{
135+
...nativeButtonProps?.style,
136+
...style
137+
}}
138+
title={title ?? nativeButtonProps?.title}
139+
onClick={onClick ?? nativeButtonProps?.onClick}
140+
disabled={nativeButtonProps?.disabled}
141+
ref={ref as React.ForwardedRef<HTMLButtonElement>}
142+
aria-pressed={pressed}
143+
{...rest}
144+
>
145+
{children}
146+
</button>
147+
)}
148+
{linkProps === undefined && nativeButtonProps === undefined && (
149+
<span
150+
{...nativeSpanProps}
151+
className={cx(nativeSpanProps?.className, className)}
152+
style={{
153+
...nativeSpanProps?.style,
154+
...style
155+
}}
156+
title={title ?? nativeSpanProps?.title}
157+
ref={ref as React.ForwardedRef<HTMLSpanElement>}
158+
{...rest}
159+
>
160+
{children}
161+
</span>
162+
)}
163+
</>
164+
);
165+
})
166+
) as MemoExoticComponent<
167+
ForwardRefExoticComponent<
168+
TagProps.Common &
169+
(TagProps.WithIcon | TagProps.WithoutIcon) &
170+
(
171+
| (TagProps.AsAnchor & RefAttributes<HTMLAnchorElement>)
172+
| (TagProps.AsButton & RefAttributes<HTMLButtonElement>)
173+
| (TagProps.AsSpan & RefAttributes<HTMLSpanElement>)
174+
)
175+
>
176+
>;
177+
178+
Tag.displayName = symToStr({ Tag });
179+
180+
export default Tag;

stories/Tag.stories.tsx

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { Tag, type TagProps } from "../dist/Tag";
2+
import { sectionName } from "./sectionName";
3+
import { getStoryFactory } from "./getStory";
4+
import { assert, Equals } from "tsafe/assert";
5+
6+
const { meta, getStory } = getStoryFactory({
7+
sectionName,
8+
"wrappedComponent": { Tag },
9+
"description": `
10+
- [See DSFR documentation](https://www.systeme-de-design.gouv.fr/elements-d-interface/composants/bouton)
11+
- [See source code](https://github.com/codegouvfr/react-dsfr/blob/main/src/Tag.tsx)`,
12+
"argTypes": {
13+
"dismissible": {
14+
"control": { "type": "boolean" }
15+
},
16+
"small": {
17+
"control": { "type": "boolean" }
18+
},
19+
"iconId": {
20+
"options": (() => {
21+
const options = ["fr-icon-checkbox-circle-line", "ri-ancient-gate-fill"] as const;
22+
type AllIconIds = NonNullable<TagProps["iconId"]>;
23+
24+
assert<
25+
Equals<typeof options[number], Extract<AllIconIds, typeof options[number]>>
26+
>();
27+
28+
return options;
29+
})(),
30+
"control": { "type": "radio" }
31+
},
32+
33+
"nativeButtonProps": {
34+
"description": `Can be used to attach extra props to the underlying native button.
35+
Example: \`{ "aria-controls": "fr-modal-1", onMouseEnter: event => {...} }\``,
36+
"control": { "type": null }
37+
},
38+
"children": {
39+
"description": "The label of the button",
40+
"control": { "type": "string" }
41+
}
42+
},
43+
"disabledProps": ["lang"]
44+
});
45+
46+
export default meta;
47+
48+
export const Default = getStory({
49+
"children": "Label tag"
50+
});
51+
52+
export const TagAsAnchor = getStory({
53+
"children": "I'm a link",
54+
"linkProps": {
55+
"href": "#"
56+
}
57+
});
58+
59+
export const TagWithIcon = getStory({
60+
"children": "Label button",
61+
"iconId": "fr-icon-checkbox-circle-line"
62+
});
63+
64+
export const TagLinkWithIcon = getStory({
65+
"children": "Label link",
66+
"iconId": "fr-icon-checkbox-circle-line",
67+
"linkProps": {
68+
"href": "#"
69+
}
70+
});
71+
72+
export const TagButtonWithIcon = getStory({
73+
"children": "Label button",
74+
"iconId": "fr-icon-checkbox-circle-line",
75+
"nativeButtonProps": {
76+
onClick: () => console.log("click")
77+
}
78+
});
79+
80+
export const SmallTag = getStory({
81+
"children": "Label button",
82+
"small": true,
83+
"iconId": "fr-icon-checkbox-circle-line"
84+
});
85+
86+
export const TagDismissible = getStory({
87+
"children": "Label button",
88+
"dismissible": true,
89+
"nativeButtonProps": {
90+
onClick: () => console.log("click")
91+
}
92+
});
93+
94+
export const SmallTagDismissible = getStory({
95+
"children": "Label button",
96+
"small": true,
97+
"dismissible": true,
98+
"nativeButtonProps": {
99+
onClick: () => console.log("click")
100+
}
101+
});
102+
103+
export const TagPressed = getStory({
104+
"children": "Label button",
105+
"pressed": true,
106+
"nativeButtonProps": {
107+
onClick: () => console.log("click")
108+
}
109+
});

test/types/Tag.tsx

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import React from "react";
2+
import { Tag } from "../../src/Tag";
3+
4+
{
5+
<Tag>Label</Tag>;
6+
}
7+
{
8+
<Tag iconId="fr-icon-add-line">Label</Tag>;
9+
}
10+
{
11+
<Tag iconId="fr-icon-add-line" small>
12+
Label
13+
</Tag>;
14+
}
15+
{
16+
<Tag iconId="fr-icon-add-line" onClick={() => console.log("clicked")}>
17+
Label
18+
</Tag>;
19+
}
20+
{
21+
<Tag
22+
linkProps={{
23+
"href": "https://www.example.com"
24+
}}
25+
iconId="fr-icon-add-line"
26+
>
27+
Label
28+
</Tag>;
29+
}
30+
{
31+
<Tag onClick={() => console.log("clicked")}>Label</Tag>;
32+
}
33+
{
34+
<Tag dismissible onClick={() => console.log("clicked on my dismissible tag")}>
35+
Label
36+
</Tag>;
37+
}
38+
{
39+
<Tag dismissible onClick={() => console.log("clicked")}>
40+
Label
41+
</Tag>;
42+
}
43+
{
44+
//@ts-expect-error: children is required
45+
<Tag></Tag>;
46+
}
47+
{
48+
//@ts-expect-error: nativeSpanProps and onClick are mutually exclusive
49+
<Tag
50+
onClick={() => console.log("clicked")}
51+
nativeSpanProps={{
52+
"id": "foo"
53+
}}
54+
>
55+
Label
56+
</Tag>;
57+
}
58+
{
59+
//@ts-expect-error: linkProps and onClick are mutually exclusive
60+
<Tag
61+
linkProps={{
62+
"href": "https://www.example.com"
63+
}}
64+
onClick={() => console.log("clicked")}
65+
>
66+
Label
67+
</Tag>;
68+
}
69+
{
70+
//@ts-expect-error: we shouldn't use Tag component as a span if it's dismissible
71+
<Tag
72+
dismissible
73+
nativeSpanProps={{
74+
"id": "foo"
75+
}}
76+
>
77+
Label
78+
</Tag>;
79+
}
80+
{
81+
//@ts-expect-error: we shouldn't use Tag component as an anchor if it's dismissible
82+
<Tag
83+
dismissible
84+
linkProps={{
85+
"href": "https://www.example.com"
86+
}}
87+
>
88+
Label
89+
</Tag>;
90+
}

0 commit comments

Comments
 (0)