23
loading...
This website collects cookies to deliver better user experience
<button />
.<button />
and style it as a link. But can we do that?<button />
element would be fine, just drop in display: inline;
and good to go, right?<button />
will never respect your display: inline;
no matter how much !important
you throw at it. It won't budge: it'll always be a minimum of display: inline-block;
. That's a bummer. Shouldn't CSS have control over everything?display: inline;
. To me it is enough to know that it just doesn't work. And because our use case is a link that should wrap just like all the other text, well, <button />
just simply can't meet that criteria.<span />
element? It is a possibility. However I think it is easier to actually make use of the anchor element since this means you can handle the issue in your normal link styles! This kind of means "zero styles" necessary for a custom element, no need for className
etc./* Note: we support `<a role="button" />` */
a {
/* Provide your link color to anchor element. */
color: royalblue;
/* Links have a pointer cursor. */
cursor: pointer;
/* Links probably should always have underline. */
text-decoration: underline;
}
normalize.css
or equivalent place where you handle default styles. It makes sense since ideally we'll be using the native anchor element directly in our code, not a component that renders an anchor.<a onClick={() => {}} />
and call it a day, right?tabIndex
!<a onClick={onClick} tabIndex={0}>Looks like a link!</a>
<button />
functionality. Links navigate when you press the enter key. Buttons do their action with enter. However buttons also do their action when you press the space key! And in this case we have an anchor element which reacts to neither, because anchor is not a link.onKeyDown
handler.function onKeyDown(event: React.KeyboardEvent<any>) {
if (event.isDefaultPrevented() || !(event.target instanceof HTMLElement)) return
if ([' ', 'Enter'].includes(event.key)) {
event.target.click()
event.preventDefault()
}
}
<a onClick={onClick} onKeyDown={onKeyDown} tabIndex={0}>
Looks like a link!
</a>
event.preventDefault()
has been called by someone before this handler executes. It makes sense since we're implementing default behavior. We are reimplementing how the web works so we also should behave similarly. So while it might be a rather edge case it is one potential future bug less when we respect the world of DOM, and give an option to skip the behavior.if
condition is to "make TypeScript happy".<a
draggable={false}
onClick={onClick}
onDragStart={(event: React.DragEvent) => event.preventDefault()}
onKeyDown={onKeyDown}
tabIndex={0}
>
Looks like a link!
</a>
draggable
but it might be a safer bet to have it to let everything absolutely know that we don't want dragging on this element.<a
draggable={false}
onClick={onClick}
onDragStart={(event: React.DragEvent) => event.preventDefault()}
onKeyDown={onKeyDown}
role="button"
tabIndex={0}
>
Looks like a link!
</a>
TextLinkButton
or something! However there is one gotcha with components: it hides the semantics of what we're doing. People also expect components to contain their own styles, but in this case we want to rely on default or generic styles. So by making this a component we break one ideal, or an expectation other developers might have.// buttonRoleProps.ts
function onKeyDown(event: React.KeyboardEvent<any>) {
if (event.isDefaultPrevented() || !(event.target instanceof HTMLElement)) return
if ([' ', 'Enter'].includes(event.key)) {
event.target.click()
event.preventDefault()
}
}
function preventDefault(event: any) {
event.preventDefault()
}
/** Usage: `<a {...buttonRoleProps} />` */
export const buttonRoleProps: React.HTMLProps<any> = {
draggable: false,
onDragStart: preventDefault,
onKeyDown,
role: 'button',
tabIndex: 0,
}
onKeyDown
you have to re-implement the space and enter key support. However I feel like this is becoming such a niche case of a niche case that it is just better add documentation to the utility like "remember to handle space and enter keys if you use custom onKeyDown
handler" rather than solving the issue.return (
<>This is text <a {...buttonRoleProps} onClick={onClick}>that has button looking like a link</a> within!</>
)
// buttonize.ts
import { buttonRoleProps } from './buttonRoleProps'
const cache = new WeakMap()
const buttonize = (
props?: JSX.Element | React.HTMLProps<any> | null | false
): JSX.Element | React.HTMLProps<any> => {
if (!props) return buttonRoleProps
if ('onKeyDown' in props && typeof props.onKeyDown === 'function') {
const { onKeyDown } = props
// having this memoize might also be overkill...
if (!cache.has(onKeyDown)) {
cache.set(onKeyDown, (event) => {
onKeyDown(event)
buttonRoleProps.onKeyDown(event)
})
}
return { ...buttonRoleProps, ...props, onKeyDown: cache.get(onKeyDown) }
}
if (React.isValidElement(props)) {
return React.cloneElement(props, buttonize(props.props))
}
return { ...buttonRoleProps, ...props }
}
// use as props:
<div>
<a {...buttonize({ onClick, onKeyDown })}>I can have focus</a>
</div>
// pass in element:
<div>
{buttonize(<a onClick={onClick} onKeyDown={onKeyDown}>I can have focus</a>)}
</div>
// compared to (here onKeyDown would also have to handle enter & space):
<div>
<a {...buttonRoleProps} onClick={onClick} onKeyDown={onKeyDown}>I can have focus</a>
</div>
<button />
, always! It is a truly awesome little piece of code. Even if you probably have to write <button type="button" />
way too often, because not every button is a submit button!user-select: none;
. This is how typical buttons behave regarding text selection. So why didn't I bring it up earlier? Because after thinking about it we're dealing with a text link. You are supposed to be able to select the text, and should not disable it here. The button made here looks like it is part of the text so selection is an expected behavior.