Skip to content

Latest commit

 

History

History
236 lines (181 loc) · 5.99 KB

STYLE.md

File metadata and controls

236 lines (181 loc) · 5.99 KB

Style

Einride Frontend Style Guide serves as a base style guide.

In addition, here follows a few project specific styling conventions.

Components

Support both controlled and uncontrolled API

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.

Make it easy to pass the right thing while keeping possibility to override

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.

Forward refs

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} />
  },
)

Place component interface and function first in each component file

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 = () => {...}

Stories

Rely on automatic titles whenever possible

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>

Use satisfies to type metadata and stories

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.

Prefer arg-style stories to changing args in render function

// 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.

Test pointer and keyboard interaction

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

Make one Snapshot story for each component

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

Query by accessibility role and name

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.