diff --git a/package.json b/package.json index 2ab91bd..c742785 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@schemavaults/ui", - "version": "0.76.0", + "version": "0.77.0", "private": false, "license": "UNLICENSED", "description": "React.js UI components for SchemaVaults frontend applications", diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts index faf4957..b4ca33d 100644 --- a/src/components/ui/index.ts +++ b/src/components/ui/index.ts @@ -123,6 +123,9 @@ export type * from "./description-list"; export * from "./status-blinker"; export type * from "./status-blinker"; +export * from "./presence-indicator"; +export type * from "./presence-indicator"; + export * from "./context-menu"; export type * from "./context-menu"; diff --git a/src/components/ui/presence-indicator/PresenceIndicator.stories.tsx b/src/components/ui/presence-indicator/PresenceIndicator.stories.tsx new file mode 100644 index 0000000..265d7c6 --- /dev/null +++ b/src/components/ui/presence-indicator/PresenceIndicator.stories.tsx @@ -0,0 +1,204 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import type { ReactElement } from "react"; + +import { Avatar, AvatarFallback } from "../avatar/avatar"; +import { + PresenceIndicator, + presenceSizeIds, + presenceStatusIds, + type PresenceStatusId, +} from "./presence-indicator"; + +const meta = { + title: "Components/PresenceIndicator", + component: PresenceIndicator, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + argTypes: { + status: { + options: presenceStatusIds, + control: { type: "radio" }, + table: { + type: { + summary: "PresenceStatusId", + detail: presenceStatusIds.map((s) => `'${s}'`).join(" | "), + }, + }, + }, + size: { + options: presenceSizeIds, + control: { type: "radio" }, + table: { + type: { + summary: "PresenceSizeId", + detail: presenceSizeIds.map((s) => `'${s}'`).join(" | "), + }, + }, + }, + pulse: { control: { type: "boolean" } }, + bordered: { control: { type: "boolean" } }, + label: { control: { type: "text" } }, + }, + args: { + status: "online", + size: "md", + pulse: false, + bordered: false, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Pulsing: Story = { + args: { + status: "online", + pulse: true, + }, +}; + +export const WithLabel: Story = { + args: { + status: "online", + label: true, + }, +}; + +export const CustomLabel: Story = { + args: { + status: "idle", + label: "Back in 5", + }, +}; + +function AllStatusesExample(): ReactElement { + return ( +
+ {presenceStatusIds.map((status: PresenceStatusId) => ( +
+ + {status} +
+ ))} +
+ ); +} + +export const AllStatuses: Story = { + render: (): ReactElement => , +}; + +function AllSizesExample(): ReactElement { + return ( +
+ {presenceSizeIds.map((size) => ( +
+ + {size} +
+ ))} +
+ ); +} + +export const Sizes: Story = { + render: (): ReactElement => , +}; + +function WithLabelsExample(): ReactElement { + return ( +
+ {presenceStatusIds.map((status: PresenceStatusId) => ( + + ))} +
+ ); +} + +export const StatusesWithLabels: Story = { + render: (): ReactElement => , +}; + +function OnAvatarExample(): ReactElement { + const users: { initials: string; color: string; status: PresenceStatusId }[] = [ + { initials: "AB", color: "bg-primary text-primary-foreground", status: "online" }, + { initials: "CD", color: "bg-destructive text-white", status: "busy" }, + { initials: "EF", color: "bg-secondary text-secondary-foreground", status: "idle" }, + { initials: "GH", color: "bg-accent text-accent-foreground", status: "away" }, + { initials: "IJ", color: "bg-muted text-muted-foreground", status: "dnd" }, + { initials: "KL", color: "bg-muted text-muted-foreground", status: "offline" }, + ]; + + return ( +
+ {users.map((user) => ( +
+ + + {user.initials} + + + + + {user.status} + +
+ ))} +
+ ); +} + +export const OnAvatar: Story = { + render: (): ReactElement => , +}; + +function PulsingOnlineRosterExample(): ReactElement { + const teammates: { name: string; initials: string; status: PresenceStatusId }[] = [ + { name: "Ada Lovelace", initials: "AL", status: "online" }, + { name: "Grace Hopper", initials: "GH", status: "online" }, + { name: "Linus Torvalds", initials: "LT", status: "idle" }, + { name: "Margaret Hamilton", initials: "MH", status: "busy" }, + { name: "Edsger Dijkstra", initials: "ED", status: "offline" }, + ]; + + return ( +
    + {teammates.map((teammate) => ( +
  • + + + + {teammate.initials} + + + + + {teammate.name} + +
  • + ))} +
+ ); +} + +export const TeamRoster: Story = { + render: (): ReactElement => , +}; diff --git a/src/components/ui/presence-indicator/index.ts b/src/components/ui/presence-indicator/index.ts new file mode 100644 index 0000000..c6d3aad --- /dev/null +++ b/src/components/ui/presence-indicator/index.ts @@ -0,0 +1,11 @@ +export { + PresenceIndicator, + PresenceIndicator as default, + presenceStatusIds, + presenceSizeIds, +} from "./presence-indicator"; +export type { + PresenceIndicatorProps, + PresenceStatusId, + PresenceSizeId, +} from "./presence-indicator"; diff --git a/src/components/ui/presence-indicator/presence-indicator.tsx b/src/components/ui/presence-indicator/presence-indicator.tsx new file mode 100644 index 0000000..37e7e63 --- /dev/null +++ b/src/components/ui/presence-indicator/presence-indicator.tsx @@ -0,0 +1,238 @@ +"use client"; + +import type { HTMLAttributes, ReactElement, Ref } from "react"; + +import { cn } from "@/lib/utils"; + +export const presenceStatusIds = [ + "online", + "idle", + "busy", + "away", + "dnd", + "offline", + "invisible", +] as const satisfies readonly string[]; + +export type PresenceStatusId = (typeof presenceStatusIds)[number]; + +export const presenceSizeIds = [ + "xs", + "sm", + "md", + "lg", + "xl", +] as const satisfies readonly string[]; + +export type PresenceSizeId = (typeof presenceSizeIds)[number]; + +const dotColorClasses = { + online: "bg-green-500", + idle: "bg-yellow-400", + busy: "bg-orange-500", + away: "bg-amber-500", + dnd: "bg-destructive", + offline: "bg-muted-foreground/50", + invisible: "bg-transparent border border-muted-foreground/60", +} satisfies Record; + +const dotSizeClasses = { + xs: "h-1.5 w-1.5", + sm: "h-2 w-2", + md: "h-2.5 w-2.5", + lg: "h-3 w-3", + xl: "h-3.5 w-3.5", +} satisfies Record; + +const dndBarSizeClasses = { + xs: "h-px w-[60%]", + sm: "h-px w-[60%]", + md: "h-px w-[60%]", + lg: "h-[1.5px] w-[60%]", + xl: "h-[1.5px] w-[60%]", +} satisfies Record; + +const ringSizeClasses = { + xs: "ring-[1.5px]", + sm: "ring-2", + md: "ring-2", + lg: "ring-2", + xl: "ring-[2.5px]", +} satisfies Record; + +const labelTextClasses = { + xs: "text-[10px]", + sm: "text-xs", + md: "text-xs", + lg: "text-sm", + xl: "text-sm", +} satisfies Record; + +const statusLabels = { + online: "Online", + idle: "Idle", + busy: "Busy", + away: "Away", + dnd: "Do not disturb", + offline: "Offline", + invisible: "Invisible", +} satisfies Record; + +const pulseColorClasses = { + online: "bg-green-500", + idle: "bg-yellow-400", + busy: "bg-orange-500", + away: "bg-amber-500", + dnd: "bg-destructive", + offline: "bg-muted-foreground/50", + invisible: "bg-muted-foreground/40", +} satisfies Record; + +export interface PresenceIndicatorProps + extends Omit, "children" | "aria-label"> { + /** Semantic presence status. Defaults to `"online"`. */ + status?: PresenceStatusId; + /** Visual size of the dot. Defaults to `"md"`. */ + size?: PresenceSizeId; + /** Render a soft `ping` animation around the dot. */ + pulse?: boolean; + /** + * Render a contrasting ring around the dot. Useful when overlaying the + * indicator on top of an `Avatar` or coloured surface. + */ + bordered?: boolean; + /** + * Render a text label next to the dot. Pass `true` to use the default + * status label (e.g. `"Online"`), or a string for a custom one. + */ + label?: boolean | string; + /** + * Accessible label describing the status. Defaults to a humanised version of + * the `status` (e.g. `"Online"`). Set to `null` to mark the indicator as + * purely decorative (e.g. when paired with adjacent text that already + * conveys the status). + */ + "aria-label"?: string | null; + ref?: Ref; +} + +function PresenceDot({ + status, + size, + pulse, + bordered, +}: { + status: PresenceStatusId; + size: PresenceSizeId; + pulse: boolean; + bordered: boolean; +}): ReactElement { + return ( + + {pulse ? ( +