diff --git a/apps/web/public/locales/en/app.json b/apps/web/public/locales/en/app.json index d360261ddca..9c31ffbf1cc 100644 --- a/apps/web/public/locales/en/app.json +++ b/apps/web/public/locales/en/app.json @@ -365,7 +365,6 @@ "uploadImage": "Upload", "removeImage": "Remove", "imageUploadDescription": "Up to 2MB, JPG or PNG", - "image": "Image", "billingPlanTitle": "Plan", "billingSettings": "Billing Settings", "billingSettingsDescription": "View and manage your space's subscription and billing information.", @@ -620,5 +619,16 @@ "viewPoll": "View poll", "responseOptions": "Response Options", "viewResults": "View results", - "pollClosedDescription": "No more responses are being accepted." + "pollClosedDescription": "No more responses are being accepted.", + "customBrandingAlertTitle": "Show your brand to participants", + "pollAdminCustomBrandingAlertDescription": "Upgrade to Pro to show your logo and brand colors to participants.", + "showBrandingDescription": "Show your brand identity on your public pages and emails", + "useCustomBranding": "Enable Custom Branding", + "savingBranding": "Saving...", + "brandingSaved": "Branding saved", + "brandingSaveError": "Failed to save branding", + "brandingEnabled": "Custom branding enabled", + "brandingDisabled": "Custom branding disabled", + "customBranding": "Custom Branding", + "customBrandingDescription": "Show your logo and brand colors to your participants" } diff --git a/apps/web/src/app/[locale]/(optional-space)/poll/[urlId]/admin-page.tsx b/apps/web/src/app/[locale]/(optional-space)/poll/[urlId]/admin-page.tsx index 17a8f6dd847..90896cd3584 100644 --- a/apps/web/src/app/[locale]/(optional-space)/poll/[urlId]/admin-page.tsx +++ b/apps/web/src/app/[locale]/(optional-space)/poll/[urlId]/admin-page.tsx @@ -7,6 +7,7 @@ import { PollViewTracker } from "@/components/poll/poll-view-tracker"; import { ResponsiveResults } from "@/components/poll/responsive-results"; import { VotingForm } from "@/components/poll/voting-form"; import { usePoll } from "@/contexts/poll"; +import { CustomBrandingAlert } from "./custom-branding-alert"; import { GuestPollAlert } from "./guest-poll-alert"; export function AdminPage() { @@ -15,13 +16,21 @@ export function AdminPage() { return (
- {poll.space?.showBranding && poll.space.primaryColor ? ( + {poll.space?.showBranding && poll.space?.primaryColor ? ( ) : null} {/* Track poll views */} - + + diff --git a/apps/web/src/app/[locale]/(optional-space)/poll/[urlId]/custom-branding-alert.tsx b/apps/web/src/app/[locale]/(optional-space)/poll/[urlId]/custom-branding-alert.tsx new file mode 100644 index 00000000000..dbd26b8a98b --- /dev/null +++ b/apps/web/src/app/[locale]/(optional-space)/poll/[urlId]/custom-branding-alert.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { + Alert, + AlertAction, + AlertDescription, + AlertTitle, +} from "@rallly/ui/alert"; +import { Button } from "@rallly/ui/button"; +import { PaletteIcon } from "lucide-react"; +import { usePoll } from "@/contexts/poll"; +import { useBilling } from "@/features/billing/client"; +import { Trans } from "@/i18n/client"; + +export function CustomBrandingAlert() { + const poll = usePoll(); + const { isFree, showPayWall } = useBilling(); + + if (!isFree || !poll.space || poll.space.showBranding) { + return null; + } + + return ( + + + + + + +

+ +

+
+ + + +
+ ); +} diff --git a/apps/web/src/app/[locale]/(space)/settings/general/components/custom-branding-section.tsx b/apps/web/src/app/[locale]/(space)/settings/general/components/custom-branding-section.tsx new file mode 100644 index 00000000000..c8b8ef071f6 --- /dev/null +++ b/apps/web/src/app/[locale]/(space)/settings/general/components/custom-branding-section.tsx @@ -0,0 +1,101 @@ +"use client"; + +import { Button } from "@rallly/ui/button"; +import { ColorPicker, parseColor } from "@rallly/ui/color-picker"; +import { Field, FieldGroup, FieldLabel } from "@rallly/ui/field"; +import { toast } from "@rallly/ui/sonner"; +import React from "react"; +import { + PageSection, + PageSectionContent, + PageSectionDescription, + PageSectionHeader, + PageSectionTitle, +} from "@/app/components/page-layout"; +import { ProBadge } from "@/components/pro-badge"; +import { DEFAULT_PRIMARY_COLOR } from "@/features/branding/constants"; +import { useSpace } from "@/features/space/client"; +import { Trans, useTranslation } from "@/i18n/client"; +import { trpc } from "@/trpc/client"; +import { CustomBrandingSwitch } from "./custom-branding-switch"; + +export function CustomBrandingSection() { + const { data: space } = useSpace(); + const { t } = useTranslation(); + const currentColor = space.primaryColor ?? DEFAULT_PRIMARY_COLOR; + const [color, setColor] = React.useState(() => parseColor(currentColor)); + const hexColor = color.toString("hex"); + const isDirty = hexColor !== currentColor; + + const updateSpace = trpc.spaces.update.useMutation(); + const utils = trpc.useUtils(); + + const handleSave = async () => { + const value = hexColor === DEFAULT_PRIMARY_COLOR ? null : hexColor; + toast.promise( + updateSpace + .mutateAsync({ name: space.name, primaryColor: value }) + .then(() => utils.spaces.getCurrent.invalidate()), + { + loading: t("savingBranding", { defaultValue: "Saving..." }), + success: t("brandingSaved", { defaultValue: "Branding saved" }), + error: t("brandingSaveError", { + defaultValue: "Failed to save branding", + }), + }, + ); + }; + + return ( + + + + + + + + + + + + + + + + {space.tier !== "pro" && } + + +
+ + + + + + + + + + + +
+
+
+
+ ); +} diff --git a/apps/web/src/app/[locale]/(space)/settings/general/components/custom-branding-switch.tsx b/apps/web/src/app/[locale]/(space)/settings/general/components/custom-branding-switch.tsx new file mode 100644 index 00000000000..792d942e83b --- /dev/null +++ b/apps/web/src/app/[locale]/(space)/settings/general/components/custom-branding-switch.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { toast } from "@rallly/ui/sonner"; +import { Switch } from "@rallly/ui/switch"; +import { useBilling } from "@/features/billing/client"; +import { useTranslation } from "@/i18n/client"; +import { trpc } from "@/trpc/client"; + +export function CustomBrandingSwitch({ + checked, + onChange, +}: { + checked: boolean; + onChange?: (checked: boolean) => void; +}) { + const { t } = useTranslation(); + const { isFree, showPayWall } = useBilling(); + const updateShowBranding = trpc.spaces.updateShowBranding.useMutation(); + const utils = trpc.useUtils(); + + const handleToggle = (checked: boolean) => { + if (isFree) { + showPayWall(); + return; + } + toast.promise( + updateShowBranding + .mutateAsync({ showBranding: checked }) + .then(() => utils.spaces.getCurrent.invalidate()), + { + loading: t("savingBranding", { defaultValue: "Saving..." }), + success: checked + ? t("brandingEnabled", { defaultValue: "Custom branding enabled" }) + : t("brandingDisabled", { defaultValue: "Custom branding disabled" }), + error: t("brandingSaveError", { + defaultValue: "Failed to save branding", + }), + }, + ); + onChange?.(checked); + }; + + return ( + + ); +} diff --git a/apps/web/src/app/[locale]/(space)/settings/general/components/space-branding-form.tsx b/apps/web/src/app/[locale]/(space)/settings/general/components/space-branding-form.tsx new file mode 100644 index 00000000000..ec784d216ea --- /dev/null +++ b/apps/web/src/app/[locale]/(space)/settings/general/components/space-branding-form.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { Button } from "@rallly/ui/button"; +import { ColorPicker, parseColor } from "@rallly/ui/color-picker"; +import { toast } from "@rallly/ui/sonner"; +import React from "react"; +import { DEFAULT_PRIMARY_COLOR } from "@/features/branding/constants"; +import type { SpaceDTO } from "@/features/space/types"; +import { Trans, useTranslation } from "@/i18n/client"; +import { trpc } from "@/trpc/client"; + +export function SpaceBrandingForm({ space }: { space: SpaceDTO }) { + const { t } = useTranslation(); + const currentColor = space.primaryColor ?? DEFAULT_PRIMARY_COLOR; + const [color, setColor] = React.useState(() => parseColor(currentColor)); + const hexColor = color.toString("hex"); + const isDirty = hexColor !== currentColor; + + const updateSpace = trpc.spaces.update.useMutation(); + const utils = trpc.useUtils(); + + const handleSave = async () => { + const value = hexColor === DEFAULT_PRIMARY_COLOR ? null : hexColor; + toast.promise( + updateSpace + .mutateAsync({ name: space.name, primaryColor: value }) + .then(() => utils.spaces.getCurrent.invalidate()), + { + loading: t("savingBranding", { defaultValue: "Saving..." }), + success: t("brandingSaved", { defaultValue: "Branding saved" }), + error: t("brandingSaveError", { + defaultValue: "Failed to save branding", + }), + }, + ); + }; + + return ( +
+
+ + +
+
+ ); +} diff --git a/apps/web/src/app/[locale]/(space)/settings/general/components/space-settings-form.tsx b/apps/web/src/app/[locale]/(space)/settings/general/components/space-settings-form.tsx index a1ae69ac9d1..73cab5379f0 100644 --- a/apps/web/src/app/[locale]/(space)/settings/general/components/space-settings-form.tsx +++ b/apps/web/src/app/[locale]/(space)/settings/general/components/space-settings-form.tsx @@ -3,16 +3,15 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { Button } from "@rallly/ui/button"; import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@rallly/ui/form"; + Field, + FieldError, + FieldGroup, + FieldLabel, + FieldSet, +} from "@rallly/ui/field"; import { Input } from "@rallly/ui/input"; import { toast } from "@rallly/ui/sonner"; -import { useForm } from "react-hook-form"; +import { Controller, useForm } from "react-hook-form"; import * as z from "zod"; import { ImageUpload, @@ -63,69 +62,74 @@ export function SpaceSettingsForm({ }; return ( -
- { - toast.promise( - updateSpace.mutateAsync({ - name: data.name, + { + toast.promise( + updateSpace.mutateAsync({ + name: data.name, + }), + { + loading: t("updatingSpace", { + defaultValue: "Updating space...", }), - { - loading: t("updatingSpace", { - defaultValue: "Updating space...", - }), - success: t("spaceUpdatedSuccess", { - defaultValue: "Space updated successfully", - }), - error: t("spaceUpdatedError", { - defaultValue: "Failed to update space", - }), - }, - ); + success: t("spaceUpdatedSuccess", { + defaultValue: "Space updated successfully", + }), + error: t("spaceUpdatedError", { + defaultValue: "Failed to update space", + }), + }, + ); - form.reset(data); - })} - className="space-y-6" - > -
- - - - - - - - getImageUploadUrl.mutateAsync(input)} - onUploadSuccess={handleImageUploadSuccess} - onRemoveSuccess={handleImageRemoveSuccess} - hasCurrentImage={!!space.image} - /> - -
- ( - - - - - - + +
+ + + + + + + + + + getImageUploadUrl.mutateAsync(input)} + onUploadSuccess={handleImageUploadSuccess} + onRemoveSuccess={handleImageRemoveSuccess} + hasCurrentImage={!!space.image} /> - - - - )} - /> -
+ + + ( + + + + + + {fieldState.invalid && ( + + )} + + )} + /> + +
+ -
- - + + + ); } diff --git a/apps/web/src/app/[locale]/(space)/settings/general/page-client.tsx b/apps/web/src/app/[locale]/(space)/settings/general/page-client.tsx index f0e4aeb6280..13adb044c00 100644 --- a/apps/web/src/app/[locale]/(space)/settings/general/page-client.tsx +++ b/apps/web/src/app/[locale]/(space)/settings/general/page-client.tsx @@ -21,6 +21,7 @@ import { import { useSpace } from "@/features/space/client"; import { Trans } from "@/i18n/client"; import { trpc } from "@/trpc/client"; +import { CustomBrandingSection } from "./components/custom-branding-section"; import { DeleteSpaceButton } from "./components/delete-space-button"; import { LeaveSpaceButton } from "./components/leave-space-button"; import { SpaceSettingsForm } from "./components/space-settings-form"; @@ -64,6 +65,7 @@ export function GeneralSettingsPageClient() { + {!isOwner ? ( diff --git a/apps/web/src/app/[locale]/(space)/settings/general/page.tsx b/apps/web/src/app/[locale]/(space)/settings/general/page.tsx index 0291513685e..5f1d76ebe91 100644 --- a/apps/web/src/app/[locale]/(space)/settings/general/page.tsx +++ b/apps/web/src/app/[locale]/(space)/settings/general/page.tsx @@ -2,7 +2,7 @@ import type { Metadata } from "next"; import { getTranslation } from "@/i18n/server"; import { GeneralSettingsPageClient } from "./page-client"; -export default function GeneralSettingsPage() { +export default async function GeneralSettingsPage() { return ; } diff --git a/apps/web/src/app/[locale]/invite/[urlId]/invite-page.tsx b/apps/web/src/app/[locale]/invite/[urlId]/invite-page.tsx index 498d807ec83..210d664b218 100644 --- a/apps/web/src/app/[locale]/invite/[urlId]/invite-page.tsx +++ b/apps/web/src/app/[locale]/invite/[urlId]/invite-page.tsx @@ -57,7 +57,14 @@ export function InvitePage() {
- + diff --git a/apps/web/src/components/event-card.tsx b/apps/web/src/components/event-card.tsx index 7103eafc8d4..4861f1710e9 100644 --- a/apps/web/src/components/event-card.tsx +++ b/apps/web/src/components/event-card.tsx @@ -43,22 +43,24 @@ function IconDescription({ ); } -export function EventCard() { +export function EventCard({ + name, + logoUrl, +}: { + logoUrl?: string; + name: string; +}) { const poll = usePoll(); return (
- {poll.space?.showBranding && poll.space.image ? ( + {logoUrl ? (
- +

- {poll.space.name} + {name}

) : null} diff --git a/apps/web/src/components/pro-badge.tsx b/apps/web/src/components/pro-badge.tsx index 46502d13be6..e091de1c954 100644 --- a/apps/web/src/components/pro-badge.tsx +++ b/apps/web/src/components/pro-badge.tsx @@ -4,7 +4,7 @@ import { PLAN_NAMES } from "@/features/billing/constants"; export const ProBadge = ({ className }: { className?: string }) => { return ( - + {PLAN_NAMES.PRO} ); diff --git a/apps/web/src/components/stacked-list.tsx b/apps/web/src/components/stacked-list.tsx index eb9e4396af3..f08e918a205 100644 --- a/apps/web/src/components/stacked-list.tsx +++ b/apps/web/src/components/stacked-list.tsx @@ -10,7 +10,7 @@ export function StackedList({ return (
    diff --git a/apps/web/src/features/billing/components/pay-wall-dialog.tsx b/apps/web/src/features/billing/components/pay-wall-dialog.tsx index 889b493d738..537d8b87006 100644 --- a/apps/web/src/features/billing/components/pay-wall-dialog.tsx +++ b/apps/web/src/features/billing/components/pay-wall-dialog.tsx @@ -21,6 +21,7 @@ import { ClockIcon, CopyIcon, LifeBuoyIcon, + PaletteIcon, SettingsIcon, SparklesIcon, TimerResetIcon, @@ -304,6 +305,21 @@ export function PayWallDialog({ + } + title={ + + } + description={ + + } + /> } title={ diff --git a/apps/web/src/features/space/client.tsx b/apps/web/src/features/space/client.tsx index a3ea3560f25..19dfd857921 100644 --- a/apps/web/src/features/space/client.tsx +++ b/apps/web/src/features/space/client.tsx @@ -59,9 +59,10 @@ export function SpaceProvider({ children }: { children: React.ReactNode }) { return ; } - const primaryColorVars = space.primaryColor - ? getPrimaryColorVars(space.primaryColor) - : null; + const primaryColorVars = + space.showBranding && space.primaryColor + ? getPrimaryColorVars(space.primaryColor) + : null; return ( <> diff --git a/apps/web/src/trpc/routers/spaces.ts b/apps/web/src/trpc/routers/spaces.ts index 08f2789ca3f..dcf2ff3921c 100644 --- a/apps/web/src/trpc/routers/spaces.ts +++ b/apps/web/src/trpc/routers/spaces.ts @@ -266,7 +266,16 @@ export const spaces = router({ }), update: spaceProcedure - .input(z.object({ name: z.string().min(1).max(100) })) + .input( + z.object({ + name: z.string().min(1).max(100), + primaryColor: z + .string() + .regex(/^#[0-9a-fA-F]{6}$/, "Invalid hex color") + .nullable() + .optional(), + }), + ) .mutation(async ({ ctx, input }) => { const memberAbility = defineAbilityForMember({ user: ctx.user, @@ -282,7 +291,12 @@ export const spaces = router({ await prisma.space.update({ where: { id: ctx.space.id }, - data: { name: input.name }, + data: { + name: input.name, + ...(input.primaryColor !== undefined && { + primaryColor: input.primaryColor, + }), + }, }); posthog()?.capture({ @@ -297,34 +311,6 @@ export const spaces = router({ }); }), - updatePrimaryColor: spaceProcedure - .input( - z.object({ - primaryColor: z - .string() - .regex(/^#[0-9a-fA-F]{6}$/, "Invalid hex color") - .nullable(), - }), - ) - .mutation(async ({ ctx, input }) => { - const memberAbility = defineAbilityForMember({ - user: ctx.user, - space: ctx.space, - }); - - if (memberAbility.cannot("update", subject("Space", ctx.space))) { - throw new TRPCError({ - code: "FORBIDDEN", - message: "You do not have permission to update this space", - }); - } - - await prisma.space.update({ - where: { id: ctx.space.id }, - data: { primaryColor: input.primaryColor }, - }); - }), - updateShowBranding: spaceProcedure .input(z.object({ showBranding: z.boolean() })) .mutation(async ({ ctx, input }) => { diff --git a/packages/tailwind-config/shared-styles.css b/packages/tailwind-config/shared-styles.css index 96f735dfde4..5eabdd744b8 100644 --- a/packages/tailwind-config/shared-styles.css +++ b/packages/tailwind-config/shared-styles.css @@ -10,7 +10,7 @@ :root { --radius: 0.625rem; - --primary: var(--primary-light, var(--color-indigo-600)); + --primary: var(--primary-light, var(--color-gray-800)); --primary-foreground: var(--primary-light-foreground, var(--color-white)); --button-outline: rgba(0, 0, 0, 0.1); @@ -101,8 +101,8 @@ --popover-foreground: var(--color-gray-300); --popover-accent: var(--color-gray-700); - --card: color-mix(in srgb, var(--color-gray-800) 25%, transparent); - --card-border: color-mix(in srgb, var(--color-gray-600) 50%, transparent); + --card: var(--color-gray-800); + --card-border: var(--color-gray-700); --card-foreground: var(--color-gray-300); --card-accent: rgba(255, 255, 255, 0.02); } diff --git a/packages/ui/package.json b/packages/ui/package.json index 6105015d3ff..2cc41882a56 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -44,6 +44,9 @@ "lucide-react": "^0.479.0", "next-themes": "^0.4.6", "react": "19.2.4", + "@react-aria/color": "^3.1.5", + "@react-stately/color": "^3.9.5", + "react-aria-components": "^1.16.0", "react-dom": "19.2.4", "react-hook-form": "^7.68.0", "sonner": "^2.0.6", diff --git a/packages/ui/src/alert.tsx b/packages/ui/src/alert.tsx index 13a8ab80925..73182087c42 100644 --- a/packages/ui/src/alert.tsx +++ b/packages/ui/src/alert.tsx @@ -5,12 +5,12 @@ import type * as React from "react"; import { cn } from "./lib/utils"; const alertVariants = cva( - "relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-xl border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current", + "relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-xl border px-4 py-3 text-sm has-data-[slot=alert-action]:relative has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 has-data-[slot=alert-action]:pr-18 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current", { variants: { variant: { primary: - "border-transparent bg-primary *:data-[slot=alert-description]:text-primary-foreground/90 [&>svg]:text-primary-foreground", + "border-transparent bg-primary text-primary-foreground *:data-[slot=alert-description]:text-primary-foreground/90 [&>svg]:text-primary-foreground", info: "border-blue-500/20 bg-blue-500/10 text-blue-900 *:data-[slot=alert-description]:text-blue-900/90 dark:text-blue-100 dark:*:data-[slot=alert-description]:text-blue-100/90 [&>svg]:text-blue-900/75 dark:[&>svg]:text-blue-100/75", warning: "border-yellow-500/20 bg-yellow-500/10 text-yellow-900 *:data-[slot=alert-description]:text-yellow-900/90 dark:text-yellow-100 dark:*:data-[slot=alert-description]:text-yellow-100/90 [&>svg]:text-yellow-900/75 dark:[&>svg]:text-yellow-100/75", @@ -68,4 +68,14 @@ function AlertDescription({ ); } -export { Alert, AlertTitle, AlertDescription }; +function AlertAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
    + ); +} + +export { Alert, AlertTitle, AlertDescription, AlertAction }; diff --git a/packages/ui/src/button-variants.ts b/packages/ui/src/button-variants.ts index 54afc75f622..25c525fead3 100644 --- a/packages/ui/src/button-variants.ts +++ b/packages/ui/src/button-variants.ts @@ -4,7 +4,7 @@ import { cn } from "./lib/utils"; export const buttonVariants = cva( cn( - "group inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-lg font-normal outline-none transition-opacity transition-transform focus-visible:ring-2 focus-visible:ring-ring not-[[aria-haspopup=menu]]:active:translate-y-0.5 active:shadow-none disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg]:opacity-90", + "group inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-lg font-normal outline-none transition-opacity transition-transform focus-visible:ring-2 focus-visible:ring-ring active:shadow-none disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg]:opacity-90", ), { variants: { diff --git a/packages/ui/src/color-picker.tsx b/packages/ui/src/color-picker.tsx new file mode 100644 index 00000000000..ee6a01ba687 --- /dev/null +++ b/packages/ui/src/color-picker.tsx @@ -0,0 +1,104 @@ +"use client"; + +import { useColorField } from "@react-aria/color"; +import { useColorFieldState } from "@react-stately/color"; +import React from "react"; +import type { + Color, + ColorPickerProps as RAColorPickerProps, +} from "react-aria-components"; +import { + ColorArea, + ColorPicker as ColorPickerPrimitive, + ColorPickerStateContext, + ColorSlider, + ColorSwatch, + ColorThumb, + SliderTrack, +} from "react-aria-components"; +import { + InputGroup, + InputGroupAddon, + InputGroupButton, + InputGroupInput, +} from "./input-group"; +import { cn } from "./lib/utils"; +import { Popover, PopoverContent, PopoverTrigger } from "./popover"; + +export type ColorPickerProps = Pick< + RAColorPickerProps, + "value" | "onChange" | "defaultValue" +>; + +export type { Color }; +export { parseColor } from "react-aria-components"; + +function HexColorInput() { + const pickerState = React.useContext(ColorPickerStateContext); + const inputRef = React.useRef(null); + + const state = useColorFieldState({ + value: pickerState?.color ?? undefined, + onChange: (color: Color | null) => { + if (color) pickerState?.setColor(color); + }, + }); + + const { inputProps } = useColorField( + { "aria-label": "Hex color" }, + state, + inputRef, + ); + + return ( + + ); +} + +export function ColorPicker(props: ColorPickerProps) { + return ( + + + + + }> + + + + + + + + + + + + + + + + + + ); +} diff --git a/packages/ui/src/field.tsx b/packages/ui/src/field.tsx new file mode 100644 index 00000000000..29dd7ef2f03 --- /dev/null +++ b/packages/ui/src/field.tsx @@ -0,0 +1,240 @@ +"use client"; + +import type { VariantProps } from "class-variance-authority"; +import { cva } from "class-variance-authority"; +import { useMemo } from "react"; +import { Label } from "./label"; +import { cn } from "./lib/utils"; +import { Separator } from "./separator"; + +function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) { + return ( +
    [data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3", + className, + )} + {...props} + /> + ); +} + +function FieldLegend({ + className, + variant = "legend", + ...props +}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) { + return ( + + ); +} + +function FieldGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
    + ); +} + +const fieldVariants = cva( + "group/field flex w-full gap-2 data-[invalid=true]:text-destructive", + { + variants: { + orientation: { + vertical: "flex-col *:w-full [&>.sr-only]:w-auto", + horizontal: + "flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px", + responsive: + "@md/field-group:flex-row flex-col @md/field-group:items-center *:w-full @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px", + }, + }, + defaultVariants: { + orientation: "vertical", + }, + }, +); + +function Field({ + className, + orientation = "vertical", + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( + // biome-ignore lint/a11y/useSemanticElements: avoid messing with shadcn defualt implementation +
    + ); +} + +function FieldContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
    + ); +} + +function FieldLabel({ + className, + ...props +}: React.ComponentProps) { + return ( +