Einride Frontend Style Guide serves as a base style guide.
In addition, here follows a few project specific styling conventions.
Both controlled and uncontrolled API should be supported whenever applicable
As an example, make sure value
and onChange
props are optional rather than required for input
components and also support defaultValue
.
As an example, let gap
prop auto-suggest officially supported values such as "md"
and "lg"
,
while keeping possibility to pass a custom number if needed.
Wrap component in forwardRef()
whenever applicable.
It makes it possible to use libraries such as framer-motion
in user-land and enables passing a
ref
for any other reason.
// bad
const Component = ({ ...props }: ComponentProps): React.JSX.Element => {
return <div {...props} />
}
// good
const Component = forwardRef<HTMLDivElement, ComponentProps>(
({ ...props }, ref): React.JSX.Element => {
return <div {...props} ref={ref} />
},
)
Putting the most important things at the top of the file makes the contract of the component fast to discover.
// bad
const helper = () => {...}
const constant1 = ...
const constant2 = ...
const SubComponent = () => {}
interface ComponentProps {...}
const Component = () => {...}
// good
interface ComponentProps {...}
const Component = () => {...}
const helper = () => {...}
const constant1 = ...
const constant2 = ...
const SubComponent = () => {...}
Automatic titles ensure hierarchy is the same in code and Storybook, which improves discoverability.
// ./src/components/typography/Text/Text.stories.tsx
// bad
const meta = {
title: "Typography/Text", // explicit title, might cause mismatch compared to file structure
component: Text,
} satisfies Meta<typeof Text>
// good
const meta = {
// implicit title, uses same structure as code
component: Text,
} satisfies Meta<typeof Text>
const meta = {
component: Text,
} satisfies Meta<typeof Text>
export default meta
type Story = StoryObj<typeof meta>
export const MyStory = {} satisfies Story
satisfies
gives benefits related to
improved typing accuracy.
Using StoryObj<typeof meta>
makes it possible to combine type information from metadata and
individual story.
// bad
export const Primary = {
render: () => <Text>Text</Text>,
} satisfies Story
export const Secondary = {
render: () => <Text color="secondary">Text</Text>,
} satisfies Story
// good
export const Primary = {
args: {
children: <>Text</>,
},
} satisfies Story
export const Secondary = {
args: {
...Primary.args,
color: "secondary",
},
} satisfies Story
Using args unlocks the full potential of Storybook by enabling changing args in the UI and reusing args in many stories.
Use interaction testing to ensure interactive components are possible to use both with pointer and
keyboard. Every interactive component should have one Pointer
story and one Keyboard
story with
an associated play
function that navigates the component.
// Example interaction test for pointer navigation
export const Pointer = {
args: {
children: "Button",
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement)
const button = canvas.getByRole("button", { name: Pointer.args.children })
await expect(button).not.toHaveFocus()
await userEvent.click(button)
await expect(button).toHaveFocus()
},
} satisfies Story
// Example interaction test for keyboard navigation
export const Keyboard = {
args: {
children: "Button",
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement)
const button = canvas.getByRole("button", { name: Keyboard.args.children })
await expect(button).not.toHaveFocus()
await userEvent.tab()
await expect(button).toHaveFocus()
},
} satisfies Story
Every component should have a Snapshot
story at the end of the file used for visual regression
testing.
Combining multiple stories in one Snapshot
story makes it possible to decrease
cost related to monthly amount of snapshots.
<SnapshotWrapper>
ensures both color schemes are tested without extra configuration.
export const Snapshot = {
render: () => (
<SnapshotWrapper>
{[Basic, FullWidth, IconRight, Disabled].map((Story, index) => (
// eslint-disable-next-line react/no-array-index-key
<PrimaryButton key={index} {...Story.args} />
))}
</SnapshotWrapper>
),
parameters: {
chromatic: { disableSnapshot: false },
},
} satisfies StoryObj
Prefer querying elements with getByRole
in interaction tests to other query types such as
getByTestId
and getByText
.
// bad
export const Basic = {
args: {
children: "Button",
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement)
const button = canvas.getByText("Button")
},
} satisfies Story
// good
export const Basic = {
args: {
children: "Button",
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement)
const button = canvas.getByRole("button", { name: "Button" })
},
} satisfies Story
Querying by ARIA role and accessibility name ensures certain accessibility requirements for free.