Skip to content

Commit 6fa417d

Browse files
committed
feat(tag): let user use span tag for Tag
1 parent a44d174 commit 6fa417d

File tree

2 files changed

+123
-88
lines changed

2 files changed

+123
-88
lines changed

src/Tag.tsx

Lines changed: 105 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ type DataAttribute = Record<`data-${string}`, string | boolean | null | undefine
2222

2323
export type TagProps = TagProps.Common &
2424
(TagProps.WithIcon | TagProps.WithoutIcon) &
25-
(TagProps.AsAnchor | TagProps.AsButton | TagProps.AsParagraph);
25+
(TagProps.AsAnchor | TagProps.AsButton | TagProps.AsParagraph | TagProps.AsSpan);
2626
export namespace TagProps {
2727
export type Common = {
2828
id?: string;
@@ -32,6 +32,7 @@ export namespace TagProps {
3232
style?: CSSProperties;
3333
title?: string;
3434
children: ReactNode;
35+
as?: "p" | "span" | "button" | "a";
3536
};
3637

3738
export type WithIcon = {
@@ -44,18 +45,20 @@ export namespace TagProps {
4445
};
4546

4647
export type AsAnchor = {
48+
as?: "a";
4749
linkProps: RegisteredLinkProps;
4850
onClick?: never;
4951
nativeButtonProps?: never;
50-
/** @deprecated Tag is now <p> by default. Use `nativeParagraphProps` instead. */
52+
/** @deprecated Tag is now `<p>` by default. Use `nativeParagraphProps` instead. */
5153
nativeSpanProps?: never;
5254
nativeParagraphProps?: never;
5355
dismissible?: never;
5456
pressed?: never;
5557
};
5658
export type AsButton = {
59+
as?: "button";
5760
linkProps?: never;
58-
/** @deprecated Tag is now <p> by default. Use `nativeParagraphProps` instead. */
61+
/** @deprecated Tag is now `<p>` by default. Use `nativeParagraphProps` instead. */
5962
nativeSpanProps?: never;
6063
nativeParagraphProps?: never;
6164
/** Default: false */
@@ -65,104 +68,118 @@ export namespace TagProps {
6568
nativeButtonProps?: ComponentProps<"button"> & DataAttribute;
6669
};
6770
export type AsParagraph = {
71+
as?: "p";
6872
linkProps?: never;
6973
onClick?: never;
7074
dismissible?: never;
7175
pressed?: never;
7276
nativeButtonProps?: never;
73-
/** @deprecated Tag is now <p> by default. Use `nativeParagraphProps` instead. */
77+
/** @deprecated Tag is now `<p>` by default. Use `nativeParagraphProps` instead. */
7478
nativeSpanProps?: ComponentProps<"span"> & DataAttribute;
7579
nativeParagraphProps?: ComponentProps<"p"> & DataAttribute;
7680
};
77-
78-
/** @deprecated Tag is now <p> by default. Use `AsParagraph` instead. */
79-
export type AsSpan = AsParagraph;
81+
export type AsSpan = {
82+
as: "span";
83+
linkProps?: never;
84+
onClick?: never;
85+
dismissible?: never;
86+
pressed?: never;
87+
nativeButtonProps?: never;
88+
nativeSpanProps?: ComponentProps<"span"> & DataAttribute;
89+
nativeParagraphProps?: never;
90+
};
8091
}
8192

8293
/** @see <https://components.react-dsfr.codegouv.studio/?path=/docs/components-tag> */
8394
export const Tag = memo(
84-
forwardRef<HTMLButtonElement | HTMLAnchorElement | HTMLParagraphElement, TagProps>(
85-
(props, ref) => {
86-
const {
87-
id: id_props,
88-
className: prop_className,
89-
children,
90-
title,
91-
iconId,
92-
small = false,
93-
pressed,
94-
dismissible = false,
95-
linkProps,
96-
nativeButtonProps,
97-
nativeParagraphProps,
98-
nativeSpanProps,
99-
style,
100-
onClick,
101-
...rest
102-
} = props;
95+
forwardRef<
96+
HTMLButtonElement | HTMLAnchorElement | HTMLParagraphElement | HTMLSpanElement,
97+
TagProps
98+
>((props, ref) => {
99+
const {
100+
id: id_props,
101+
className: prop_className,
102+
children,
103+
title,
104+
iconId,
105+
small = false,
106+
pressed,
107+
dismissible = false,
108+
linkProps,
109+
nativeButtonProps,
110+
nativeParagraphProps,
111+
nativeSpanProps,
112+
style,
113+
onClick,
114+
as: AsTag = "p",
115+
...rest
116+
} = props;
103117

104-
assert<Equals<keyof typeof rest, never>>();
118+
assert<Equals<keyof typeof rest, never>>();
105119

106-
const id = useAnalyticsId({
107-
"defaultIdPrefix": "fr-tag",
108-
"explicitlyProvidedId": id_props
109-
});
120+
const id = useAnalyticsId({
121+
"defaultIdPrefix": "fr-tag",
122+
"explicitlyProvidedId": id_props
123+
});
110124

111-
const { Link } = getLink();
125+
const { Link } = getLink();
112126

113-
const className = cx(
114-
fr.cx(
115-
"fr-tag",
116-
small && `fr-tag--sm`,
117-
iconId,
118-
iconId && "fr-tag--icon-left", // actually, it's always left but we need it in order to have the icon rendering
119-
dismissible && "fr-tag--dismiss"
120-
),
121-
linkProps !== undefined && linkProps.className,
122-
prop_className
123-
);
127+
const className = cx(
128+
fr.cx(
129+
"fr-tag",
130+
small && `fr-tag--sm`,
131+
iconId,
132+
iconId && "fr-tag--icon-left", // actually, it's always left but we need it in order to have the icon rendering
133+
dismissible && "fr-tag--dismiss"
134+
),
135+
linkProps !== undefined && linkProps.className,
136+
prop_className
137+
);
124138

125-
const nativeParagraphOrSpanProps = nativeParagraphProps ?? nativeSpanProps;
139+
// to support old usage
140+
const nativeParagraphOrSpanProps = nativeParagraphProps ?? nativeSpanProps;
126141

127-
return (
128-
<>
129-
{linkProps !== undefined && (
130-
<Link
131-
{...linkProps}
132-
id={id_props ?? linkProps.id ?? id}
133-
title={title ?? linkProps.title}
134-
className={cx(linkProps?.className, className)}
135-
style={{
136-
...linkProps?.style,
137-
...style
138-
}}
139-
ref={ref as React.ForwardedRef<HTMLAnchorElement>}
140-
{...rest}
141-
>
142-
{children}
143-
</Link>
144-
)}
145-
{nativeButtonProps !== undefined && (
146-
<button
147-
{...nativeButtonProps}
148-
id={id_props ?? nativeButtonProps.id ?? id}
149-
className={cx(nativeButtonProps?.className, className)}
150-
style={{
151-
...nativeButtonProps?.style,
152-
...style
153-
}}
154-
title={title ?? nativeButtonProps?.title}
155-
onClick={onClick ?? nativeButtonProps?.onClick}
156-
disabled={nativeButtonProps?.disabled}
157-
ref={ref as React.ForwardedRef<HTMLButtonElement>}
158-
aria-pressed={pressed}
159-
{...rest}
160-
>
161-
{children}
162-
</button>
163-
)}
164-
{linkProps === undefined && nativeButtonProps === undefined && (
165-
<p
142+
return (
143+
<>
144+
{linkProps !== undefined && (
145+
<Link
146+
{...linkProps}
147+
id={id_props ?? linkProps.id ?? id}
148+
title={title ?? linkProps.title}
149+
className={cx(linkProps?.className, className)}
150+
style={{
151+
...linkProps?.style,
152+
...style
153+
}}
154+
ref={ref as React.ForwardedRef<HTMLAnchorElement>}
155+
{...rest}
156+
>
157+
{children}
158+
</Link>
159+
)}
160+
{nativeButtonProps !== undefined && (
161+
<button
162+
{...nativeButtonProps}
163+
id={id_props ?? nativeButtonProps.id ?? id}
164+
className={cx(nativeButtonProps?.className, className)}
165+
style={{
166+
...nativeButtonProps?.style,
167+
...style
168+
}}
169+
title={title ?? nativeButtonProps?.title}
170+
onClick={onClick ?? nativeButtonProps?.onClick}
171+
disabled={nativeButtonProps?.disabled}
172+
ref={ref as React.ForwardedRef<HTMLButtonElement>}
173+
aria-pressed={pressed}
174+
{...rest}
175+
>
176+
{children}
177+
</button>
178+
)}
179+
{linkProps === undefined &&
180+
nativeButtonProps === undefined &&
181+
(AsTag === "span" || AsTag === "p") && (
182+
<AsTag
166183
{...nativeParagraphOrSpanProps}
167184
id={id_props ?? nativeParagraphOrSpanProps?.id ?? id}
168185
className={cx(nativeParagraphOrSpanProps?.className, className)}
@@ -175,12 +192,11 @@ export const Tag = memo(
175192
{...rest}
176193
>
177194
{children}
178-
</p>
195+
</AsTag>
179196
)}
180-
</>
181-
);
182-
}
183-
)
197+
</>
198+
);
199+
})
184200
) as MemoExoticComponent<
185201
ForwardRefExoticComponent<
186202
TagProps.Common &
@@ -189,6 +205,7 @@ export const Tag = memo(
189205
| (TagProps.AsAnchor & RefAttributes<HTMLAnchorElement>)
190206
| (TagProps.AsButton & RefAttributes<HTMLButtonElement>)
191207
| (TagProps.AsParagraph & RefAttributes<HTMLParagraphElement>)
208+
| (TagProps.AsSpan & RefAttributes<HTMLParagraphElement>)
192209
)
193210
>
194211
>;

stories/Tag.stories.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,19 @@ const { meta, getStory } = getStoryFactory({
3535
Example: \`{ "aria-controls": "fr-modal-1", onMouseEnter: event => {...} }\``,
3636
"control": { "type": null }
3737
},
38+
39+
"as": {
40+
"options": (() => {
41+
const options = ["p", "span", "button", "a", undefined] as const;
42+
43+
assert<Equals<typeof options[number], TagProps["as"]>>();
44+
45+
return options;
46+
})(),
47+
"control": { type: "select", labels: { null: "default p element" } },
48+
"description":
49+
"You can specify a 'span' element instead of default 'p' if the badge is inside a `<p>`. 'button' and 'a' are implicit."
50+
},
3851
"children": {
3952
"description": "The label of the button",
4053
"control": { "type": "string" }
@@ -107,3 +120,8 @@ export const TagPressed = getStory({
107120
onClick: () => console.log("click")
108121
}
109122
});
123+
124+
export const AsSpan = getStory({
125+
"children": "Label button",
126+
as: "span"
127+
});

0 commit comments

Comments
 (0)