svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function AlertDescription({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+export { Alert, AlertTitle, AlertDescription, AlertAction }
diff --git a/packages/ui/src/components/anchored-surface.tsx b/packages/ui/src/components/anchored-surface.tsx
new file mode 100644
index 0000000..ef78602
--- /dev/null
+++ b/packages/ui/src/components/anchored-surface.tsx
@@ -0,0 +1,923 @@
+"use client"
+
+import * as React from "react"
+import { useRender } from "@base-ui/react/use-render"
+import { useMergedRefs } from "@base-ui/utils/useMergedRefs"
+
+import { cn } from "@workspace/ui/lib/utils"
+
+const QUARTER_CIRCLE_BEZIER = 0.5522847498
+
+type NormalizedTailPoint = readonly [
+ edgeProgress: number,
+ outwardProgress: number,
+]
+
+interface TailBezierSegment {
+ control1: NormalizedTailPoint
+ control2: NormalizedTailPoint
+ end: NormalizedTailPoint
+}
+
+const SEQUOIA_TAIL_START_POINT = [0.102584, 0] as const
+const SEQUOIA_TAIL_SEGMENTS: readonly TailBezierSegment[] = [
+ {
+ control1: [0.104017, 0],
+ control2: [0.205677, 0.00175],
+ end: [0.282041, 0.252625],
+ },
+ {
+ control1: [0.342923, 0.4525],
+ control2: [0.371691, 0.573375],
+ end: [0.406448, 0.719375],
+ },
+ {
+ control1: [0.415623, 0.757875],
+ control2: [0.425181, 0.798125],
+ end: [0.435885, 0.842125],
+ },
+ {
+ control1: [0.461531, 0.947375],
+ control2: [0.480766, 1],
+ end: [0.5, 1],
+ },
+ {
+ control1: [0.519234, 1],
+ control2: [0.538469, 0.947375],
+ end: [0.564115, 0.842125],
+ },
+ {
+ control1: [0.574819, 0.798125],
+ control2: [0.584377, 0.757875],
+ end: [0.593552, 0.719375],
+ },
+ {
+ control1: [0.628309, 0.573375],
+ control2: [0.657077, 0.4525],
+ end: [0.717959, 0.252625],
+ },
+ {
+ control1: [0.794323, 0.00175],
+ control2: [0.895983, 0],
+ end: [0.897416, 0],
+ },
+]
+
+interface ElementDimensions {
+ width: number
+ height: number
+}
+
+export type AnchoredSurfaceRadiusPreset =
+ "xs" | "sm" | "md" | "lg" | "xl" | "2xl" | "3xl" | "4xl"
+
+export type AnchoredSurfaceCornerRadius = number | AnchoredSurfaceRadiusPreset
+
+export interface AnchoredSurfaceCornerRadii {
+ bottomLeft: AnchoredSurfaceCornerRadius
+ bottomRight: AnchoredSurfaceCornerRadius
+ topLeft: AnchoredSurfaceCornerRadius
+ topRight: AnchoredSurfaceCornerRadius
+}
+
+export type AnchoredSurfaceSide = "top" | "right" | "bottom" | "left"
+export type AnchoredSurfaceTailSize = "sm" | "md" | "lg"
+
+interface PixelCornerRadii {
+ bottomLeft: number
+ bottomRight: number
+ topLeft: number
+ topRight: number
+}
+
+const RADIUS_PRESETS: Record
= {
+ xs: 2,
+ sm: 6,
+ md: 8,
+ lg: 10,
+ xl: 14,
+ "2xl": 18,
+ "3xl": 22,
+ "4xl": 26,
+}
+
+const TAIL_SIZE_PRESETS = {
+ sm: { baseWidth: 32, depth: 8 },
+ md: { baseWidth: 40, depth: 10 },
+ lg: { baseWidth: 48, depth: 12 },
+} as const satisfies Record<
+ AnchoredSurfaceTailSize,
+ { baseWidth: number; depth: number }
+>
+
+const OPPOSITE_SIDE: Record = {
+ top: "bottom",
+ right: "left",
+ bottom: "top",
+ left: "right",
+}
+
+function resolveRadius(radius: AnchoredSurfaceCornerRadius) {
+ return typeof radius === "number"
+ ? Math.max(0, radius)
+ : RADIUS_PRESETS[radius]
+}
+
+function resolveCornerRadii(
+ radius: AnchoredSurfaceCornerRadius | AnchoredSurfaceCornerRadii
+): PixelCornerRadii {
+ if (typeof radius === "object") {
+ return {
+ bottomLeft: resolveRadius(radius.bottomLeft),
+ bottomRight: resolveRadius(radius.bottomRight),
+ topLeft: resolveRadius(radius.topLeft),
+ topRight: resolveRadius(radius.topRight),
+ }
+ }
+
+ const value = resolveRadius(radius)
+
+ return {
+ bottomLeft: value,
+ bottomRight: value,
+ topLeft: value,
+ topRight: value,
+ }
+}
+
+export function getAnchoredSurfaceSideOffset({
+ anchor,
+}: {
+ anchor: ElementDimensions
+}) {
+ return Math.min(anchor.width, anchor.height) / 2
+}
+
+interface CreatePopoverPathOptions {
+ borderWidth: number
+ cornerRadii: PixelCornerRadii
+ tailBaseWidth: number
+ tailCenterOffset?: number
+ tailDepth: number
+ tailPosition: number
+ tailSide: AnchoredSurfaceSide
+}
+
+interface ResolvedTailPlacement {
+ centerOffset: number
+ side: AnchoredSurfaceSide
+}
+
+interface AnchoredSurfaceArrowContextValue {
+ arrowBoxSize: number
+ setArrowElement: (element: HTMLDivElement | null) => void
+}
+
+const AnchoredSurfaceArrowContext =
+ React.createContext(null)
+
+function normalizeCornerRadii(
+ width: number,
+ height: number,
+ radii: PixelCornerRadii
+) {
+ const top = radii.topLeft + radii.topRight
+ const right = radii.topRight + radii.bottomRight
+ const bottom = radii.bottomLeft + radii.bottomRight
+ const left = radii.topLeft + radii.bottomLeft
+ const scale = Math.min(
+ 1,
+ top > 0 ? width / top : 1,
+ right > 0 ? height / right : 1,
+ bottom > 0 ? width / bottom : 1,
+ left > 0 ? height / left : 1
+ )
+
+ return {
+ bottomLeft: radii.bottomLeft * scale,
+ bottomRight: radii.bottomRight * scale,
+ topLeft: radii.topLeft * scale,
+ topRight: radii.topRight * scale,
+ }
+}
+
+function fitCornerPair(first: number, second: number, available: number) {
+ const sum = first + second
+ const scale = sum > available && sum > 0 ? available / sum : 1
+
+ return [first * scale, second * scale] as const
+}
+
+function resolveSurfaceCornerRadii(
+ width: number,
+ height: number,
+ {
+ borderWidth,
+ cornerRadii,
+ tailBaseWidth,
+ tailDepth,
+ tailSide,
+ }: Pick<
+ CreatePopoverPathOptions,
+ "borderWidth" | "cornerRadii" | "tailBaseWidth" | "tailDepth" | "tailSide"
+ >
+) {
+ const radii = normalizeCornerRadii(
+ Math.max(0, width - borderWidth),
+ Math.max(0, height - borderWidth),
+ cornerRadii
+ )
+
+ if (tailDepth <= 0 || tailBaseWidth <= 0) {
+ return radii
+ }
+
+ if (tailSide === "top") {
+ ;[radii.topLeft, radii.topRight] = fitCornerPair(
+ radii.topLeft,
+ radii.topRight,
+ Math.max(0, width - borderWidth - tailBaseWidth)
+ )
+ } else if (tailSide === "right") {
+ ;[radii.topRight, radii.bottomRight] = fitCornerPair(
+ radii.topRight,
+ radii.bottomRight,
+ Math.max(0, height - borderWidth - tailBaseWidth)
+ )
+ } else if (tailSide === "bottom") {
+ ;[radii.bottomLeft, radii.bottomRight] = fitCornerPair(
+ radii.bottomLeft,
+ radii.bottomRight,
+ Math.max(0, width - borderWidth - tailBaseWidth)
+ )
+ } else {
+ ;[radii.topLeft, radii.bottomLeft] = fitCornerPair(
+ radii.topLeft,
+ radii.bottomLeft,
+ Math.max(0, height - borderWidth - tailBaseWidth)
+ )
+ }
+
+ return radii
+}
+
+function createPopoverPath(
+ width: number,
+ height: number,
+ {
+ borderWidth,
+ cornerRadii,
+ tailBaseWidth,
+ tailCenterOffset,
+ tailDepth,
+ tailPosition,
+ tailSide,
+ }: CreatePopoverPathOptions
+) {
+ const inset = borderWidth / 2
+ const bodyOffsetX = tailSide === "left" ? tailDepth : 0
+ const bodyOffsetY = tailSide === "top" ? tailDepth : 0
+ const bodyLeft = bodyOffsetX + inset
+ const bodyRight = bodyOffsetX + width - inset
+ const bodyTop = bodyOffsetY + inset
+ const bodyBottom = bodyOffsetY + height - inset
+ const relativeTailPosition = Math.min(1, Math.max(0, tailPosition))
+ const radii = resolveSurfaceCornerRadii(width, height, {
+ borderWidth,
+ cornerRadii,
+ tailBaseWidth,
+ tailDepth,
+ tailSide,
+ })
+
+ const resolveTailMetrics = (
+ edgeStart: number,
+ edgeEnd: number,
+ bodyOffset: number
+ ) => {
+ const halfBaseWidth = Math.min(
+ tailBaseWidth / 2,
+ Math.max(0, (edgeEnd - edgeStart) / 2)
+ )
+ const minimumCenter = edgeStart + halfBaseWidth
+ const maximumCenter = edgeEnd - halfBaseWidth
+ const preferredCenter =
+ tailCenterOffset === undefined
+ ? minimumCenter +
+ Math.max(0, maximumCenter - minimumCenter) * relativeTailPosition
+ : bodyOffset + tailCenterOffset
+ const centerOffset = Math.min(
+ maximumCenter,
+ Math.max(minimumCenter, preferredCenter)
+ )
+
+ return { centerOffset, halfBaseWidth }
+ }
+
+ const hasTail = tailDepth > 0 && tailBaseWidth > 0
+ const createTailEdge = (
+ edgeSide: AnchoredSurfaceSide,
+ {
+ centerOffset,
+ halfBaseWidth,
+ }: { centerOffset: number; halfBaseWidth: number },
+ reverse: boolean
+ ) => {
+ const mapPoint = ([edgeProgress, outwardProgress]: NormalizedTailPoint) => {
+ const edgePosition =
+ centerOffset - halfBaseWidth + edgeProgress * halfBaseWidth * 2
+
+ if (edgeSide === "top") {
+ return [edgePosition, bodyTop - outwardProgress * tailDepth] as const
+ }
+
+ if (edgeSide === "right") {
+ return [bodyRight + outwardProgress * tailDepth, edgePosition] as const
+ }
+
+ if (edgeSide === "bottom") {
+ return [edgePosition, bodyBottom + outwardProgress * tailDepth] as const
+ }
+
+ return [bodyLeft - outwardProgress * tailDepth, edgePosition] as const
+ }
+ const lineToEdgePosition = (point: NormalizedTailPoint) => {
+ const [x, y] = mapPoint(point)
+
+ return edgeSide === "top" || edgeSide === "bottom" ? `H ${x}` : `V ${y}`
+ }
+ const cubicTo = (
+ control1: NormalizedTailPoint,
+ control2: NormalizedTailPoint,
+ end: NormalizedTailPoint
+ ) => {
+ const [control1X, control1Y] = mapPoint(control1)
+ const [control2X, control2Y] = mapPoint(control2)
+ const [endX, endY] = mapPoint(end)
+
+ return `C ${control1X} ${control1Y} ${control2X} ${control2Y} ${endX} ${endY}`
+ }
+ const commands: string[] = []
+
+ if (reverse) {
+ commands.push(
+ lineToEdgePosition([1, 0]),
+ lineToEdgePosition(SEQUOIA_TAIL_SEGMENTS.at(-1)!.end)
+ )
+
+ for (
+ let index = SEQUOIA_TAIL_SEGMENTS.length - 1;
+ index >= 0;
+ index -= 1
+ ) {
+ const curve = SEQUOIA_TAIL_SEGMENTS[index]
+ const curveStart =
+ index === 0
+ ? SEQUOIA_TAIL_START_POINT
+ : SEQUOIA_TAIL_SEGMENTS[index - 1].end
+
+ commands.push(cubicTo(curve.control2, curve.control1, curveStart))
+ }
+
+ commands.push(lineToEdgePosition([0, 0]))
+ return commands
+ }
+
+ commands.push(
+ lineToEdgePosition([0, 0]),
+ lineToEdgePosition(SEQUOIA_TAIL_START_POINT)
+ )
+
+ for (const curve of SEQUOIA_TAIL_SEGMENTS) {
+ commands.push(cubicTo(curve.control1, curve.control2, curve.end))
+ }
+
+ commands.push(lineToEdgePosition([1, 0]))
+ return commands
+ }
+
+ let topEdge: string[] = []
+ let rightEdge: string[] = []
+ let bottomEdge: string[] = []
+ let leftEdge: string[] = []
+
+ if (hasTail) {
+ if (tailSide === "top") {
+ topEdge = createTailEdge(
+ "top",
+ resolveTailMetrics(
+ bodyLeft + radii.topLeft,
+ bodyRight - radii.topRight,
+ bodyOffsetX
+ ),
+ false
+ )
+ } else if (tailSide === "right") {
+ rightEdge = createTailEdge(
+ "right",
+ resolveTailMetrics(
+ bodyTop + radii.topRight,
+ bodyBottom - radii.bottomRight,
+ bodyOffsetY
+ ),
+ false
+ )
+ } else if (tailSide === "bottom") {
+ bottomEdge = createTailEdge(
+ "bottom",
+ resolveTailMetrics(
+ bodyLeft + radii.bottomLeft,
+ bodyRight - radii.bottomRight,
+ bodyOffsetX
+ ),
+ true
+ )
+ } else {
+ leftEdge = createTailEdge(
+ "left",
+ resolveTailMetrics(
+ bodyTop + radii.topLeft,
+ bodyBottom - radii.bottomLeft,
+ bodyOffsetY
+ ),
+ true
+ )
+ }
+ }
+
+ return [
+ `M ${bodyLeft + radii.topLeft} ${bodyTop}`,
+ ...topEdge,
+ `H ${bodyRight - radii.topRight}`,
+ `C ${bodyRight - radii.topRight + radii.topRight * QUARTER_CIRCLE_BEZIER} ${bodyTop}`,
+ `${bodyRight} ${bodyTop + radii.topRight - radii.topRight * QUARTER_CIRCLE_BEZIER}`,
+ `${bodyRight} ${bodyTop + radii.topRight}`,
+ ...rightEdge,
+ `V ${bodyBottom - radii.bottomRight}`,
+ `C ${bodyRight} ${bodyBottom - radii.bottomRight + radii.bottomRight * QUARTER_CIRCLE_BEZIER}`,
+ `${bodyRight - radii.bottomRight + radii.bottomRight * QUARTER_CIRCLE_BEZIER} ${bodyBottom}`,
+ `${bodyRight - radii.bottomRight} ${bodyBottom}`,
+ ...bottomEdge,
+ `H ${bodyLeft + radii.bottomLeft}`,
+ `C ${bodyLeft + radii.bottomLeft - radii.bottomLeft * QUARTER_CIRCLE_BEZIER} ${bodyBottom}`,
+ `${bodyLeft} ${bodyBottom - radii.bottomLeft + radii.bottomLeft * QUARTER_CIRCLE_BEZIER}`,
+ `${bodyLeft} ${bodyBottom - radii.bottomLeft}`,
+ ...leftEdge,
+ `V ${bodyTop + radii.topLeft}`,
+ `C ${bodyLeft} ${bodyTop + radii.topLeft - radii.topLeft * QUARTER_CIRCLE_BEZIER}`,
+ `${bodyLeft + radii.topLeft - radii.topLeft * QUARTER_CIRCLE_BEZIER} ${bodyTop}`,
+ `${bodyLeft + radii.topLeft} ${bodyTop}`,
+ "Z",
+ ].join(" ")
+}
+
+function useElementDimensions() {
+ const [element, setElement] = React.useState(null)
+ const [dimensions, setDimensions] = React.useState({
+ width: 0,
+ height: 0,
+ })
+ const ref = React.useCallback((node: T | null) => {
+ setElement(node)
+ }, [])
+
+ React.useLayoutEffect(() => {
+ if (!element) {
+ return
+ }
+
+ const updateDimensions = (width: number, height: number) => {
+ setDimensions((current) => {
+ if (current.width === width && current.height === height) {
+ return current
+ }
+
+ return { width, height }
+ })
+ }
+ const measureElement = () => {
+ const { width, height } = element.getBoundingClientRect()
+
+ updateDimensions(width, height)
+ }
+
+ measureElement()
+
+ const resizeObserver = new ResizeObserver(([entry]) => {
+ const borderBoxSize = entry.borderBoxSize[0]
+
+ if (borderBoxSize) {
+ updateDimensions(borderBoxSize.inlineSize, borderBoxSize.blockSize)
+ return
+ }
+
+ measureElement()
+ })
+ resizeObserver.observe(element, { box: "border-box" })
+
+ return () => resizeObserver.disconnect()
+ }, [element])
+
+ return [ref, dimensions] as const
+}
+
+function resolvePhysicalSide(element: HTMLElement) {
+ const side = element.dataset.side
+
+ if (
+ side === "top" ||
+ side === "right" ||
+ side === "bottom" ||
+ side === "left"
+ ) {
+ return side
+ }
+
+ const isRtl = getComputedStyle(element).direction === "rtl"
+
+ if (side === "inline-start") {
+ return isRtl ? "right" : "left"
+ }
+
+ if (side === "inline-end") {
+ return isRtl ? "left" : "right"
+ }
+
+ return null
+}
+
+function getTailSide(placementSide: AnchoredSurfaceSide): AnchoredSurfaceSide {
+ return OPPOSITE_SIDE[placementSide]
+}
+
+function parsePixelOffset(value: string) {
+ const offset = Number.parseFloat(value)
+
+ return Number.isFinite(offset) ? offset : null
+}
+
+function useBaseUIArrowPlacement(
+ arrowElement: HTMLDivElement | null,
+ arrowBoxSize: number
+) {
+ const [placement, setPlacement] =
+ React.useState(null)
+
+ React.useLayoutEffect(() => {
+ if (!arrowElement) {
+ setPlacement(null)
+ return
+ }
+
+ const updatePlacement = () => {
+ const placementSide = resolvePhysicalSide(arrowElement)
+
+ if (!placementSide) {
+ setPlacement(null)
+ return
+ }
+
+ const side = getTailSide(placementSide)
+ const positionValue =
+ side === "top" || side === "bottom"
+ ? arrowElement.style.left
+ : arrowElement.style.top
+ const offset = parsePixelOffset(positionValue)
+
+ if (offset === null) {
+ setPlacement(null)
+ return
+ }
+
+ const centerOffset = offset + arrowBoxSize / 2
+
+ setPlacement((current) => {
+ if (
+ current?.side === side &&
+ Math.abs(current.centerOffset - centerOffset) < 0.01
+ ) {
+ return current
+ }
+
+ return { centerOffset, side }
+ })
+ }
+
+ updatePlacement()
+
+ const mutationObserver = new MutationObserver(updatePlacement)
+ mutationObserver.observe(arrowElement, {
+ attributeFilter: ["data-side", "style"],
+ attributes: true,
+ })
+
+ return () => mutationObserver.disconnect()
+ }, [arrowBoxSize, arrowElement])
+
+ return placement
+}
+
+/**
+ * Connects an {@link AnchoredSurface} tail to a Base UI positioning arrow.
+ * The invisible element supplies Base UI with the tail dimensions and
+ * receives its resolved position.
+ *
+ * @example Base UI Popover
+ * ```tsx
+ *
+ * }
+ * >
+ * } />
+ * Popover content
+ *
+ *
+ * ```
+ *
+ * @example Base UI Tooltip
+ * ```tsx
+ *
+ * }
+ * >
+ * } />
+ * Tooltip content
+ *
+ *
+ * ```
+ */
+export const AnchoredSurfaceArrow = React.forwardRef<
+ HTMLDivElement,
+ React.ComponentPropsWithoutRef<"div">
+>(function AnchoredSurfaceArrow({ className, style, ...props }, forwardedRef) {
+ const context = React.useContext(AnchoredSurfaceArrowContext)
+
+ if (!context) {
+ throw new Error(
+ "AnchoredSurfaceArrow must be rendered inside AnchoredSurface."
+ )
+ }
+
+ const ref = useMergedRefs(forwardedRef, context.setArrowElement)
+
+ return (
+
+ )
+})
+
+export interface AnchoredSurfaceProps extends Omit<
+ useRender.ComponentProps<"div">,
+ "ref"
+> {
+ borderWidth?: number
+ cornerRadius?: AnchoredSurfaceCornerRadius | AnchoredSurfaceCornerRadii
+ pathClassName?: string
+ shadowClassName?: string
+ side?: AnchoredSurfaceSide
+ tailPosition?: number
+ tailSize?: AnchoredSurfaceTailSize
+}
+
+/**
+ * Renders a rounded surface and its continuous pointer tail.
+ *
+ * This component only renders the visual surface. Without a positioning
+ * library, place it relative to its anchor manually.
+ *
+ * @example Standalone
+ * ```tsx
+ *
+ * Standalone content
+ *
+ * ```
+ *
+ * @example Manually positioned below an anchor
+ * ```tsx
+ *
+ *
+ *
+ *
+ * The tail is on top because the surface is below its anchor.
+ *
+ *
+ *
+ * ```
+ */
+export const AnchoredSurface = React.forwardRef<
+ HTMLDivElement,
+ AnchoredSurfaceProps
+>(function AnchoredSurface(
+ {
+ borderWidth = 1,
+ children,
+ className,
+ cornerRadius = "lg",
+ pathClassName,
+ render,
+ shadowClassName,
+ side = "bottom",
+ style,
+ tailPosition = 0.5,
+ tailSize = "md",
+ ...props
+ },
+ forwardedRef
+) {
+ const [contentRef, contentDimensions] = useElementDimensions()
+ const { baseWidth: tailBaseWidth, depth: tailDepth } =
+ TAIL_SIZE_PRESETS[tailSize]
+ const [arrowElement, setArrowElement] = React.useState(
+ null
+ )
+ const baseUIArrowPlacement = useBaseUIArrowPlacement(
+ arrowElement,
+ tailBaseWidth
+ )
+ const resolvedBorderWidth = Math.max(0, borderWidth)
+ const resolvedCornerRadii = React.useMemo(
+ () => resolveCornerRadii(cornerRadius),
+ [cornerRadius]
+ )
+ const tailSide = baseUIArrowPlacement?.side ?? side
+ const resolvedTailPosition = Math.min(1, Math.max(0, tailPosition))
+ const hasMeasuredDimensions =
+ contentDimensions.width > 0 && contentDimensions.height > 0
+ const surfaceCornerRadii = React.useMemo(
+ () =>
+ hasMeasuredDimensions
+ ? resolveSurfaceCornerRadii(
+ contentDimensions.width,
+ contentDimensions.height,
+ {
+ borderWidth: resolvedBorderWidth,
+ cornerRadii: resolvedCornerRadii,
+ tailBaseWidth,
+ tailDepth,
+ tailSide,
+ }
+ )
+ : resolvedCornerRadii,
+ [
+ contentDimensions.height,
+ contentDimensions.width,
+ hasMeasuredDimensions,
+ resolvedBorderWidth,
+ resolvedCornerRadii,
+ tailBaseWidth,
+ tailDepth,
+ tailSide,
+ ]
+ )
+ const surfaceBorderRadius = `${surfaceCornerRadii.topLeft}px ${surfaceCornerRadii.topRight}px ${surfaceCornerRadii.bottomRight}px ${surfaceCornerRadii.bottomLeft}px`
+ const path = React.useMemo(
+ () =>
+ hasMeasuredDimensions
+ ? createPopoverPath(contentDimensions.width, contentDimensions.height, {
+ borderWidth: resolvedBorderWidth,
+ cornerRadii: resolvedCornerRadii,
+ tailBaseWidth,
+ tailCenterOffset: baseUIArrowPlacement?.centerOffset,
+ tailDepth,
+ tailPosition: resolvedTailPosition,
+ tailSide,
+ })
+ : "",
+ [
+ baseUIArrowPlacement?.centerOffset,
+ contentDimensions.height,
+ contentDimensions.width,
+ hasMeasuredDimensions,
+ resolvedBorderWidth,
+ resolvedCornerRadii,
+ resolvedTailPosition,
+ tailBaseWidth,
+ tailDepth,
+ tailSide,
+ ]
+ )
+ const hasHorizontalTail = tailSide === "top" || tailSide === "bottom"
+ const svgWidth = contentDimensions.width + (hasHorizontalTail ? 0 : tailDepth)
+ const svgHeight =
+ contentDimensions.height + (hasHorizontalTail ? tailDepth : 0)
+ const svgLeft = tailSide === "left" ? -tailDepth : 0
+ const svgTop = tailSide === "top" ? -tailDepth : 0
+ const hasResolvedArrowPlacement =
+ arrowElement === null || baseUIArrowPlacement !== null
+ const renderedSvgWidth = hasMeasuredDimensions ? svgWidth : 1
+ const renderedSvgHeight = hasMeasuredDimensions ? svgHeight : 1
+ const mergedContentRef = useMergedRefs(contentRef, forwardedRef)
+ const arrowContext = React.useMemo(
+ () => ({
+ arrowBoxSize: tailBaseWidth,
+ setArrowElement,
+ }),
+ [tailBaseWidth]
+ )
+
+ const surface = useRender({
+ defaultTagName: "div",
+ ref: mergedContentRef,
+ render,
+ props: {
+ ...props,
+ className: cn("relative isolate inline-block", className),
+ style: {
+ ...style,
+ borderColor: "transparent",
+ borderRadius: surfaceBorderRadius,
+ borderStyle: "solid",
+ borderWidth: resolvedBorderWidth,
+ boxSizing: "border-box",
+ visibility: hasResolvedArrowPlacement ? undefined : "hidden",
+ },
+ children: (
+ <>
+
+ {children}
+
+ >
+ ),
+ },
+ })
+
+ return (
+
+ {surface}
+
+ )
+})
diff --git a/packages/ui/src/components/aspect-ratio.tsx b/packages/ui/src/components/aspect-ratio.tsx
new file mode 100644
index 0000000..d9a6f84
--- /dev/null
+++ b/packages/ui/src/components/aspect-ratio.tsx
@@ -0,0 +1,22 @@
+import { cn } from "@workspace/ui/lib/utils"
+
+function AspectRatio({
+ ratio,
+ className,
+ ...props
+}: React.ComponentProps<"div"> & { ratio: number }) {
+ return (
+
+ )
+}
+
+export { AspectRatio }
diff --git a/packages/ui/src/components/attachment.tsx b/packages/ui/src/components/attachment.tsx
new file mode 100644
index 0000000..9866445
--- /dev/null
+++ b/packages/ui/src/components/attachment.tsx
@@ -0,0 +1,207 @@
+import * as React from "react"
+import { mergeProps } from "@base-ui/react/merge-props"
+import { useRender } from "@base-ui/react/use-render"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@workspace/ui/lib/utils"
+import { Button } from "@workspace/ui/components/button"
+
+const attachmentVariants = cva(
+ "group/attachment relative flex w-fit max-w-full min-w-0 shrink-0 flex-wrap rounded-xl border bg-card text-card-foreground transition-colors focus-within:ring-1 focus-within:ring-ring/50 has-[>a,>button]:hover:bg-muted/50 data-[state=error]:border-destructive/30 data-[state=idle]:border-dashed",
+ {
+ variants: {
+ size: {
+ default:
+ "gap-2 text-sm has-data-[slot=attachment-content]:px-2.5 has-data-[slot=attachment-content]:py-2 has-data-[slot=attachment-media]:p-2",
+ sm: "gap-2.5 text-xs has-data-[slot=attachment-content]:px-2 has-data-[slot=attachment-content]:py-1.5 has-data-[slot=attachment-media]:p-1.5",
+ xs: "gap-1.5 rounded-lg text-xs has-data-[slot=attachment-content]:px-1.5 has-data-[slot=attachment-content]:py-1 has-data-[slot=attachment-media]:p-1",
+ },
+ orientation: {
+ horizontal: "min-w-40 items-center",
+ vertical: "w-24 flex-col has-data-[slot=attachment-content]:w-30",
+ },
+ },
+ }
+)
+
+function Attachment({
+ className,
+ state = "done",
+ size = "default",
+ orientation = "horizontal",
+ ...props
+}: React.ComponentProps<"div"> &
+ VariantProps & {
+ state?: "idle" | "uploading" | "processing" | "error" | "done"
+ }) {
+ return (
+
+ )
+}
+
+const attachmentMediaVariants = cva(
+ "relative flex aspect-square w-10 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted text-foreground group-data-[orientation=vertical]/attachment:w-full group-data-[size=sm]/attachment:w-8 group-data-[size=xs]/attachment:w-7 group-data-[size=xs]/attachment:rounded-md group-data-[state=error]/attachment:bg-destructive/10 group-data-[state=error]/attachment:text-destructive group-data-[orientation=vertical]/attachment:*:data-[slot=spinner]:size-6! [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 group-data-[orientation=vertical]/attachment:[&_svg:not([class*='size-'])]:size-6 group-data-[size=xs]/attachment:[&_svg:not([class*='size-'])]:size-3.5",
+ {
+ variants: {
+ variant: {
+ icon: "",
+ image:
+ "opacity-60 group-data-[state=done]/attachment:opacity-100 group-data-[state=idle]/attachment:opacity-100 *:[img]:aspect-square *:[img]:w-full *:[img]:object-cover",
+ },
+ },
+ defaultVariants: {
+ variant: "icon",
+ },
+ }
+)
+
+function AttachmentMedia({
+ className,
+ variant = "icon",
+ ...props
+}: React.ComponentProps<"div"> & VariantProps) {
+ return (
+
+ )
+}
+
+function AttachmentContent({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function AttachmentTitle({
+ className,
+ ...props
+}: React.ComponentProps<"span">) {
+ return (
+
+ )
+}
+
+function AttachmentDescription({
+ className,
+ ...props
+}: React.ComponentProps<"span">) {
+ return (
+
+ )
+}
+
+function AttachmentActions({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function AttachmentAction({
+ className,
+ variant,
+ size = "icon-xs",
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AttachmentTrigger({
+ className,
+ render,
+ type,
+ ...props
+}: useRender.ComponentProps<"button">) {
+ return useRender({
+ defaultTagName: "button",
+ props: mergeProps<"button">(
+ {
+ type: render ? type : (type ?? "button"),
+ className: cn("absolute inset-0 z-10 outline-none", className),
+ },
+ props
+ ),
+ render,
+ state: {
+ slot: "attachment-trigger",
+ },
+ })
+}
+
+function AttachmentGroup({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+export {
+ Attachment,
+ AttachmentGroup,
+ AttachmentMedia,
+ AttachmentContent,
+ AttachmentTitle,
+ AttachmentDescription,
+ AttachmentActions,
+ AttachmentAction,
+ AttachmentTrigger,
+}
diff --git a/packages/ui/src/components/avatar.tsx b/packages/ui/src/components/avatar.tsx
new file mode 100644
index 0000000..67a19be
--- /dev/null
+++ b/packages/ui/src/components/avatar.tsx
@@ -0,0 +1,107 @@
+import * as React from "react"
+import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"
+
+import { cn } from "@workspace/ui/lib/utils"
+
+function Avatar({
+ className,
+ size = "default",
+ ...props
+}: AvatarPrimitive.Root.Props & {
+ size?: "default" | "sm" | "lg"
+}) {
+ return (
+
+ )
+}
+
+function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
+ return (
+
+ )
+}
+
+function AvatarFallback({
+ className,
+ ...props
+}: AvatarPrimitive.Fallback.Props) {
+ return (
+
+ )
+}
+
+function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
+ return (
+ svg]:hidden",
+ "group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
+ "group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function AvatarGroupCount({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+ svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+export {
+ Avatar,
+ AvatarImage,
+ AvatarFallback,
+ AvatarGroup,
+ AvatarGroupCount,
+ AvatarBadge,
+}
diff --git a/packages/ui/src/components/badge.tsx b/packages/ui/src/components/badge.tsx
new file mode 100644
index 0000000..0ced148
--- /dev/null
+++ b/packages/ui/src/components/badge.tsx
@@ -0,0 +1,52 @@
+import { mergeProps } from "@base-ui/react/merge-props"
+import { useRender } from "@base-ui/react/use-render"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@workspace/ui/lib/utils"
+
+const badgeVariants = cva(
+ "group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
+ {
+ variants: {
+ variant: {
+ default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
+ secondary:
+ "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
+ destructive:
+ "bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
+ outline:
+ "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
+ ghost:
+ "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
+ link: "text-primary underline-offset-4 hover:underline",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
+
+function Badge({
+ className,
+ variant = "default",
+ render,
+ ...props
+}: useRender.ComponentProps<"span"> & VariantProps
) {
+ return useRender({
+ defaultTagName: "span",
+ props: mergeProps<"span">(
+ {
+ className: cn(badgeVariants({ variant }), className),
+ },
+ props
+ ),
+ render,
+ state: {
+ slot: "badge",
+ variant,
+ },
+ })
+}
+
+export { Badge, badgeVariants }
diff --git a/packages/ui/src/components/breadcrumb.tsx b/packages/ui/src/components/breadcrumb.tsx
new file mode 100644
index 0000000..8925952
--- /dev/null
+++ b/packages/ui/src/components/breadcrumb.tsx
@@ -0,0 +1,125 @@
+import * as React from "react"
+import { mergeProps } from "@base-ui/react/merge-props"
+import { useRender } from "@base-ui/react/use-render"
+
+import { cn } from "@workspace/ui/lib/utils"
+import { ChevronRightIcon, MoreHorizontalIcon } from "lucide-react"
+
+function Breadcrumb({ className, ...props }: React.ComponentProps<"nav">) {
+ return (
+
+ )
+}
+
+function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
+ return (
+
+ )
+}
+
+function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
+ return (
+
+ )
+}
+
+function BreadcrumbLink({
+ className,
+ render,
+ ...props
+}: useRender.ComponentProps<"a">) {
+ return useRender({
+ defaultTagName: "a",
+ props: mergeProps<"a">(
+ {
+ className: cn("transition-colors hover:text-foreground", className),
+ },
+ props
+ ),
+ render,
+ state: {
+ slot: "breadcrumb-link",
+ },
+ })
+}
+
+function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
+ return (
+
+ )
+}
+
+function BreadcrumbSeparator({
+ children,
+ className,
+ ...props
+}: React.ComponentProps<"li">) {
+ return (
+ svg]:size-3.5", className)}
+ {...props}
+ >
+ {children ?? (
+
+ )}
+
+ )
+}
+
+function BreadcrumbEllipsis({
+ className,
+ ...props
+}: React.ComponentProps<"span">) {
+ return (
+ svg]:size-4",
+ className
+ )}
+ {...props}
+ >
+
+ More
+
+ )
+}
+
+export {
+ Breadcrumb,
+ BreadcrumbList,
+ BreadcrumbItem,
+ BreadcrumbLink,
+ BreadcrumbPage,
+ BreadcrumbSeparator,
+ BreadcrumbEllipsis,
+}
diff --git a/packages/ui/src/components/bubble.tsx b/packages/ui/src/components/bubble.tsx
new file mode 100644
index 0000000..6be97f1
--- /dev/null
+++ b/packages/ui/src/components/bubble.tsx
@@ -0,0 +1,128 @@
+import * as React from "react"
+import { mergeProps } from "@base-ui/react/merge-props"
+import { useRender } from "@base-ui/react/use-render"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@workspace/ui/lib/utils"
+
+function BubbleGroup({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+const bubbleVariants = cva(
+ "group/bubble relative flex w-fit max-w-[80%] min-w-0 flex-col gap-1 group-data-[align=end]/message:self-end data-[align=end]:self-end data-[variant=ghost]:max-w-full",
+ {
+ variants: {
+ variant: {
+ default:
+ "*:data-[slot=bubble-content]:bg-primary *:data-[slot=bubble-content]:text-primary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-primary/80",
+ secondary:
+ "*:data-[slot=bubble-content]:bg-secondary *:data-[slot=bubble-content]:text-secondary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)]",
+ muted:
+ "*:data-[slot=bubble-content]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--muted),var(--foreground)_5%)]",
+ tinted:
+ "*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.93_calc(c*0.4)_h)] *:data-[slot=bubble-content]:text-foreground dark:*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.3_calc(c*0.4)_h)] [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.88_calc(c*0.5)_h)] dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.35_calc(c*0.5)_h)]",
+ outline:
+ "*:data-[slot=bubble-content]:border-border *:data-[slot=bubble-content]:bg-background [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-input/30",
+ ghost:
+ "border-none *:data-[slot=bubble-content]:rounded-none *:data-[slot=bubble-content]:bg-transparent *:data-[slot=bubble-content]:p-0 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted/50",
+ destructive:
+ "*:data-[slot=bubble-content]:bg-destructive/10 *:data-[slot=bubble-content]:text-destructive dark:*:data-[slot=bubble-content]:bg-destructive/20 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/20 dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/30",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
+
+function Bubble({
+ variant = "default",
+ align = "start",
+ className,
+ ...props
+}: React.ComponentProps<"div"> &
+ VariantProps & {
+ align?: "start" | "end"
+ }) {
+ return (
+
+ )
+}
+
+function BubbleContent({
+ className,
+ render,
+ ...props
+}: useRender.ComponentProps<"div">) {
+ return useRender({
+ defaultTagName: "div",
+ props: mergeProps<"div">(
+ {
+ className: cn(
+ "w-fit max-w-full min-w-0 overflow-hidden rounded-xl border border-transparent px-3 py-2 text-sm leading-relaxed wrap-break-word group-data-[align=end]/bubble:self-end [button]:text-start [button,a]:transition-colors [button,a]:outline-none [button,a]:focus-visible:border-ring [button,a]:focus-visible:ring-3 [button,a]:focus-visible:ring-ring/50",
+ className
+ ),
+ },
+ props
+ ),
+ render,
+ state: {
+ slot: "bubble-content",
+ },
+ })
+}
+
+const bubbleReactionsVariants = cva(
+ "absolute z-10 flex w-fit shrink-0 items-center justify-center gap-1 rounded-full bg-muted px-1.5 py-0.5 text-sm ring-3 ring-card has-[button]:p-0",
+ {
+ variants: {
+ side: {
+ top: "top-0 -translate-y-3/4",
+ bottom: "bottom-0 translate-y-3/4",
+ },
+ align: {
+ start: "start-3",
+ end: "end-3",
+ },
+ },
+ defaultVariants: {
+ side: "bottom",
+ align: "end",
+ },
+ }
+)
+
+function BubbleReactions({
+ side = "bottom",
+ align = "end",
+ className,
+ ...props
+}: React.ComponentProps<"div"> & {
+ align?: "start" | "end"
+ side?: "top" | "bottom"
+}) {
+ return (
+
+ )
+}
+
+export { BubbleGroup, Bubble, BubbleContent, BubbleReactions }
diff --git a/packages/ui/src/components/button-group.tsx b/packages/ui/src/components/button-group.tsx
new file mode 100644
index 0000000..4606329
--- /dev/null
+++ b/packages/ui/src/components/button-group.tsx
@@ -0,0 +1,87 @@
+import { mergeProps } from "@base-ui/react/merge-props"
+import { useRender } from "@base-ui/react/use-render"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@workspace/ui/lib/utils"
+import { Separator } from "@workspace/ui/components/separator"
+
+const buttonGroupVariants = cva(
+ "flex w-fit items-stretch *:focus-visible:relative *:focus-visible:z-10 has-[>[data-slot=button-group]]:gap-2 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-e-md [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
+ {
+ variants: {
+ orientation: {
+ horizontal:
+ "*:data-slot:rounded-e-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-e-md! [&>[data-slot]~[data-slot]]:rounded-s-none [&>[data-slot]~[data-slot]]:border-s-0",
+ vertical:
+ "flex-col *:data-slot:rounded-b-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-md! [&>[data-slot]~[data-slot]]:rounded-t-none [&>[data-slot]~[data-slot]]:border-t-0",
+ },
+ },
+ defaultVariants: {
+ orientation: "horizontal",
+ },
+ }
+)
+
+function ButtonGroup({
+ className,
+ orientation,
+ ...props
+}: React.ComponentProps<"div"> & VariantProps) {
+ return (
+
+ )
+}
+
+function ButtonGroupText({
+ className,
+ render,
+ ...props
+}: useRender.ComponentProps<"div">) {
+ return useRender({
+ defaultTagName: "div",
+ props: mergeProps<"div">(
+ {
+ className: cn(
+ "flex items-center gap-2 rounded-md border bg-muted px-2.5 text-sm font-medium shadow-xs [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
+ className
+ ),
+ },
+ props
+ ),
+ render,
+ state: {
+ slot: "button-group-text",
+ },
+ })
+}
+
+function ButtonGroupSeparator({
+ className,
+ orientation = "vertical",
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export {
+ ButtonGroup,
+ ButtonGroupSeparator,
+ ButtonGroupText,
+ buttonGroupVariants,
+}
diff --git a/packages/ui/src/components/button.tsx b/packages/ui/src/components/button.tsx
index 59a364e..d685f3e 100644
--- a/packages/ui/src/components/button.tsx
+++ b/packages/ui/src/components/button.tsx
@@ -2,9 +2,10 @@ import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@workspace/ui/lib/utils"
+import { useRippleRef } from "@workspace/ui/hooks/use-ripple"
const buttonVariants = cva(
- "group/button inline-flex shrink-0 items-center justify-center rounded-md border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+ "group/button relative inline-flex shrink-0 items-center justify-center rounded-md border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
@@ -40,19 +41,25 @@ const buttonVariants = cva(
}
)
+type ButtonProps = ButtonPrimitive.Props & VariantProps
+
function Button({
+ ref,
className,
variant = "default",
size = "default",
...props
-}: ButtonPrimitive.Props & VariantProps) {
+}: ButtonProps) {
+ const rippleRef = useRippleRef(ref)
+
return (
)
}
-export { Button, buttonVariants }
+export { Button, buttonVariants, type ButtonProps }
diff --git a/packages/ui/src/components/calendar.tsx b/packages/ui/src/components/calendar.tsx
new file mode 100644
index 0000000..4bb169f
--- /dev/null
+++ b/packages/ui/src/components/calendar.tsx
@@ -0,0 +1,221 @@
+"use client"
+
+import * as React from "react"
+import {
+ DayPicker,
+ getDefaultClassNames,
+ type DayButton,
+ type Locale,
+} from "react-day-picker"
+
+import { cn } from "@workspace/ui/lib/utils"
+import { Button, buttonVariants } from "@workspace/ui/components/button"
+import { ChevronLeftIcon, ChevronRightIcon, ChevronDownIcon } from "lucide-react"
+
+function Calendar({
+ className,
+ classNames,
+ showOutsideDays = true,
+ captionLayout = "label",
+ buttonVariant = "ghost",
+ locale,
+ formatters,
+ components,
+ ...props
+}: React.ComponentProps & {
+ buttonVariant?: React.ComponentProps["variant"]
+}) {
+ const defaultClassNames = getDefaultClassNames()
+
+ return (
+ svg]:rotate-180`,
+ String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
+ className
+ )}
+ captionLayout={captionLayout}
+ locale={locale}
+ formatters={{
+ formatMonthDropdown: (date) =>
+ date.toLocaleString(locale?.code, { month: "short" }),
+ ...formatters,
+ }}
+ classNames={{
+ root: cn("w-fit", defaultClassNames.root),
+ months: cn(
+ "relative flex flex-col gap-4 md:flex-row",
+ defaultClassNames.months
+ ),
+ month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
+ nav: cn(
+ "absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
+ defaultClassNames.nav
+ ),
+ button_previous: cn(
+ buttonVariants({ variant: buttonVariant }),
+ "size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
+ defaultClassNames.button_previous
+ ),
+ button_next: cn(
+ buttonVariants({ variant: buttonVariant }),
+ "size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
+ defaultClassNames.button_next
+ ),
+ month_caption: cn(
+ "flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)",
+ defaultClassNames.month_caption
+ ),
+ dropdowns: cn(
+ "flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium",
+ defaultClassNames.dropdowns
+ ),
+ dropdown_root: cn(
+ "relative rounded-(--cell-radius)",
+ defaultClassNames.dropdown_root
+ ),
+ dropdown: cn(
+ "absolute inset-0 bg-popover opacity-0",
+ defaultClassNames.dropdown
+ ),
+ caption_label: cn(
+ "font-medium select-none",
+ captionLayout === "label"
+ ? "text-sm"
+ : "flex items-center gap-1 rounded-(--cell-radius) text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground",
+ defaultClassNames.caption_label
+ ),
+ month_grid: cn("w-full border-collapse", defaultClassNames.month_grid),
+ weekdays: cn("flex", defaultClassNames.weekdays),
+ weekday: cn(
+ "flex-1 rounded-(--cell-radius) text-[0.8rem] font-normal text-muted-foreground select-none",
+ defaultClassNames.weekday
+ ),
+ week: cn("mt-2 flex w-full", defaultClassNames.week),
+ week_number_header: cn(
+ "w-(--cell-size) select-none",
+ defaultClassNames.week_number_header
+ ),
+ week_number: cn(
+ "text-[0.8rem] text-muted-foreground select-none",
+ defaultClassNames.week_number
+ ),
+ day: cn(
+ "group/day relative aspect-square h-full w-full rounded-(--cell-radius) p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-e-(--cell-radius)",
+ props.showWeekNumber
+ ? "[&:nth-child(2)[data-selected=true]_button]:rounded-s-(--cell-radius)"
+ : "[&:first-child[data-selected=true]_button]:rounded-s-(--cell-radius)",
+ defaultClassNames.day
+ ),
+ range_start: cn(
+ "relative isolate z-0 rounded-s-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:end-0 after:w-4 after:bg-muted",
+ defaultClassNames.range_start
+ ),
+ range_middle: cn("rounded-none", defaultClassNames.range_middle),
+ range_end: cn(
+ "relative isolate z-0 rounded-e-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:start-0 after:w-4 after:bg-muted",
+ defaultClassNames.range_end
+ ),
+ today: cn(
+ "rounded-(--cell-radius) bg-muted text-foreground data-[selected=true]:rounded-none",
+ defaultClassNames.today
+ ),
+ outside: cn(
+ "text-muted-foreground aria-selected:text-muted-foreground",
+ defaultClassNames.outside
+ ),
+ disabled: cn(
+ "text-muted-foreground opacity-50",
+ defaultClassNames.disabled
+ ),
+ hidden: cn("invisible", defaultClassNames.hidden),
+ ...classNames,
+ }}
+ components={{
+ Root: ({ className, rootRef, ...props }) => {
+ return (
+
+ )
+ },
+ Chevron: ({ className, orientation, ...props }) => {
+ if (orientation === "left") {
+ return (
+
+ )
+ }
+
+ if (orientation === "right") {
+ return (
+
+ )
+ }
+
+ return (
+
+ )
+ },
+ DayButton: ({ ...props }) => (
+
+ ),
+ WeekNumber: ({ children, ...props }) => {
+ return (
+
+
+ {children}
+
+ |
+ )
+ },
+ ...components,
+ }}
+ {...props}
+ />
+ )
+}
+
+function CalendarDayButton({
+ className,
+ day,
+ modifiers,
+ locale,
+ ...props
+}: React.ComponentProps & { locale?: Partial }) {
+ const defaultClassNames = getDefaultClassNames()
+
+ const ref = React.useRef(null)
+ React.useEffect(() => {
+ if (modifiers.focused) ref.current?.focus()
+ }, [modifiers.focused])
+
+ return (
+