No results found

Feed

A digital timeline that shows recent events or actions in a list, usually with the newest items at the top, helping users quickly see what's been happening.

Feed is a block that depends on the Avatar component.

Activity
Misko Hevery Profile
MH
Misko created the issue • 1 day ago
Jack Shelton Profile
Jack changed status from In Progress to Todo • 1 day ago
Jack added label • Release
Misko Hevery Profile
MH
Misko1 day ago
LGTM - Approved

Installation

In the future:

Run qwik-design add feed

Manual Install:

  1. Add tailwind with pnpm qwik add tailwind
  2. Install cva, clsx, and tailwind-merge
npm i class-variance-authority clsx tailwind-merge
  1. add the cn utility in src/utils/cn.ts
import { type ClassValue, clsx } from "clsx";
import { extendTailwindMerge } from "tailwind-merge";

const twMerge = extendTailwindMerge({
  extend: {},
});

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}
  1. Copy the Avatar code:
import {
  type ContextId,
  type PropsOf,
  Slot,
  component$,
  createContextId,
  useContext,
  useContextProvider,
} from "@builder.io/qwik";
import { cn } from "~/utils/cn";
import { type VariantProps, cva } from "class-variance-authority";

type RootProps = {
  status?: "online" | "offline" | "dnd";
} & PropsOf<"div"> &
  VariantProps<typeof avatarSizeVariants>;

export const avatarContextId: ContextId<AvatarContext> =
  createContextId("general-avatar");

export type AvatarContext = {
  status?: "online" | "offline" | "dnd";
  size?: VariantProps<typeof avatarSizeVariants>["size"];
};

/** Make sure to update the imageDimensions object for CLS shift. */
const avatarSizeVariants = cva(["relative aspect-square"], {
  variants: {
    size: {
      small: "size-9 min-w-9", // 36px
      medium: "size-12 min-w-12", // 48px
      large: "size-[60px] min-w-[60px]", // 60px
    },
  },
  defaultVariants: {
    size: "medium",
  },
});

const Root = component$(({ status, size = "medium", ...props }: RootProps) => {
  const context: AvatarContext = {
    status,
    size,
  };

  useContextProvider(avatarContextId, context);

  return (
    <div
      {...props}
      class={cn(
        avatarSizeVariants({ size }),
        "grid grid-cols-1 grid-rows-1 isolate",
        props.class
      )}
    >
      <Slot />
    </div>
  );
});

const Image = component$<PropsOf<"img">>(({ alt, width, height, ...props }) => {
  const context = useContext(avatarContextId);

  /** TODO: have one source of truth for these dimensions based on styles */
  const imageDimensions = {
    small: 36,
    medium: 48,
    large: 60,
  };

  return (
    <img
      {...props}
      alt={alt}
      width={width ? width : imageDimensions[context.size || "medium"]}
      height={height ? height : imageDimensions[context.size || "medium"]}
      class={cn(
        avatarSizeVariants({ size: context.size }),
        "rounded-full",
        props.class
      )}
    />
  );
});

const Fallback = component$<PropsOf<"div">>(({ ...props }) => {
  const context = useContext(avatarContextId);

  return (
    <div
      {...props}
      class={cn(
        avatarSizeVariants({ size: context.size }),
        "bg-slate-500 rounded-full flex items-center justify-center text-white -z-10",
        props.class
      )}
    >
      <Slot />
    </div>
  );
});

const Status = component$<PropsOf<"div">>(({ ...props }) => {
  const context = useContext(avatarContextId);

  return (
    <div
      {...props}
      class={cn(
        `absolute bottom-0 right-0 h-3 w-3 rounded-full border-2 border-slate-950 ${
          context.status === "online" && "bg-green-400"
        } ${context.status === "offline" && "bg-gray-500"} ${
          context.status === "dnd" && "bg-red-400"
        }`,
        props.class
      )}
    />
  );
});

export const Avatar = {
  Root,
  Image,
  Fallback,
  Content: Fallback,
  Status,
  avatarSizeVariants,
};
  1. Copy the Feed block:
import { type PropsOf, Slot, component$ } from "@builder.io/qwik";
import { cn } from "~/utils/cn";

type RootProps = PropsOf<"div">;

const Root = component$((props: RootProps) => {
  return (
    <div
      {...props}
      class={cn(
        "flex flex-col gap-4 bg-slate-950 p-4 text-white selection:bg-slate-700",
        props.class
      )}
    >
      <Slot />
    </div>
  );
});

const Item = component$((props: PropsOf<"div">) => {
  return (
    <div {...props} class="flex items-center gap-4 px-4">
      <Slot />
    </div>
  );
});

const Dynamic = component$((props: PropsOf<"span">) => {
  return (
    <span {...props} class={cn("text-white", props.class)}>
      <Slot />
    </span>
  );
});

const Text = component$((props: PropsOf<"span">) => {
  return (
    <span {...props} class={cn("text-gray-500", props.class)}>
      <Slot />
    </span>
  );
});

export const Feed = {
  Root,
  Item,
  Dynamic,
  Text,
};
  1. If you want the icons install @qwikest/icons, otherwise remove the icon in the code example below:
npm i -D @qwikest/icons
  1. Copy the consumer code:
import { component$, Slot } from "@builder.io/qwik";
import { Avatar, type AvatarContext } from "~/components/avatar/avatar";
import { Feed } from "~/components/feed/feed";
import { LuTag } from "@qwikest/icons/lucide";

const ticketData = {
  users: ["Misko", "Jack"],
  currStatus: "Todo",
  prevStatus: "In Progress",
  timeAgo: "1 day ago",
  labels: ["Release", "Bug"],
  comments: ["LGTM - Approved"],
};

const { users, currStatus, prevStatus, timeAgo, labels, comments } = ticketData;

export default component$(() => {
  const history = [
    <FeedAvatar
      src="https://github.com/mhevery.png"
      key="history"
      alt="Misko Hevery Profile"
      status="offline"
    >
      <Avatar.Fallback>MH</Avatar.Fallback>
    </FeedAvatar>,
    <Feed.Text key="history">
      <Feed.Dynamic>{users[0]}</Feed.Dynamic> created the issue • {timeAgo}
    </Feed.Text>,
  ];

  const status = [
    <FeedAvatar
      status="online"
      src="https://i.imgur.com/rVyLbyc.png"
      key="status"
      alt="Jack Shelton Profile"
    />,
    <Feed.Text key="status">
      <Feed.Dynamic>{users[1]}</Feed.Dynamic> changed status from
      <Feed.Dynamic> {prevStatus}</Feed.Dynamic> to
      <Feed.Dynamic> {currStatus}</Feed.Dynamic>{timeAgo}
    </Feed.Text>,
  ];

  const label = [
    <FeedAvatar key="label">
      <Avatar.Content class="bg-transparent">
        <LuTag />
      </Avatar.Content>
    </FeedAvatar>,
    <Feed.Text key="label">
      <Feed.Dynamic>{users[1]}</Feed.Dynamic> added label
      <Feed.Dynamic>{labels[0]}</Feed.Dynamic>
    </Feed.Text>,
  ];

  const activityTypes = [history, status, label];

  return (
    <Feed.Root>
      <span class="text-2xl">Activity</span>
      {activityTypes.map((activity) => (
        <Feed.Item key={activity.toString()}>{activity}</Feed.Item>
      ))}
      <Comments />
    </Feed.Root>
  );
});

type FeedAvatarProps = AvatarContext & {
  src?: string;
  alt?: string;
};

const FeedAvatar = component$(
  ({ status, size = "small", src, alt, ...props }: FeedAvatarProps) => {
    return (
      <Avatar.Root status={status} size={size} {...props}>
        <Avatar.Image hidden={!src} src={src} alt={alt} />
        {status && <Avatar.Status />}
        <Slot />
      </Avatar.Root>
    );
  }
);

const Comments = component$(() => {
  return (
    <>
      {/* Pretend this maps over a list of comments */}
      <div class="min-h-20 rounded-md border border-slate-800 bg-slate-900 p-4">
        <div class="mb-4 flex items-center gap-4">
          <FeedAvatar
            src="https://github.com/mhevery.png"
            alt="Misko Hevery Profile"
            status="offline"
          >
            <Avatar.Fallback>MH</Avatar.Fallback>
          </FeedAvatar>
          <div class="flex gap-2">
            <span>{users[0]}</span>
            <Feed.Text>{timeAgo}</Feed.Text>
          </div>
        </div>
        <div>{comments[0]}</div>
      </div>

      <textarea
        class="min-h-20 w-full resize-none rounded-md border border-slate-800 bg-slate-900 p-4 outline-none placeholder:text-gray-600"
        placeholder="Leave a comment..."
      />
    </>
  );
});