I am trying to write a React component for HTML heading tags (h1, h2, h3, etc.), where the heading level is specified via a prop.
I tried to do it like this:
<h{this.props.level}>Hello</h{this.props.level}>
And I expected output like:
<h1>Hello</h1>
But this is not working.
Is there any way to do this?
No way to do that in-place, just put it in a variable (with first letter capitalised):
const CustomTag = `h${this.props.level}`;
<CustomTag>Hello</CustomTag>
If you're using TypeScript, you'll have seen an error like this:
Type '{ children: string; }' has no properties in common with type 'IntrinsicAttributes'.ts(2559)
TypeScript does not know that CustomTag is a valid HTML tag name and throws an unhelpful error.
To fix, cast CustomTag as keyof JSX.IntrinsicElements!
// var name must start with a capital letter
const CustomTag = `h${this.props.level}` as keyof JSX.IntrinsicElements;
<CustomTag>Hello</CustomTag>
For completeness, if you want to use a dynamic name, you can also directly call React.createElement instead of using JSX:
React.createElement(`h${this.props.level}`, null, 'Hello')
This avoids having to create a new variable or component.
With props:
React.createElement(
`h${this.props.level}`,
{
foo: 'bar',
},
'Hello'
)
From the docs:
Create and return a new React element of the given type. The type argument can be either a tag name string (such as 'div' or 'span'), or a React component type (a class or a function).
Code written with JSX will be converted to use React.createElement(). You will not typically invoke React.createElement() directly if you are using JSX. See React Without JSX to learn more.
All the other answers are working fine but I would add some extra, because by doing this:
It is a bit safer. Even if your type-checking is failing you still
return a proper component.
It is more declarative. Anybody by looking at this component can see what it could return.
Its is more flexible for example instead of 'h1', 'h2', ... for type of your Heading you can have some other abstract concepts 'sm', 'lg' or 'primary', 'secondary'
The Heading component:
import React from 'react';
const elements = {
h1: 'h1',
h2: 'h2',
h3: 'h3',
h4: 'h4',
h5: 'h5',
h6: 'h6',
};
function Heading({ type, children, ...props }) {
return React.createElement(
elements[type] || elements.h1,
props,
children
);
}
Heading.defaultProps = {
type: 'h1',
};
export default Heading;
Which you can use it like
<Heading type="h1">Some Heading</Heading>
or you can have a different abstract concept, for example you can define a size props like:
import React from 'react';
const elements = {
xl: 'h1',
lg: 'h2',
rg: 'h3',
sm: 'h4',
xs: 'h5',
xxs: 'h6',
};
function Heading({ size, children }) {
return React.createElement(
elements[size] || elements.rg,
props,
children
);
}
Heading.defaultProps = {
size: 'rg',
};
export default Heading;
Which you can use it like
<Heading size="sm">Some Heading</Heading>
In the instance of dynamic headings (h1, h2...), a component could return React.createElement (mentioned above by Felix) like so.
const Heading = ({level, children, ...props}) => {
return React.createElement('h'.concat(level), props , children)
}
For composability, both props and children are passed.
See Example
This is how I set it up for my project.
TypographyType.ts
import { HTMLAttributes } from 'react';
export type TagType = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'p' | 'span';
export type HeadingType = HTMLAttributes<HTMLHeadingElement>;
export type ParagraphType = HTMLAttributes<HTMLParagraphElement>;
export type SpanType = HTMLAttributes<HTMLSpanElement>;
export type TypographyProps = (HeadingType | ParagraphType | SpanType) & {
variant?:
| 'h1'
| 'h2'
| 'h3'
| 'h4'
| 'h5'
| 'h6'
| 'body1'
| 'body2'
| 'subtitle1'
| 'subtitle2'
| 'caption'
| 'overline'
| 'button';
};
Typography.tsx
import { FC } from 'react';
import cn from 'classnames';
import { typography } from '#/theme';
import { TagType, TypographyProps } from './TypographyType';
const headings = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'];
const paragraphs = ['body1', 'body2', 'subtitle1', 'subtitle2'];
const spans = ['button', 'caption', 'overline'];
const Typography: FC<TypographyProps> = ({
children,
variant = 'body1',
className,
...props
}) => {
const { variants } = typography;
const Tag = cn({
[`${variant}`]: headings.includes(variant),
[`p`]: paragraphs.includes(variant),
[`span`]: spans.includes(variant)
}) as TagType;
return (
<Tag
{...props}
className={cn(
{
[`${variants[variant]}`]: variant,
},
className
)}
>
{children}
</Tag>
);
};
export default Typography;
You can give this a try. I implement like this.
import { memo, ReactNode } from "react";
import cx from "classnames";
import classes from "./Title.module.scss";
export interface TitleProps {
children?: ReactNode;
className?: string;
text?: string;
variant: Sizes;
}
type Sizes = "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
const Title = ({
className,
variant = "h1",
text,
children,
}: TitleProps): JSX.Element => {
const Tag = `${variant}` as keyof JSX.IntrinsicElements;
return (
<Tag
className={cx(`${classes.title} ${classes[variant]}`, {
[`${className}`]: className,
})}
>
{text || children}
</Tag>
);
};
export default memo(Title);
Generalising robstarbuck's answer you can create a completely dynamic tag component like this:
const Tag = ({ tagName, children, ...props }) => (
React.createElement(tagName, props , children)
)
which you can use like:
const App = ({ myTagName = 'h1' }) => {
return (
<Tag tagName={myTagName} className="foo">
Hello Tag!
</Tag>
)
}
//for Typescript
interface ComponentProps {
containerTag: keyof JSX.IntrinsicElements;
}
export const Component = ({ containerTag: CustomTag }: ComponentProps) => {
return <CustomTag>Hello</CustomTag>;
}
Related
I am writing a story in Storybook for the Stencil component. Seems like all props work as expected if they are all lowercase. However, if the prop is camelCase, it is not working. I tried both camelCase and 'dash-case' .nothing works.
Stencil component:
import {Component, Prop, h} from '#stencil/core';
#Component({
tag: 'text-input',
shadow: true,
})
export class TextInput {
#Prop() label: string;
#Prop() inputId: string;
render() {
return (
<div class="text-input">
<label htmlFor={this.inputId}>{this.label}</label>
<input type="text" id={this.inputId} />
</div>
)
}
}
Storybook story:
export default {
title: 'Components/Text Input',
argTypes: {
label: {control: 'text'},
inputid : {control: 'text'},
}
};
const Template = (args) => {
let tag = `<text-input label="${args.label}" inputId="${args.inputid}"></text-input>`;
return tag
};
export const Default = Template.bind({});
Default.args = {
label: 'First name:',
inputid: 'Input1'
};
inputId is the one that doesn't work while label is fine. Renaming inputId="${args.inputid}" to input-id="${args.inputid}"is not helping. It only works if I rename the Stencil prop to the all-lowercase inputid.
How to solve this issue? How can I keep Stencil props camelCase and make it work in Storybook?
Might be something to do with the casing of your argTypes and Default args, i.e. "inputid" is all lowercase. Maybe try the below?
export default {
title: 'Components/Text Input',
argTypes: {
label: {control: 'text'},
inputId : {control: 'text'},
}
};
const Template = (args) => {
let tag = `<text-input label="${args.label}" inputId="${args.inputId}"></text-input>`;
return tag
};
export const Default = Template.bind({});
Default.args = {
label: 'First name:',
inputId: 'Input1'
};
I would also try and create your template without string concatenation. EG:
const Template = ({ label, inputId }) => {
const element = document.createElement('text-input');
element.label = label;
element.inputId = inputId;
return rootEl;
};
This will make it easier when you pass in params of another type than string
I'm trying to create a utility component for displaying typography and it needs to be semantic (not just a div). I want to limit the choices for the tags to be either an h1, h2, h3, or p tag only.
I've tried the following code, where if body1 or body2 is passed, it will be mapped to a <p> tag:
type Props = {
children: string;
variant?: 'h1' | 'h2' | 'h3' | 'body1' | 'body2';
}
export default function Typography({children, variant}: Props) {
if (variant === 'body1' || variant === 'body2') return <p>{children}</p>
const Tag = variant || 'p';
return <Tag>{children}</Tag>
}
But when I use this component, it does not allow me to pass the underlying html attributes such as className etc. So I read about polymorphism in typescript and has seen this code in one of the tutorials:
import { ComponentProps, ElementType, ReactNode } from "react";
type TextOwnProps<E extends ElementType> = {
size?: "sm" | "md" | "lg";
color?: "primary" | "secondary";
children: ReactNode;
as?: E;
};
type TextProps<E extends ElementType> = TextOwnProps<E> & Omit<ComponentProps<E>, keyof TextOwnProps<E>>;
export const Text = <E extends ElementType = "div">({
size,
color,
children,
as,
}: TextProps<E>) => {
const Component = as || "div";
return <Component className={`class-with-${size}-${color}`}>{children}</Component>;
};
This makes it possible to pass html attributes (and removes conflict from custom props). However, I do not know how to limit this to only the values I want. The as prop displays all valid html elements.
I was trying to implement a Typography react component.
As you can see below, I got variant as an input prop and used it as index of VariantsMap object to get corresponding html tag name.
Then I used styled-components 'as' polymorphic prop to render it as selected html tag.
but I keep get this error :
No overload matches this call. Overload 1 of 2, '(props: Omit<Omit<Pick<DetailedHTMLProps<HTMLAttributes<HTMLSpanElement>, HTMLSpanElement>, "key" | keyof HTMLAttributes<...>> & { ...; } & { ...; }, never> & Partial<...>, "theme"> & { ...; } & { ...; }): ReactElement<...>', gave the following error. Type 'string' is not assignable to type 'undefined'.
I found in #types/styled-component that 'as' props can be 'never | undefined', and my variantsMap returns string type.
But I really want to use this 'as' prop with my variant-specific html tag selection feature.
Is there any way to solve this problem?
const variantMap = {
h1: 'h1',
h2: 'h2',
h3: 'h3',
h4: 'h4',
h5: 'h5',
h6: 'h6',
subheading1: 'h6',
subheading2: 'h6',
body1: 'p',
body2: 'p',
};
export const Typography = ({ variant : string }) => {
const selectedComponent = variantMap[variant];
return (<TypographyRoot
as={selectedComponent}
variant={variant}
{...props}
>
{children}
</TypographyRoot>);
}
First of all, export const Typography = ({ variant : string }) => {} is invalid syntax.
You just changed the name of destructured variant to string. You did not provide a type.
The reason you have an error even with valid string type like here export const Typography = ({ variant }:{variant: string}) => {} is that variantMap expects as a key h1 | 'h2' |'h3' ... keys whereas string is much wider.
I'd willing to bet that you don't want to assign foo string to variant property.
IN order to fix it, you just need to make variantMap immutable and apply appropriate constraint to variantMap:
import React from 'react'
import styled from "styled-components";
const Div = styled.div`
color: red;
`;
const VariantMap = {
h1: 'h1',
h2: 'h2',
h3: 'h3',
h4: 'h4',
h5: 'h5',
h6: 'h6',
subheading1: 'h6',
subheading2: 'h6',
body1: 'p',
body2: 'p',
} as const;
type Props = {
variant: keyof typeof VariantMap
}
export const Typography = ({ variant }: Props) => {
const selectedComponent = VariantMap[variant];
return <Div
as={selectedComponent}
/>
}
Now styled-component is happy.
Playground
Making the map immutable saved me. Can you explain why not making the map immutable makes Typescript complain?
Without as const all values of VariantMap are infered as a string instead of literals "h1", "h2". Which in turn affects selectedComponent, since this const represents a value of VariantMap. I mean selectedComponent becomes just a regular string instead of string literal, whereas as property expects strict string literal h1, h2, h3, ...
I am building a library of components and I need some of them to have a customizable tag name. For example, sometimes what looks like a <button> is actually a <a>. So I would like to be able to use the button component like so:
<Button onClick={onClick}>Click me!</Button>
<Button as="a" href="/some-url">Click me!</Button>
Ideally, I would like the available props to be inferred based on the "as" prop:
// Throws an error because the default value of "as" is "button",
// which doesn't accept the "href" attribute.
<Button href="/some-url">Click me!<Button>
We might need to pass a custom component as well:
// Doesn't throw an error because RouterLink has a "to" prop
<Button as={RouterLink} to="/">Click me!</Button>
Here's the implementation, without TypeScript:
function Button({ as = "button", children, ...props }) {
return React.createElement(as, props, children);
}
So, how can I implement a "as" prop with TypeScript while passing down the props?
Note: I am basically trying to do what styled-components does. But we are using CSS modules and SCSS so I can't afford adding styled-components. I am open to simpler alternatives, though.
New answer
I recently came across Iskander Samatov's article React polymorphic components with TypeScript in which they share a more complete and simpler solution:
import * as React from "react";
interface ButtonProps<T extends React.ElementType> {
as?: T;
children?: React.ReactNode;
}
function Button<T extends React.ElementType = "button">({
as,
...props
}:
ButtonProps<T>
& Omit<React.ComponentPropsWithoutRef<T>, keyof ButtonProps<T>>
) {
const Component = as || "button";
return <Component {...props} />;
}
Typescript playground
Old answer
I spent some time digging into styled-components' types declarations. I was able to extract the minimum required code, here it is:
import * as React from "react";
import { Link } from "react-router-dom";
type CustomComponentProps<
C extends keyof JSX.IntrinsicElements | React.ComponentType<any>,
O extends object
> = React.ComponentPropsWithRef<
C extends keyof JSX.IntrinsicElements | React.ComponentType<any> ? C : never
> &
O & { as?: C };
interface CustomComponent<
C extends keyof JSX.IntrinsicElements | React.ComponentType<any>,
O extends object
> {
<AsC extends keyof JSX.IntrinsicElements | React.ComponentType<any> = C>(
props: CustomComponentProps<AsC, O>
): React.ReactElement<CustomComponentProps<AsC, O>>;
}
const Button: CustomComponent<"button", { variant: "primary" }> = (props) => (
<button {...props} />
);
<Button variant="primary">Test</Button>;
<Button variant="primary" to="/test">
Test
</Button>;
<Button variant="primary" as={Link} to="/test">
Test
</Button>;
<Button variant="primary" as={Link}>
Test
</Button>;
TypeScript playground
I removed a lot of stuff from styled-components which is way more complex than that. For example, they have some workaround to deal with class components which I removed. So this snippet might need to be customized for advanced use cases.
I found that you can make the same thing with JSX.IntrinsicElements. I have Panel element:
export type PanelAsKeys = 'div' | 'label'
export type PanelAsKey = Extract<keyof JSX.IntrinsicElements, PanelAsKeys>
export type PanelAs<T extends PanelAsKey> = JSX.IntrinsicElements[T]
export type PanelAsProps<T extends PanelAsKey> = Omit<PanelAs<T>, 'className' | 'ref'>
I omitted native types like ref and className because i have my own types for these fields
And this how props will look look like
export type PanelProps<T extends PanelAsKey = 'div'> = PanelAsProps<T> & {}
const Panel = <T extends PanelAsKey = 'div'>(props: PanelProps<T>) => {}
Then you can use React.createElement
return React.createElement(
as || 'div',
{
tabIndex: tabIndex || -1,
onClick: handleClick,
onFocus: handleFocus,
onBlur: handleBlur,
onMouseEnter: handleMouseEnter,
onMouseLeave: handleMouseLeave,
'data-testid': 'panel',
...rest
},
renderChildren)
I see no ts errors in this case and have completions for label props like htmlFor
I am trying to use Typography component from material-ui with TypeScript, but I am getting this weird error
TypeScript error: Type 'string' is not assignable to type 'ComponentClass<HTMLAttributes<HTMLElement>, any> | FunctionComponent<HTMLAttributes<HTMLElement>> | undefined'. TS2322
8 | }
9 | export default ({ text, date }: Props) => (
> 10 | <Typography component="p" gutterBottom>
| ^
11 | {text && <span>{text}: </span>}
12 | <FormattedDate value={date} />
13 |
Here's how my component looks like
import React from 'react';
import { FormattedDate, FormattedTime } from 'react-intl';
import Typography from '#material-ui/core/Typography';
interface Props {
date: Date;
text?: string;
}
export default ({ text, date }: Props) => (
<Typography component="p" gutterBottom>
{text && <span>{text}: </span>}
<FormattedDate value={date} />
<FormattedTime value={date} />
</Typography>
);
I am not able to understand why "p" is not an acceptable value for component prop. I tried it with "h1" and "h2" which fails in the same way and apparently, the official demo also uses the string.
Is there anything I am missing?, I don't want to ignore this with // #ts-ignore, but want to fix this.
Had this in 2021, the problem was that I was spreading HTMLAttributes of an input against InputBase, while I should have properly spreaded InputBaseProps instead.
In my case it was related to an input, but same can be replicated for every component: as long as you use a material UI component, you should provide the properties it asks and properly set the correct props types if you use/extend them.
Example that gives error:
import { HTMLAttributes } from 'react';
import InputBase from '#material-ui/core/InputBase';
import inputStyle from 'Input.module.scss';
export interface InputProps extends HTMLAttributes<HTMLInputElement> {
}
export function Input(props: InputProps) {
const { ...rest } = props;
return (
<InputBase
fullWidth={true}
className={[inputStyle.input].join(' ')}
color='primary'
{...rest}
/>
);
}
(in this ccase, an error was raised on color)
Proper way to do this:
import InputBase, { InputBaseProps } from '#material-ui/core/InputBase';
import inputStyle from 'Input.module.scss';
export interface InputProps extends InputBaseProps {
}
export function Input(props: InputProps) {
const { ...rest } = props;
return (
<InputBase
fullWidth={true}
className={[inputStyle.input].join(' ')}
color='primary'
{...rest}
/>
);
}
As per reference document provided by Material UI for Typography
(https://material-ui.com/api/typography/)
{ h1: 'h1', h2: 'h2', h3: 'h3', h4: 'h4', h5: 'h5', h6: 'h6', subtitle1: 'h6', subtitle2: 'h6', body1: 'p', body2: 'p',}
variantMapping has these mapping so going forward if you want to use <p> tags you can use variant type as body1 or body2 instead of component prop.
I have this since I updated to material-ui/core#4.6.0 and typescript#3.7.2.
I managed to silence the errors by using component={'div' as any} so I'm posting this as a temporary answer, but I do think there must be a better fix coming up.