No results found

Composition

Composition

What is Composability?

Composability means building complex UIs from simple, reusable pieces that work well together. In QDS, components are designed to be composed rather than configured through numerous props.

For example, instead of a monolithic component with many configuration options, QDS provides component parts that you can combine:

<Tooltip.Root>
  <Tooltip.Trigger>Hover me</Tooltip.Trigger>
  <Tooltip.Content>I appear on hover!</Tooltip.Content>
</Tooltip.Root>

One Component, One Markup Element

A key principle in QDS is that each component typically corresponds to ONE piece of markup. With few exceptions, components are responsible for rendering and controlling a single element in the DOM.

export const CheckboxTrigger = component$((props) => {
  return (
    <button {...props}>
      <Slot />
    </button>
  );
});

This principle:

By following these composition principles, QDS aims to provide a flexible foundation that can be adapted to any design system needs while maintaining accessibility and performance.

Composability means building complex UIs from simple, reusable pieces that work well together.

For example, instead of a single <Tooltip> component with many props, you get:

<Tooltip.Root>
  <Tooltip.Trigger>Hover me</Tooltip.Trigger>
  <Tooltip.Content>I appear on hover!</Tooltip.Content>
</Tooltip.Root>

This approach gives you more control and flexibility.

Without composability, you face "prop armageddon":

<Tooltip
  content="I appear on hover!"
  triggerText="Hover me"
  triggerProps={{ className: "btn", disabled: false }}
  triggerClass="text-blue"
  contentBackgroundColor="#333"
  contentClass="p-2 rounded"
  position="top"
  arrow={true}
  arrowSize={8}
  delay={200}
  // ...and 20 more props
/>

This pattern is ok for those consuming the library, but not for those building it.

Composing by abstraction

The purpose of these primitives is for consumers to combine the pieces of the component, so you can focus on building your app.

import { Tooltip } from "@kunai-consulting/qwik";

export const HelpTip = component$(({ trigger }) => {
  return (
    <Tooltip.Root>
      <Tooltip.Trigger>
        {icon}
      </Tooltip.Trigger>
      <Tooltip.Content class="help-bubble">
        <Slot />
      </Tooltip.Content>
    </Tooltip.Root>
  );
});

export default component$(() => {
  return (
      <form>
        <label>
          Username
          <HelpTip icon={<QuestionCircleIcon />}>
            Choose a username between 3-20 characters
          </HelpTip>
        </label>
        <input name="username" />
      </form>
  )
})

Composing with your own elements

With the asChild prop, consumers can even replace our default elements with their own JSX or components:

<Tooltip.Root>
  <Tooltip.Trigger asChild>
    <button class="my-custom-button"> 
      Hover me <Icon name="info" />
    </button>
  </Tooltip.Trigger>
  <Tooltip.Content>I appear on hover!</Tooltip.Content>
</Tooltip.Root>

For more on composability, see this article.

This lets you maintain your component structure while still getting all the built-in behavior. The next section covers asChild in more detail.

What is asChild?

The asChild prop enables component polymorphism - the ability to change a component's underlying HTML element while preserving its functionality. This pattern allows developers to:

For example, instead of this:

<Button>
  <Link href="/about">About</Link>
</Button>

You can write this:

<Button asChild>
  <Link href="/about">About</Link>
</Button>

Implementing asChild in Your Components

To enable asChild functionality in your components, use the Render component. It handles all the complexity of merging props and element types automatically.

Step 1: Import the Render Component

import { Render } from "../render/render";

Step 2: Use Render in Your Component

export const TooltipTrigger = component$((props) => {
  const triggerRef = useSignal<HTMLElement>();
  
  return (
    <Render
      fallback="button"  // Default element type
      internalRef={triggerRef}  // Your component's ref
      {...props}
    >
      <Slot />
    </Render>
  );
});

Key Props

Consumer Setup

Consumers need the Vite plugin in their project for asChild to work:

// vite.config.ts
import { asChild } from '@kunai-consulting/core/vite';

export default defineConfig({
  plugins: [
    qwikVite(),
    asChild(),
  ],
});

Why Use the Render Component?

Accessibility Considerations

When using asChild, accessibility responsibility shifts partially to the consumer. Remember:

⚠️ Important: When allowing element substitution, ensure:

For example, if replacing a button with a div:

<Button asChild>
  <div 
    tabIndex={0} 
    onKeyDown$={(e) => e.key === 'Enter' && handleClick()}
  >
    Click me
  </div>
</Button>