From 9fa960ddb9a32360ab239ffb81b7f7090546d726 Mon Sep 17 00:00:00 2001 From: Muhammed Sanjid Date: Tue, 7 Apr 2026 00:51:50 +0530 Subject: [PATCH 1/2] feat: new components and accordion variants --- apps/cli/dist/index.js | 1 + apps/cli/dist/utils/files.js | 237 +++++++ apps/platform/app/(docs)/[...slug]/page.tsx | 12 +- apps/platform/app/globals.css | 21 + .../showcase/component-live-preview.tsx | 225 ++++++- .../components/ui/accordion-variants.tsx | 457 +++++++++++++ apps/platform/components/ui/accordion.tsx | 10 +- apps/platform/components/ui/button.tsx | 25 +- apps/platform/components/ui/carousel.tsx | 41 +- apps/platform/components/ui/chip.tsx | 64 ++ apps/platform/components/ui/fab.tsx | 127 ++++ .../docs/components/accordion-variants.mdx | 118 ++++ .../platform/content/docs/components/chip.mdx | 36 + apps/platform/content/docs/components/fab.mdx | 40 ++ .../platform/content/docs/components/list.mdx | 44 ++ .../content/docs/components/meta.json | 2 +- .../content/docs/components/search-bar.mdx | 36 + .../docs/components/segmented-control.mdx | 36 + apps/platform/lib/docs-navigation.ts | 5 +- apps/platform/lib/normalize-doc-slug.ts | 13 + apps/platform/lib/registry-data.json | 74 ++- apps/showcase/app/(components)/[slug].tsx | 369 +++++++++- apps/showcase/app/(tabs)/explore.tsx | 35 + apps/showcase/app/(tabs)/index.tsx | 14 + .../components/ui/accordion-variants.tsx | 628 ++++++++++++++++++ apps/showcase/components/ui/accordion.tsx | 6 +- apps/showcase/components/ui/chip.tsx | 130 ++++ apps/showcase/components/ui/fab.tsx | 224 +++++++ apps/showcase/components/ui/list.tsx | 141 ++++ apps/showcase/components/ui/search-bar.tsx | 132 ++++ .../components/ui/segmented-control.tsx | 108 +++ packages/registry/package.json | 1 + packages/registry/registry.json | 132 ++++ .../src/components/ui/accordion-variants.tsx | 596 +++++++++++++++++ .../registry/src/components/ui/accordion.tsx | 6 +- packages/registry/src/components/ui/chip.tsx | 130 ++++ packages/registry/src/components/ui/fab.tsx | 227 +++++++ packages/registry/src/components/ui/list.tsx | 141 ++++ .../registry/src/components/ui/search-bar.tsx | 132 ++++ .../src/components/ui/segmented-control.tsx | 108 +++ 40 files changed, 4840 insertions(+), 44 deletions(-) create mode 100644 apps/platform/components/ui/accordion-variants.tsx create mode 100644 apps/platform/components/ui/chip.tsx create mode 100644 apps/platform/components/ui/fab.tsx create mode 100644 apps/platform/content/docs/components/accordion-variants.mdx create mode 100644 apps/platform/content/docs/components/chip.mdx create mode 100644 apps/platform/content/docs/components/fab.mdx create mode 100644 apps/platform/content/docs/components/list.mdx create mode 100644 apps/platform/content/docs/components/search-bar.mdx create mode 100644 apps/platform/content/docs/components/segmented-control.mdx create mode 100644 apps/platform/lib/normalize-doc-slug.ts create mode 100644 apps/showcase/components/ui/accordion-variants.tsx create mode 100644 apps/showcase/components/ui/chip.tsx create mode 100644 apps/showcase/components/ui/fab.tsx create mode 100644 apps/showcase/components/ui/list.tsx create mode 100644 apps/showcase/components/ui/search-bar.tsx create mode 100644 apps/showcase/components/ui/segmented-control.tsx create mode 100644 packages/registry/src/components/ui/accordion-variants.tsx create mode 100644 packages/registry/src/components/ui/chip.tsx create mode 100644 packages/registry/src/components/ui/fab.tsx create mode 100644 packages/registry/src/components/ui/list.tsx create mode 100644 packages/registry/src/components/ui/search-bar.tsx create mode 100644 packages/registry/src/components/ui/segmented-control.tsx diff --git a/apps/cli/dist/index.js b/apps/cli/dist/index.js index 8d4939c..110641c 100755 --- a/apps/cli/dist/index.js +++ b/apps/cli/dist/index.js @@ -80,6 +80,7 @@ program await (0, config_js_1.setConfig)(cwd, config); await (0, files_js_1.ensureComponentsDirectory)(cwd, config); await (0, files_js_1.ensureUtilsFile)(cwd, config); + await (0, files_js_1.ensureCssFile)(cwd, config); const requiredDeps = [ 'clsx', 'tailwind-merge', diff --git a/apps/cli/dist/utils/files.js b/apps/cli/dist/utils/files.js index 5d63b9c..3270084 100644 --- a/apps/cli/dist/utils/files.js +++ b/apps/cli/dist/utils/files.js @@ -7,6 +7,7 @@ exports.installFiles = installFiles; exports.transformImports = transformImports; exports.ensureUtilsFile = ensureUtilsFile; exports.ensureComponentsDirectory = ensureComponentsDirectory; +exports.ensureCssFile = ensureCssFile; const fs_extra_1 = __importDefault(require("fs-extra")); const path_1 = __importDefault(require("path")); async function installFiles(components, options) { @@ -60,6 +61,242 @@ async function ensureComponentsDirectory(cwd, config) { const componentsPath = config.aliases.components.replace('@/', ''); await fs_extra_1.default.ensureDir(path_1.default.join(cwd, componentsPath, 'ui')); } +async function ensureCssFile(cwd, config) { + const cssContent = `@import "tailwindcss"; + +@custom-variant dark (&:is(.dark *)); + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --font-sans: var(--font-sans); + --font-mono: var(--font-geist-mono); + --shadow-3d: inset 0 5px 6px var(--color-border); + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --radius-sm: calc(var(--radius) * 0.6); + --radius-md: calc(var(--radius) * 0.8); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) * 1.4); + --radius-2xl: calc(var(--radius) * 1.8); + --radius-3xl: calc(var(--radius) * 2.2); + --radius-4xl: calc(var(--radius) * 2.6); +} + +:root { + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.809 0.105 251.813); + --chart-2: oklch(0.623 0.214 259.815); + --chart-3: oklch(0.546 0.245 262.881); + --chart-4: oklch(0.488 0.243 264.376); + --chart-5: oklch(0.424 0.199 265.638); + --radius: 0.625rem; + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.809 0.105 251.813); + --chart-2: oklch(0.623 0.214 259.815); + --chart-3: oklch(0.546 0.245 262.881); + --chart-4: oklch(0.488 0.243 264.376); + --chart-5: oklch(0.424 0.199 265.638); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } + html { + @apply font-sans; + } + +@keyframes show-top-mask { + to { + --top-mask-height: var(--mask-height); + } + } + +@keyframes hide-bottom-mask { + to { + --bottom-mask-height: 0px; + } + } + +@keyframes show-left-mask { + to { + --left-mask-width: var(--mask-width); + } + } + +@keyframes hide-right-mask { + to { + --right-mask-width: 0px; + } + } +} + +@property --top-mask-height { + syntax: ""; + inherits: true; + initial-value: 0px; +} + +@property --bottom-mask-height { + syntax: ""; + inherits: true; + initial-value: 64px; +} + +@property --left-mask-width { + syntax: ""; + inherits: true; + initial-value: 0px; +} + +@property --right-mask-width { + syntax: ""; + inherits: true; + initial-value: 64px; +} + +@utility scroll-fade-effect-y { + --mask-height: 64px; + --mask-offset-top: 0px; + --mask-offset-bottom: 0px; + --scroll-buffer: 2rem; + mask-image: linear-gradient(to top, transparent, black 90%), linear-gradient(to bottom, transparent 0%, black 100%), linear-gradient(black, black); + mask-size: 100% var(--top-mask-height), 100% var(--bottom-mask-height), 100% 100%; + mask-repeat: no-repeat, no-repeat, no-repeat; + mask-position: 0 var(--mask-offset-top), 0 calc(100% - var(--mask-offset-bottom)), 0 0; + mask-composite: exclude; + animation-name: show-top-mask, hide-bottom-mask; + animation-timeline: scroll(self), scroll(self); + animation-range: 0 var(--scroll-buffer), calc(100% - var(--scroll-buffer)) 100%; + animation-fill-mode: both; +} + +@utility scroll-fade-effect-x { + --mask-width: 64px; + --mask-offset-left: 0px; + --mask-offset-right: 0px; + --scroll-buffer: 2rem; + mask-image: linear-gradient(to left, transparent, black 90%), linear-gradient(to right, transparent 0%, black 100%), linear-gradient(black, black); + mask-size: var(--left-mask-width) 100%, var(--right-mask-width) 100%, 100% 100%; + mask-repeat: no-repeat, no-repeat, no-repeat; + mask-position: var(--mask-offset-left) 0, calc(100% - var(--mask-offset-right)) 0, 0 0; + mask-composite: exclude; + animation-name: show-left-mask, hide-right-mask; + animation-timeline: scroll(self inline), scroll(self inline); + animation-range: 0 var(--scroll-buffer), calc(100% - var(--scroll-buffer)) 100%; + animation-fill-mode: both; +} + +/* Hide scrollbar globally */ +.scrollbar-hide { + -ms-overflow-style: none; + scrollbar-width: none; +} + +.scrollbar-hide::-webkit-scrollbar { + display: none; +} + +/* Apply to all scrollable elements */ +* { + scrollbar-width: none; + -ms-overflow-style: none; +} + +*::-webkit-scrollbar { + display: none; +} +`; + const fullPath = path_1.default.join(cwd, config.tailwind.css); + if (!(await fs_extra_1.default.pathExists(fullPath))) { + await fs_extra_1.default.ensureDir(path_1.default.dirname(fullPath)); + await fs_extra_1.default.writeFile(fullPath, cssContent, 'utf-8'); + } +} function resolveTargetRoot(cwd, installPath) { if (!installPath) { return cwd; diff --git a/apps/platform/app/(docs)/[...slug]/page.tsx b/apps/platform/app/(docs)/[...slug]/page.tsx index 4e58df0..fe04fb2 100644 --- a/apps/platform/app/(docs)/[...slug]/page.tsx +++ b/apps/platform/app/(docs)/[...slug]/page.tsx @@ -21,6 +21,7 @@ import { PopoverTrigger, } from "@/components/ui/popover"; import { getComponentLinks, getComponentPager } from "@/lib/docs-navigation"; +import { normalizeDocSlug } from "@/lib/normalize-doc-slug"; import { docsSource } from "@/lib/docs-source"; import { getRegistryCatalog } from "@/lib/registry-catalog"; import { customMDXComponents } from "@/mdx-components"; @@ -124,7 +125,8 @@ export default async function DocsPage({ ); } - const componentSlug = page.slugs.at(-1); + const componentSlug = normalizeDocSlug(page.slugs.at(-1) ?? ""); + const catalog = await getRegistryCatalog(); const componentDocs = getComponentLinks(); const component = catalog @@ -183,10 +185,10 @@ export default async function DocsPage({
{qrValue ? ( -
+
- @@ -198,6 +200,10 @@ export default async function DocsPage({ /> +

+ Tap to show a QR code for the Expo showcase deep link. The web + preview below runs in the browser. +

) : null} -
- - +
+ + Getting started - -

+ +

Use accordion items to reveal grouped content one section at a time.

+ + Details + +

+ Open and close each section—animations use the Radix content + height variable. +

+
+
@@ -1054,6 +1074,26 @@ export function AccordionInlinePreview() { ) } +function AccordionVariantsLivePreview() { + return ( + +
+ +
+
+ ) +} + +export function AccordionVariantsInlinePreview() { + return ( + +
+ +
+
+ ) +} + function DialogLivePreview() { return ( @@ -2149,6 +2189,155 @@ export { TableLivePreview as TableInlinePreview, }; +function ListLivePreview() { + const rows = [ + { title: "Notifications", sub: "Push, email, and in-app" }, + { title: "Privacy", sub: "Who can see your activity" }, + ]; + return ( + +
+ {rows.map((row) => ( + + ))} +
+
+ ); +} + +function SegmentedControlLivePreview() { + const [value, setValue] = useState("list"); + const options = [ + { value: "list", label: "List" }, + { value: "map", label: "Map" }, + ]; + return ( + +
+
+ {options.map((opt) => ( + + ))} +
+

Selected: {value}

+
+
+ ); +} + +function FabLivePreview() { + const fabMenuActions = useMemo( + () => [ + { + id: "msg", + label: "New message", + onPress: () => {}, + icon: , + }, + { + id: "photo", + label: "New photo", + onPress: () => {}, + icon: , + }, + { + id: "task", + label: "New task", + onPress: () => {}, + icon: , + }, + ], + [], + ); + + return ( + +
+ + open ? ( + + ) : ( + + ) + } + /> +
+
+ ); +} + +function SearchBarLivePreview() { + const [q, setQ] = useState(""); + return ( + +
+
+ + setQ(e.target.value)} + /> + {q ? ( + + ) : null} +
+

+ {q ? `Query: ${q}` : "Type to see the clear affordance."} +

+
+
+ ); +} + +function ChipLivePreview() { + const [on, setOn] = useState(true); + return ( + +
+ setOn((v) => !v)}> + {on ? "Enabled" : "Disabled"} + + Outline + {}}>Removable +
+
+ ); +} + export function ComponentLivePreview({ slug }: { slug: string }) { switch (slug) { case "button": @@ -2157,6 +2346,8 @@ export function ComponentLivePreview({ slug }: { slug: string }) { return ; case "accordion": return ; + case "accordion-variants": + return ; case "alert": return ; case "alert-dialog": @@ -2235,7 +2426,27 @@ export function ComponentLivePreview({ slug }: { slug: string }) { return ; case "otp-input": return ; + case "list": + return ; + case "segmented-control": + return ; + case "fab": + return ; + case "search-bar": + return ; + case "chip": + return ; default: - return null; + return ( + +

+ No web preview is registered for this slug yet. Use{" "} + Scan to preview{" "} + to open the native Expo showcase, or switch to the{" "} + Code tab for the + source. +

+
+ ); } } diff --git a/apps/platform/components/ui/accordion-variants.tsx b/apps/platform/components/ui/accordion-variants.tsx new file mode 100644 index 0000000..53680ce --- /dev/null +++ b/apps/platform/components/ui/accordion-variants.tsx @@ -0,0 +1,457 @@ +"use client" + +import * as React from "react" +import { + Headphones, + Package, + RefreshCw, + ChevronDown, +} from "lucide-react" + +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion" +import { Avatar, AvatarFallback } from "@/components/ui/avatar" +import { cn } from "@/lib/utils" + +export type AccordionVariantFaqItem = { + value: string + title: string + description: string +} + +export const ACCORDION_VARIANT_FAQ_DEFAULT: AccordionVariantFaqItem[] = [ + { + value: "track", + title: "How do I track my order?", + description: + "Open Orders in your account and select the shipment to see live status and carrier tracking.", + }, + { + value: "returns", + title: "What is your return policy?", + description: + "Unopened items can be returned within 30 days. Start a return from the order detail page.", + }, + { + value: "support", + title: "How can I contact customer support?", + description: + "Use in-app chat weekdays 9–6, or email support@example.com—we reply within one business day.", + }, +] + +type VariantBaseProps = React.ComponentProps & { + items?: AccordionVariantFaqItem[] +} + +function resolveDefaultValue( + type: React.ComponentProps["type"], + items: { value: string }[], + explicit?: string | string[], +) { + if (explicit !== undefined) { + return explicit + } + if (type === "multiple") { + return [items[0]?.value].filter(Boolean) as string[] + } + return items[0]?.value ?? "" +} + +function FaqContent({ text }: { text: string }) { + return ( +

{text}

+ ) +} + +/** Minimal list: hairline separators only. */ +export function AccordionVariantList({ + items = ACCORDION_VARIANT_FAQ_DEFAULT, + type = "single", + defaultValue, + ...rest +}: VariantBaseProps) { + const dv = resolveDefaultValue(type, items, defaultValue) + return ( + + {items.map((item) => ( + + {item.title} + + + + + ))} + + ) +} + +/** Each item is its own rounded card with gap between. */ +export function AccordionVariantCards({ + items = ACCORDION_VARIANT_FAQ_DEFAULT, + type = "single", + defaultValue, + ...rest +}: VariantBaseProps) { + const dv = resolveDefaultValue(type, items, defaultValue) + return ( + + {items.map((item) => ( + + {item.title} + + + + + ))} + + ) +} + +/** Single bordered container with internal dividers. */ +export function AccordionVariantGrouped({ + items = ACCORDION_VARIANT_FAQ_DEFAULT, + type = "single", + defaultValue, + ...rest +}: VariantBaseProps) { + const dv = resolveDefaultValue(type, items, defaultValue) + return ( +
+ + {items.map((item, index) => ( + + {item.title} + + + + + ))} + +
+ ) +} + +/** Bold titles with plus / minus on the right. */ +export function AccordionVariantPlusMinus({ + items = ACCORDION_VARIANT_FAQ_DEFAULT, + type = "single", + defaultValue, + ...rest +}: VariantBaseProps) { + const dv = resolveDefaultValue(type, items, defaultValue) + return ( + + {items.map((item) => ( + + + + + + + + + ))} + + ) +} + +/** Accent header and rule when expanded. */ +export function AccordionVariantAccent({ + items = ACCORDION_VARIANT_FAQ_DEFAULT, + type = "single", + defaultValue, + ...rest +}: VariantBaseProps) { + const dv = resolveDefaultValue(type, items, defaultValue) + return ( + + {items.map((item) => ( + + + + + + + + + ))} + + ) +} + +const LEAD_ICONS = [Package, RefreshCw, Headphones] as const + +/** Leading stroke icon + title + chevron. */ +export function AccordionVariantLeadIcons({ + items = ACCORDION_VARIANT_FAQ_DEFAULT, + type = "single", + defaultValue, + ...rest +}: VariantBaseProps) { + const dv = resolveDefaultValue(type, items, defaultValue) + return ( + + {items.map((item, index) => { + const Icon = LEAD_ICONS[index % LEAD_ICONS.length] + return ( + + + + + + + + + ) + })} + + ) +} + +export type AccordionVariantSubtitledItem = { + value: string + title: string + subtitle: string + description: string +} + +export const ACCORDION_VARIANT_SUBTITLED_DEFAULT: AccordionVariantSubtitledItem[] = [ + { + value: "ship", + title: "Shipping updates", + subtitle: "Shipping & delivery", + description: + "We ship within 2 business days. You will get an email when the label is created.", + }, + { + value: "intl", + title: "International orders", + subtitle: "Shipping & delivery", + description: + "Duties and taxes may apply based on your country. Estimates appear at checkout.", + }, + { + value: "pickup", + title: "Pickup locations", + subtitle: "Shipping & delivery", + description: "Select a locker or store pickup during checkout where available.", + }, +] + +type SubtitledProps = React.ComponentProps & { + items?: AccordionVariantSubtitledItem[] +} + +/** Bordered group with circular icon, title, subtitle, and plus/minus. */ +export function AccordionVariantSubtitled({ + items = ACCORDION_VARIANT_SUBTITLED_DEFAULT, + type = "single", + defaultValue, + ...rest +}: SubtitledProps) { + const dv = resolveDefaultValue(type, items, defaultValue) + return ( +
+ + {items.map((item, index) => { + const Icon = LEAD_ICONS[index % LEAD_ICONS.length] + return ( + + + + + + + + + ) + })} + +
+ ) +} + +export type AccordionVariantProfileItem = { + value: string + name: string + email: string + bio: string +} + +export const ACCORDION_VARIANT_PROFILE_DEFAULT: AccordionVariantProfileItem[] = [ + { + value: "u1", + name: "Alex Morgan", + email: "alex@example.com", + bio: "Product designer focused on systems and accessibility. Previously at a commerce platform, now building mobile-first patterns.", + }, + { + value: "u2", + name: "Jordan Lee", + email: "jordan@example.com", + bio: "Engineer working on performance and offline support. Open source contributor and conference speaker.", + }, +] + +type ProfileProps = React.ComponentProps & { + items?: AccordionVariantProfileItem[] +} + +/** Avatar, name, email in the header; bio in the panel. */ +export function AccordionVariantProfile({ + items = ACCORDION_VARIANT_PROFILE_DEFAULT, + type = "single", + defaultValue, + ...rest +}: ProfileProps) { + const dv = resolveDefaultValue(type, items, defaultValue) + return ( + + {items.map((item) => { + const initials = item.name + .split(" ") + .map((p) => p[0]) + .join("") + .slice(0, 2) + .toUpperCase() + return ( + + + + + +

{item.bio}

+
+
+ ) + })} +
+ ) +} + +/** Outer categories with a nested accordion for detail questions. */ +export function AccordionVariantNested(props: React.ComponentProps<"div">) { + return ( +
+
+ + + Shipping & delivery + +
+ + + How do I track my order? + + + + + + When will my package arrive? + + + + + +
+
+
+ + Returns & refunds + +

+ Start a return from your order history. Refunds post after we receive the item. +

+
+
+
+
+
+ ) +} diff --git a/apps/platform/components/ui/accordion.tsx b/apps/platform/components/ui/accordion.tsx index c0d54d3..e5a70ff 100644 --- a/apps/platform/components/ui/accordion.tsx +++ b/apps/platform/components/ui/accordion.tsx @@ -23,17 +23,21 @@ AccordionItem.displayName = "AccordionItem" const AccordionTrigger = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( +>(({ className, children, asChild = false, ...props }, ref) => ( svg]:rotate-180", + asChild + ? "" + : "flex flex-1 items-center justify-between py-4 text-left text-sm font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180", className )} + data-slot="accordion-trigger" {...props} + asChild={asChild} > - {props.asChild ? ( + {asChild ? ( children ) : ( <> diff --git a/apps/platform/components/ui/button.tsx b/apps/platform/components/ui/button.tsx index c88ffd6..7475c77 100644 --- a/apps/platform/components/ui/button.tsx +++ b/apps/platform/components/ui/button.tsx @@ -41,20 +41,21 @@ const buttonVariants = cva( } ) -function Button({ - className, - variant = "default", - size = "default", - asChild = false, - ...props -}: React.ComponentProps<"button"> & - VariantProps & { - asChild?: boolean - }) { +const Button = React.forwardRef< + HTMLButtonElement, + React.ComponentProps<"button"> & + VariantProps & { + asChild?: boolean + } +>(function Button( + { className, variant = "default", size = "default", asChild = false, ...props }, + ref, +) { const Comp = asChild ? Slot.Root : "button" return ( ) -} +}) + +Button.displayName = "Button" export { Button, buttonVariants } diff --git a/apps/platform/components/ui/carousel.tsx b/apps/platform/components/ui/carousel.tsx index 55c3364..16f3f37 100644 --- a/apps/platform/components/ui/carousel.tsx +++ b/apps/platform/components/ui/carousel.tsx @@ -1,6 +1,7 @@ "use client" import * as React from "react" +import { ChevronLeft, ChevronRight } from "lucide-react" import { cn } from "@/lib/utils" @@ -11,6 +12,7 @@ type CarouselContextValue = { setIndex: (index: number) => void scrollToIndex: (index: number) => void loop: boolean + viewportRef: React.RefObject } const CarouselContext = React.createContext(null) @@ -75,7 +77,7 @@ function Carousel({ const clamped = clampIndex(nextIndex, count, loop) const viewport = viewportRef.current - if (viewport) { + if (viewport && viewport.clientWidth > 0) { viewport.scrollTo({ left: clamped * viewport.clientWidth, behavior: "smooth", @@ -87,6 +89,27 @@ function Carousel({ [count, loop, setIndex] ) + // After slides mount, align scroll for non-zero defaultIndex (viewport width may be 0 on first paint) + React.useEffect(() => { + if (count <= 0 || defaultIndex === 0) { + return + } + const viewport = viewportRef.current + if (!viewport) { + return + } + const apply = () => { + if (viewport.clientWidth <= 0) { + return + } + const t = clampIndex(defaultIndex, count, loop) + viewport.scrollTo({ left: t * viewport.clientWidth, behavior: "auto" }) + } + apply() + const id = requestAnimationFrame(apply) + return () => cancelAnimationFrame(id) + }, [count, defaultIndex, loop]) + return (
-
{children}
+ {children}
) @@ -114,7 +138,7 @@ function CarouselContent({ className, ...props }: React.ComponentProps<"div">) { - const { setCount, setIndex } = useCarouselContext() + const { setCount, setIndex, viewportRef } = useCarouselContext() React.useEffect(() => { setCount(React.Children.count(children)) @@ -122,9 +146,10 @@ function CarouselContent({ return (
{ @@ -174,12 +199,12 @@ function CarouselPrevious({ scrollToIndex(index - 1) }} className={cn( - "inline-flex size-10 items-center justify-center rounded-full border bg-background text-sm font-medium shadow-sm transition-colors hover:bg-accent disabled:pointer-events-none disabled:opacity-40", + "inline-flex size-10 shrink-0 items-center justify-center rounded-full border bg-background text-sm font-medium shadow-sm transition-colors hover:bg-accent disabled:pointer-events-none disabled:opacity-40", className )} {...props} > - {children ?? "<"} + {children ?? } ) } @@ -205,12 +230,12 @@ function CarouselNext({ scrollToIndex(index + 1) }} className={cn( - "inline-flex size-10 items-center justify-center rounded-full border bg-background text-sm font-medium shadow-sm transition-colors hover:bg-accent disabled:pointer-events-none disabled:opacity-40", + "inline-flex size-10 shrink-0 items-center justify-center rounded-full border bg-background text-sm font-medium shadow-sm transition-colors hover:bg-accent disabled:pointer-events-none disabled:opacity-40", className )} {...props} > - {children ?? ">"} + {children ?? } ) } diff --git a/apps/platform/components/ui/chip.tsx b/apps/platform/components/ui/chip.tsx new file mode 100644 index 0000000..1835be5 --- /dev/null +++ b/apps/platform/components/ui/chip.tsx @@ -0,0 +1,64 @@ +"use client" + +import * as React from "react" +import { X } from "lucide-react" + +import { cn } from "@/lib/utils" + +export type ChipProps = Omit, "children"> & { + children: React.ReactNode + selected?: boolean + variant?: "default" | "outline" + /** Shows a remove control (filters / tags). */ + onRemove?: () => void +} + +function Chip({ + className, + selected = false, + variant = "default", + onRemove, + children, + ...props +}: ChipProps) { + if (onRemove) { + return ( + + {children} + + + ) + } + + return ( + + ) +} + +export { Chip } diff --git a/apps/platform/components/ui/fab.tsx b/apps/platform/components/ui/fab.tsx new file mode 100644 index 0000000..8d433e2 --- /dev/null +++ b/apps/platform/components/ui/fab.tsx @@ -0,0 +1,127 @@ +"use client" + +import * as React from "react" + +import { cn } from "@/lib/utils" + +const sizeClass: Record<"sm" | "default" | "lg", string> = { + sm: "size-12 min-h-12 min-w-12", + default: "size-14 min-h-14 min-w-14", + lg: "size-16 min-h-16 min-w-16", +} + +export type FabProps = React.ComponentProps<"button"> & { + size?: "sm" | "default" | "lg" + variant?: "default" | "secondary" +} + +function Fab({ + className, + size = "default", + variant = "default", + type = "button", + ...props +}: FabProps) { + return ( +
+ ) +} + +export { Fab, FabMenu } diff --git a/apps/platform/content/docs/components/accordion-variants.mdx b/apps/platform/content/docs/components/accordion-variants.mdx new file mode 100644 index 0000000..cf55c67 --- /dev/null +++ b/apps/platform/content/docs/components/accordion-variants.mdx @@ -0,0 +1,118 @@ +--- +title: Accordion variants +description: Opinionated FAQ and layout presets built on Accordion—cards, grouped boxes, plus/minus, accent states, icons, profiles, and nested sections. +kind: component +category: Components +image: /logo.png +featured: false +dependencies: + - accordion +install: + - watermelon add accordion-variants +qrValue: showcase://components/accordion-variants +appStoreHref: https://apps.apple.com/app/expo-go/id982107779 +playStoreHref: https://play.google.com/store/apps/details?id=host.exp.exponent +sourceHref: https://github.com/vanshpatelx/RN/blob/main/packages/registry/src/components/ui/accordion-variants.tsx +registryHref: https://github.com/vanshpatelx/RN/blob/main/packages/registry/registry.json +--- + +import { CodeBlock } from "@/components/showcase/code-block"; +import { ApiTable, DocSection, DocSubsection } from "@/components/showcase/docs-primitives"; +import { CompPreview } from "@/components/mdx/component-preview"; +import { AccordionVariantsInlinePreview } from "@/components/showcase/component-live-preview"; + +## Import + + + + {`import { + AccordionVariantList, + AccordionVariantCards, + AccordionVariantGrouped, + AccordionVariantPlusMinus, + AccordionVariantAccent, + AccordionVariantLeadIcons, + AccordionVariantSubtitled, + AccordionVariantProfile, + AccordionVariantNested, + ACCORDION_VARIANT_FAQ_DEFAULT, +} from "@/components/ui/accordion-variants";`} + + + +## Usage + + + Each export renders the same default FAQ copy unless you pass **`items`**. Use **`AccordionVariantNested`** for a category accordion that contains another accordion inside the first panel. + + + `} + > + + + + + +## Presets + + + …", + default: "—", + description: "Hairline separators; default trigger + chevron.", + }, + { + prop: "AccordionVariantCards", + type: "() => …", + default: "—", + description: "Each item in its own rounded bordered card with vertical gap.", + }, + { + prop: "AccordionVariantGrouped", + type: "() => …", + default: "—", + description: "Single outer border with internal dividers.", + }, + { + prop: "AccordionVariantPlusMinus", + type: "() => …", + default: "—", + description: "Bold titles with + / − affordance.", + }, + { + prop: "AccordionVariantAccent", + type: "() => …", + default: "—", + description: "Orange header and rule when expanded (web uses Tailwind; native uses hex).", + }, + { + prop: "AccordionVariantLeadIcons", + type: "() => …", + default: "—", + description: "Leading icon + title + chevron.", + }, + { + prop: "AccordionVariantSubtitled", + type: "() => …", + default: "—", + description: "Circular icon, title, subtitle, plus/minus in a grouped shell.", + }, + { + prop: "AccordionVariantProfile", + type: "() => …", + default: "—", + description: "Avatar, name, email; bio in content.", + }, + { + prop: "AccordionVariantNested", + type: "() => …", + default: "—", + description: "Shipping category opens to a nested FAQ accordion.", + }, + ]} + /> + diff --git a/apps/platform/content/docs/components/chip.mdx b/apps/platform/content/docs/components/chip.mdx new file mode 100644 index 0000000..141bed8 --- /dev/null +++ b/apps/platform/content/docs/components/chip.mdx @@ -0,0 +1,36 @@ +--- +title: Chip +description: Selectable or dismissible pills for filters, tags, and compact choices with touch-friendly padding. +kind: component +category: Components +image: /logo.png +featured: false +dependencies: [] +install: + - watermelon add chip +qrValue: showcase://components/chip +appStoreHref: https://apps.apple.com/app/expo-go/id982107779 +playStoreHref: https://play.google.com/store/apps/details?id=host.exp.exponent +sourceHref: https://github.com/vanshpatelx/RN/blob/main/packages/registry/src/components/ui/chip.tsx +registryHref: https://github.com/vanshpatelx/RN/blob/main/packages/registry/registry.json +--- + +import { CodeBlock } from "@/components/showcase/code-block"; +import { ApiTable, DocSection, DocSubsection } from "@/components/showcase/docs-primitives"; + +## Import + + + + {`import { Chip } from "@/components/ui/chip";`} + + + +## Usage + + + Use string `children` for simple labels, or pass custom nodes. Toggle `selected` for filter state, `variant="outline"` for outlined chips, and `onRemove` for a trailing remove affordance. + + void", default: "-", description: "Shows a remove control beside the chip." }]} /> + + diff --git a/apps/platform/content/docs/components/fab.mdx b/apps/platform/content/docs/components/fab.mdx new file mode 100644 index 0000000..cd93b44 --- /dev/null +++ b/apps/platform/content/docs/components/fab.mdx @@ -0,0 +1,40 @@ +--- +title: FAB +description: Floating action button with elevation, plus an optional speed-dial FabMenu for multiple labeled actions. +kind: component +category: Components +image: /logo.png +featured: false +dependencies: [] +install: + - watermelon add fab +qrValue: showcase://components/fab +appStoreHref: https://apps.apple.com/app/expo-go/id982107779 +playStoreHref: https://play.google.com/store/apps/details?id=host.exp.exponent +sourceHref: https://github.com/vanshpatelx/RN/blob/main/packages/registry/src/components/ui/fab.tsx +registryHref: https://github.com/vanshpatelx/RN/blob/main/packages/registry/registry.json +--- + +import { CodeBlock } from "@/components/showcase/code-block"; +import { ApiTable, DocSection, DocSubsection } from "@/components/showcase/docs-primitives"; + +## Import + + + + {`import { Fab, FabMenu, type FabMenuAction } from "@/components/ui/fab";`} + + + +## Usage + + + Provide an icon (or glyph) as `children` and set `accessibilityLabel`. Sizes include `sm`, `default`, and `lg`. Use `variant="secondary"` for a softer surface. + + + + For a speed dial, use **`FabMenu`**: pass `actions` (`id`, `label`, optional `onPress` and `icon`) and optionally **`renderMain`** so the main button switches between open and closed icons. The menu uses an in-area dimmed backdrop; give the preview container enough height (about `220px`) so actions stack above the main FAB. + + ReactNode", default: "+ / ×", description: "Main FAB content for open vs closed." }, { prop: "accessibilityLabel", type: "string", default: "\"Open actions menu\"", description: "Main FAB when closed." }]} /> + + diff --git a/apps/platform/content/docs/components/list.mdx b/apps/platform/content/docs/components/list.mdx new file mode 100644 index 0000000..626b47d --- /dev/null +++ b/apps/platform/content/docs/components/list.mdx @@ -0,0 +1,44 @@ +--- +title: List +description: Settings-style rows with leading icon, title, description, and trailing slots sized for touch. +kind: component +category: Components +image: /logo.png +featured: false +dependencies: [] +install: + - watermelon add list +qrValue: showcase://components/list +appStoreHref: https://apps.apple.com/app/expo-go/id982107779 +playStoreHref: https://play.google.com/store/apps/details?id=host.exp.exponent +sourceHref: https://github.com/vanshpatelx/RN/blob/main/packages/registry/src/components/ui/list.tsx +registryHref: https://github.com/vanshpatelx/RN/blob/main/packages/registry/registry.json +--- + +import { CodeBlock } from "@/components/showcase/code-block"; +import { ApiTable, DocSection, DocSubsection } from "@/components/showcase/docs-primitives"; + +## Import + + + + {`import { + List, + ListItem, + ListItemContent, + ListItemDescription, + ListItemIcon, + ListItemTitle, + ListItemTrailing, +} from "@/components/ui/list";`} + + + +## Usage + + + Compose rows with `ListItem` (pressable by default) or `pressable={false}` for static detail rows. Aim for at least ~52pt row height for comfortable tapping. + + + + diff --git a/apps/platform/content/docs/components/meta.json b/apps/platform/content/docs/components/meta.json index ce5e845..a2d9c68 100644 --- a/apps/platform/content/docs/components/meta.json +++ b/apps/platform/content/docs/components/meta.json @@ -1,4 +1,4 @@ { "title": "Components", - "pages": ["button", "text", "input", "badge", "avatar", "breadcrumb", "card", "accordion", "alert-dialog", "alert", "aspect-ratio", "carousel", "collapsible", "command", "dialog", "drawer", "dropdown-menu", "form", "hover-card", "input-group", "kbd", "label", "popover", "pagination", "radio-group", "separator", "scroll-area", "sheet", "slider", "spinner", "tabs", "table", "textarea", "skeleton", "switch", "checkbox", "progress", "otp-input", "toggle", "toggle-group", "tooltip", "spotlight-button"] + "pages": ["button", "text", "input", "badge", "avatar", "breadcrumb", "card", "accordion", "accordion-variants", "alert-dialog", "alert", "aspect-ratio", "carousel", "collapsible", "command", "dialog", "drawer", "dropdown-menu", "form", "hover-card", "input-group", "kbd", "label", "popover", "pagination", "radio-group", "separator", "scroll-area", "sheet", "slider", "spinner", "tabs", "table", "textarea", "skeleton", "switch", "checkbox", "progress", "otp-input", "toggle", "toggle-group", "tooltip", "spotlight-button"] } diff --git a/apps/platform/content/docs/components/search-bar.mdx b/apps/platform/content/docs/components/search-bar.mdx new file mode 100644 index 0000000..9dd09f0 --- /dev/null +++ b/apps/platform/content/docs/components/search-bar.mdx @@ -0,0 +1,36 @@ +--- +title: Search Bar +description: Full-width search field with optional leading slot, clear affordance, and room for trailing accessories. +kind: component +category: Components +image: /logo.png +featured: false +dependencies: [] +install: + - watermelon add search-bar +qrValue: showcase://components/search-bar +appStoreHref: https://apps.apple.com/app/expo-go/id982107779 +playStoreHref: https://play.google.com/store/apps/details?id=host.exp.exponent +sourceHref: https://github.com/vanshpatelx/RN/blob/main/packages/registry/src/components/ui/search-bar.tsx +registryHref: https://github.com/vanshpatelx/RN/blob/main/packages/registry/registry.json +--- + +import { CodeBlock } from "@/components/showcase/code-block"; +import { ApiTable, DocSection, DocSubsection } from "@/components/showcase/docs-primitives"; + +## Import + + + + {`import { SearchBar } from "@/components/ui/search-bar";`} + + + +## Usage + + + Control text with `value` / `onChangeText`. Pass `onClear` to show a clear control when the query is non-empty. Use `leftSlot` for a search icon and `rightSlot` when you need an extra action when there is nothing to clear. + + void", default: "-", description: "When set, shows a clear affordance when value is non-empty." }, { prop: "leftSlot", type: "ReactNode", default: "-", description: "Leading accessory (e.g. search icon)." }, { prop: "rightSlot", type: "ReactNode", default: "-", description: "Trailing accessory when clear is not shown." }]} /> + + diff --git a/apps/platform/content/docs/components/segmented-control.mdx b/apps/platform/content/docs/components/segmented-control.mdx new file mode 100644 index 0000000..9c639b2 --- /dev/null +++ b/apps/platform/content/docs/components/segmented-control.mdx @@ -0,0 +1,36 @@ +--- +title: Segmented Control +description: Single-choice horizontal segments for filters, view modes, and compact toggles on small screens. +kind: component +category: Components +image: /logo.png +featured: false +dependencies: [] +install: + - watermelon add segmented-control +qrValue: showcase://components/segmented-control +appStoreHref: https://apps.apple.com/app/expo-go/id982107779 +playStoreHref: https://play.google.com/store/apps/details?id=host.exp.exponent +sourceHref: https://github.com/vanshpatelx/RN/blob/main/packages/registry/src/components/ui/segmented-control.tsx +registryHref: https://github.com/vanshpatelx/RN/blob/main/packages/registry/registry.json +--- + +import { CodeBlock } from "@/components/showcase/code-block"; +import { ApiTable, DocSection, DocSubsection } from "@/components/showcase/docs-primitives"; + +## Import + + + + {`import { SegmentedControl } from "@/components/ui/segmented-control";`} + + + +## Usage + + + Pass `options` with `value` / `label`, and control selection with `value` and `onValueChange`. + + void", default: "-", description: "Called when the user selects a segment." }]} /> + + diff --git a/apps/platform/lib/docs-navigation.ts b/apps/platform/lib/docs-navigation.ts index aa6f498..7be1dc4 100644 --- a/apps/platform/lib/docs-navigation.ts +++ b/apps/platform/lib/docs-navigation.ts @@ -1,4 +1,5 @@ import { docsSource } from "@/lib/docs-source"; +import { normalizeDocSlug } from "@/lib/normalize-doc-slug"; export type GuideLink = { title: string; @@ -57,7 +58,9 @@ export function getComponentLinks(): ComponentLink[] { const pageData = page.data as DocsNavigationPageData; return { - slug: page.slugs.at(-1) ?? page.url.split("/").at(-1) ?? "", + slug: normalizeDocSlug( + page.slugs.at(-1) ?? page.url.split("/").at(-1) ?? "", + ), title: pageData.title, description: pageData.description ?? "", category: pageData.category ?? COMPONENTS_CATEGORY, diff --git a/apps/platform/lib/normalize-doc-slug.ts b/apps/platform/lib/normalize-doc-slug.ts new file mode 100644 index 0000000..e3299f6 --- /dev/null +++ b/apps/platform/lib/normalize-doc-slug.ts @@ -0,0 +1,13 @@ +/** + * Registry and showcase use leaf slugs (`chip`). Fumadocs URLs may use a single + * segment like `components/chip` — normalize to the leaf for matching. + */ +export function normalizeDocSlug(segment: string | undefined): string { + if (!segment) { + return ""; + } + if (segment.includes("/")) { + return segment.split("/").filter(Boolean).pop() ?? segment; + } + return segment; +} diff --git a/apps/platform/lib/registry-data.json b/apps/platform/lib/registry-data.json index 990aee1..f851c03 100644 --- a/apps/platform/lib/registry-data.json +++ b/apps/platform/lib/registry-data.json @@ -63,7 +63,19 @@ "collapsible" ], "sourcePath": "components/ui/accordion.tsx", - "source": "/** @jsxImportSource react */\nimport * as React from 'react';\nimport { View, StyleSheet, type PressableProps, type ViewProps, type StyleProp, type ViewStyle } from \"react-native\";\n\nimport { Collapsible, CollapsibleContent, CollapsibleTrigger } from './collapsible';\nimport { Text } from './text';\nimport { useRegistryTheme } from '../../lib/theme';\nimport { TextStyleContext } from './text';\nimport { ChevronDown } from 'lucide-react-native';\n\ntype AccordionContextValue = {\n type: 'single' | 'multiple';\n value: string | string[] | undefined;\n onItemToggle: (value: string) => void;\n};\n\ntype AccordionItemContextValue = {\n value: string;\n open: boolean;\n};\n\ntype AccordionProps = ViewProps & {\n children?: React.ReactNode;\n type?: 'single' | 'multiple';\n value?: string | string[];\n defaultValue?: string | string[];\n onValueChange?: (value: string | string[]) => void;\n};\n\ntype AccordionItemProps = ViewProps & {\n children?: React.ReactNode;\n value: string;\n};\n\ntype AccordionTriggerProps = PressableProps & {\n asChild?: boolean;\n children?: React.ReactNode;\n};\n\ntype AccordionContentProps = ViewProps & {\n children?: React.ReactNode;\n};\n\nconst AccordionContext = React.createContext(null);\nconst AccordionItemContext = React.createContext(null);\n\nfunction useAccordionContext() {\n const context = React.useContext(AccordionContext);\n\n if (!context) {\n throw new Error('Accordion components must be used inside Accordion.');\n }\n\n return context;\n}\n\nfunction useAccordionItemContext() {\n const context = React.useContext(AccordionItemContext);\n\n if (!context) {\n throw new Error('Accordion item components must be used inside AccordionItem.');\n }\n\n return context;\n}\n\nfunction Accordion({\n children,\n type = 'single',\n value,\n defaultValue,\n onValueChange,\n ...props\n}: AccordionProps) {\n const [internalValue, setInternalValue] = React.useState(\n defaultValue,\n );\n const isControlled = value !== undefined;\n const resolvedValue = isControlled ? value : internalValue;\n\n const onItemToggle = React.useCallback(\n (item: string) => {\n let nextValue: string | string[];\n\n if (type === 'single') {\n nextValue = resolvedValue === item ? '' : item;\n } else {\n const current = Array.isArray(resolvedValue) ? resolvedValue : [];\n nextValue = current.includes(item)\n ? current.filter((entry) => entry !== item)\n : [...current, item];\n }\n\n if (!isControlled) {\n setInternalValue(nextValue);\n }\n\n onValueChange?.(nextValue);\n },\n [isControlled, onValueChange, resolvedValue, type],\n );\n\n return (\n \n {children}\n \n );\n}\n\nfunction AccordionItem({ children, value, style, ...props }: AccordionItemProps) {\n const context = useAccordionContext();\n const theme = useRegistryTheme();\n const open = Array.isArray(context.value)\n ? context.value.includes(value)\n : context.value === value;\n\n return (\n \n context.onItemToggle(value)} \n style={[styles.item, { borderBottomColor: theme.border }, style]} \n {...props}\n >\n {children}\n \n \n );\n}\n\nfunction AccordionTrigger({ style, children, asChild = false, ...props }: AccordionTriggerProps) {\n const context = useAccordionItemContext();\n const theme = useRegistryTheme();\n const content =\n typeof children === 'string' || typeof children === 'number' ? (\n {children}\n ) : (\n children\n );\n\n return (\n {\n const resolvedStyle = typeof style === \"function\" ? style(state) : style;\n return asChild ? (resolvedStyle as StyleProp) : [\n state.pressed && styles.triggerPressed,\n resolvedStyle as StyleProp,\n ];\n }}\n {...props}\n >\n {asChild ? (\n children\n ) : (\n \n \n {content}\n \n \n \n )}\n \n );\n}\n\nfunction AccordionContent({ style, children, ...props }: AccordionContentProps) {\n useAccordionItemContext();\n return (\n \n {children}\n \n );\n}\n\nconst styles = StyleSheet.create({\n item: {\n borderBottomWidth: StyleSheet.hairlineWidth,\n },\n triggerInner: {\n flexDirection: \"row\",\n alignItems: \"center\",\n justifyContent: \"space-between\",\n paddingVertical: 16,\n width: \"100%\",\n },\n triggerPressed: {\n opacity: 0.8,\n },\n content: {\n paddingBottom: 16,\n },\n});\n\nexport { Accordion, AccordionContent, AccordionItem, AccordionTrigger };\nexport type {\n AccordionContentProps,\n AccordionItemProps,\n AccordionProps,\n AccordionTriggerProps,\n};\n" + "source": "/** @jsxImportSource react */\nimport * as React from 'react';\nimport { View, StyleSheet, type PressableProps, type ViewProps, type StyleProp, type ViewStyle } from \"react-native\";\n\nimport { Collapsible, CollapsibleContent, CollapsibleTrigger } from './collapsible';\nimport { Text } from './text';\nimport { useRegistryTheme } from '../../lib/theme';\nimport { TextStyleContext } from './text';\nimport { ChevronDown } from 'lucide-react-native';\n\ntype AccordionContextValue = {\n type: 'single' | 'multiple';\n value: string | string[] | undefined;\n onItemToggle: (value: string) => void;\n};\n\ntype AccordionItemContextValue = {\n value: string;\n open: boolean;\n};\n\ntype AccordionProps = ViewProps & {\n children?: React.ReactNode;\n type?: 'single' | 'multiple';\n value?: string | string[];\n defaultValue?: string | string[];\n onValueChange?: (value: string | string[]) => void;\n};\n\ntype AccordionItemProps = ViewProps & {\n children?: React.ReactNode;\n value: string;\n};\n\ntype AccordionTriggerProps = PressableProps & {\n asChild?: boolean;\n children?: React.ReactNode;\n};\n\ntype AccordionContentProps = ViewProps & {\n children?: React.ReactNode;\n};\n\nconst AccordionContext = React.createContext(null);\nconst AccordionItemContext = React.createContext(null);\n\nfunction useAccordionContext() {\n const context = React.useContext(AccordionContext);\n\n if (!context) {\n throw new Error('Accordion components must be used inside Accordion.');\n }\n\n return context;\n}\n\nfunction useAccordionItemContext() {\n const context = React.useContext(AccordionItemContext);\n\n if (!context) {\n throw new Error('Accordion item components must be used inside AccordionItem.');\n }\n\n return context;\n}\n\nfunction Accordion({\n children,\n type = 'single',\n value,\n defaultValue,\n onValueChange,\n ...props\n}: AccordionProps) {\n const [internalValue, setInternalValue] = React.useState(\n defaultValue,\n );\n const isControlled = value !== undefined;\n const resolvedValue = isControlled ? value : internalValue;\n\n const onItemToggle = React.useCallback(\n (item: string) => {\n let nextValue: string | string[];\n\n if (type === 'single') {\n nextValue = resolvedValue === item ? '' : item;\n } else {\n const current = Array.isArray(resolvedValue) ? resolvedValue : [];\n nextValue = current.includes(item)\n ? current.filter((entry) => entry !== item)\n : [...current, item];\n }\n\n if (!isControlled) {\n setInternalValue(nextValue);\n }\n\n onValueChange?.(nextValue);\n },\n [isControlled, onValueChange, resolvedValue, type],\n );\n\n return (\n \n {children}\n \n );\n}\n\nfunction AccordionItem({ children, value, style, ...props }: AccordionItemProps) {\n const context = useAccordionContext();\n const theme = useRegistryTheme();\n const open = Array.isArray(context.value)\n ? context.value.includes(value)\n : context.value === value;\n\n return (\n \n context.onItemToggle(value)} \n style={[styles.item, { borderBottomColor: theme.border }, style]} \n {...props}\n >\n {children}\n \n \n );\n}\n\nfunction AccordionTrigger({ style, children, asChild = false, ...props }: AccordionTriggerProps) {\n const context = useAccordionItemContext();\n const theme = useRegistryTheme();\n const content =\n typeof children === 'string' || typeof children === 'number' ? (\n {children}\n ) : (\n children\n );\n\n return (\n {\n const resolvedStyle = typeof style === \"function\" ? style(state) : style;\n return asChild ? (resolvedStyle as StyleProp) : [\n state.pressed && styles.triggerPressed,\n resolvedStyle as StyleProp,\n ];\n }}\n {...props}\n >\n {asChild ? (\n children\n ) : (\n \n \n {content}\n \n \n \n )}\n \n );\n}\n\nfunction AccordionContent({ style, children, ...props }: AccordionContentProps) {\n useAccordionItemContext();\n return (\n \n {children}\n \n );\n}\n\nconst styles = StyleSheet.create({\n item: {\n borderBottomWidth: StyleSheet.hairlineWidth,\n },\n triggerInner: {\n flexDirection: \"row\",\n alignItems: \"center\",\n justifyContent: \"space-between\",\n paddingVertical: 16,\n width: \"100%\",\n },\n triggerPressed: {\n opacity: 0.8,\n },\n content: {\n paddingBottom: 16,\n },\n});\n\nfunction useAccordionItemOpen(): boolean {\n return useAccordionItemContext().open;\n}\n\nexport { Accordion, AccordionContent, AccordionItem, AccordionTrigger, useAccordionItemOpen };\nexport type {\n AccordionContentProps,\n AccordionItemProps,\n AccordionProps,\n AccordionTriggerProps,\n};\n" + }, + { + "slug": "accordion-variants", + "name": "accordion-variants", + "dependencies": [], + "registryDependencies": [ + "accordion", + "avatar", + "text" + ], + "sourcePath": "components/ui/accordion-variants.tsx", + "source": "/** @jsxImportSource react */\nimport * as React from 'react';\nimport { Pressable, StyleSheet, View, type ViewProps } from 'react-native';\nimport { ChevronDown, Headphones, Package, RefreshCw } from 'lucide-react-native';\n\nimport {\n Accordion,\n AccordionContent,\n AccordionItem,\n AccordionTrigger,\n useAccordionItemOpen,\n type AccordionProps,\n} from './accordion';\nimport { Avatar, AvatarFallback } from './avatar';\nimport { Text } from './text';\nimport { useRegistryTheme } from '../../lib/theme';\n\nconst ACCENT = '#ea580c';\n\nexport type AccordionVariantFaqItem = {\n value: string;\n title: string;\n description: string;\n};\n\nexport const ACCORDION_VARIANT_FAQ_DEFAULT: AccordionVariantFaqItem[] = [\n {\n value: 'track',\n title: 'How do I track my order?',\n description:\n 'Open Orders in your account and select the shipment to see live status and carrier tracking.',\n },\n {\n value: 'returns',\n title: 'What is your return policy?',\n description:\n 'Unopened items can be returned within 30 days. Start a return from the order detail page.',\n },\n {\n value: 'support',\n title: 'How can I contact customer support?',\n description:\n 'Use in-app chat weekdays 9–6, or email support@example.com—we reply within one business day.',\n },\n];\n\ntype VariantBaseProps = Omit & {\n items?: AccordionVariantFaqItem[];\n};\n\nfunction resolveDefaultValue(\n type: AccordionProps['type'],\n items: { value: string }[],\n explicit?: string | string[],\n) {\n if (explicit !== undefined) {\n return explicit;\n }\n if (type === 'multiple') {\n return [items[0]?.value].filter(Boolean) as string[];\n }\n return items[0]?.value ?? '';\n}\n\nfunction FaqContent({ text }: { text: string }) {\n return (\n \n {text}\n \n );\n}\n\n/** Minimal list: hairline separators only (default accordion rhythm). */\nexport function AccordionVariantList({\n items = ACCORDION_VARIANT_FAQ_DEFAULT,\n type = 'single',\n defaultValue,\n ...rest\n}: VariantBaseProps) {\n const dv = resolveDefaultValue(type, items, defaultValue);\n return (\n \n {items.map((item) => (\n \n {item.title}\n \n \n \n \n ))}\n \n );\n}\n\n/** Each item is its own rounded card with gap between. */\nexport function AccordionVariantCards({\n items = ACCORDION_VARIANT_FAQ_DEFAULT,\n type = 'single',\n defaultValue,\n ...rest\n}: VariantBaseProps) {\n const theme = useRegistryTheme();\n const dv = resolveDefaultValue(type, items, defaultValue);\n return (\n \n \n {items.map((item) => (\n \n \n {item.title}\n \n \n \n \n \n ))}\n
\n \n );\n}\n\n/** Single bordered container with internal dividers. */\nexport function AccordionVariantGrouped({\n items = ACCORDION_VARIANT_FAQ_DEFAULT,\n type = 'single',\n defaultValue,\n ...rest\n}: VariantBaseProps) {\n const theme = useRegistryTheme();\n const dv = resolveDefaultValue(type, items, defaultValue);\n return (\n \n \n {items.map((item, index) => (\n \n {item.title}\n \n \n \n \n ))}\n \n \n );\n}\n\nfunction PlusMinusTrigger({ title }: { title: string }) {\n const open = useAccordionItemOpen();\n const theme = useRegistryTheme();\n return (\n \n [styles.triggerRow, pressed && styles.pressed]}>\n {title}\n {open ? '−' : '+'}\n \n \n );\n}\n\n/** Bold titles with plus / minus on the right. */\nexport function AccordionVariantPlusMinus({\n items = ACCORDION_VARIANT_FAQ_DEFAULT,\n type = 'single',\n defaultValue,\n ...rest\n}: VariantBaseProps) {\n const dv = resolveDefaultValue(type, items, defaultValue);\n return (\n \n {items.map((item) => (\n \n \n \n \n \n \n ))}\n \n );\n}\n\nfunction AccentTrigger({ title }: { title: string }) {\n const open = useAccordionItemOpen();\n const theme = useRegistryTheme();\n return (\n \n [pressed && styles.pressed]}>\n \n \n {title}\n \n \n \n \n \n );\n}\n\n/** Accent header and rule when expanded. */\nexport function AccordionVariantAccent({\n items = ACCORDION_VARIANT_FAQ_DEFAULT,\n type = 'single',\n defaultValue,\n ...rest\n}: VariantBaseProps) {\n const dv = resolveDefaultValue(type, items, defaultValue);\n return (\n \n {items.map((item) => (\n \n \n \n \n \n \n ))}\n \n );\n}\n\nconst LEAD_ICONS = [Package, RefreshCw, Headphones] as const;\n\nfunction LeadIconTrigger({ title, Icon }: { title: string; Icon: (typeof LEAD_ICONS)[number] }) {\n const open = useAccordionItemOpen();\n const theme = useRegistryTheme();\n return (\n \n [styles.triggerRow, pressed && styles.pressed]}>\n \n \n {title}\n \n \n \n \n );\n}\n\n/** Leading stroke icon + title + chevron. */\nexport function AccordionVariantLeadIcons({\n items = ACCORDION_VARIANT_FAQ_DEFAULT,\n type = 'single',\n defaultValue,\n ...rest\n}: VariantBaseProps) {\n const dv = resolveDefaultValue(type, items, defaultValue);\n return (\n \n {items.map((item, index) => {\n const Icon = LEAD_ICONS[index % LEAD_ICONS.length];\n return (\n \n \n \n \n \n \n );\n })}\n \n );\n}\n\nexport type AccordionVariantSubtitledItem = {\n value: string;\n title: string;\n subtitle: string;\n description: string;\n};\n\nexport const ACCORDION_VARIANT_SUBTITLED_DEFAULT: AccordionVariantSubtitledItem[] = [\n {\n value: 'ship',\n title: 'Shipping updates',\n subtitle: 'Shipping & delivery',\n description: 'We ship within 2 business days. You will get an email when the label is created.',\n },\n {\n value: 'intl',\n title: 'International orders',\n subtitle: 'Shipping & delivery',\n description: 'Duties and taxes may apply based on your country. Estimates appear at checkout.',\n },\n {\n value: 'pickup',\n title: 'Pickup locations',\n subtitle: 'Shipping & delivery',\n description: 'Select a locker or store pickup during checkout where available.',\n },\n];\n\ntype SubtitledProps = Omit & {\n items?: AccordionVariantSubtitledItem[];\n};\n\nfunction SubtitledTrigger({\n title,\n subtitle,\n Icon,\n}: {\n title: string;\n subtitle: string;\n Icon: (typeof LEAD_ICONS)[number];\n}) {\n const open = useAccordionItemOpen();\n const theme = useRegistryTheme();\n return (\n \n [styles.triggerRow, pressed && styles.pressed]}>\n \n \n \n \n \n {title}\n {subtitle}\n \n \n {open ? '−' : '+'}\n \n \n );\n}\n\n/** Bordered group with circular icon, title, subtitle, and plus/minus. */\nexport function AccordionVariantSubtitled({\n items = ACCORDION_VARIANT_SUBTITLED_DEFAULT,\n type = 'single',\n defaultValue,\n ...rest\n}: SubtitledProps) {\n const theme = useRegistryTheme();\n const dv = resolveDefaultValue(type, items, defaultValue);\n return (\n \n \n {items.map((item, index) => {\n const Icon = LEAD_ICONS[index % LEAD_ICONS.length];\n return (\n \n \n \n \n \n \n );\n })}\n \n \n );\n}\n\nexport type AccordionVariantProfileItem = {\n value: string;\n name: string;\n email: string;\n bio: string;\n};\n\nexport const ACCORDION_VARIANT_PROFILE_DEFAULT: AccordionVariantProfileItem[] = [\n {\n value: 'u1',\n name: 'Alex Morgan',\n email: 'alex@example.com',\n bio: 'Product designer focused on systems and accessibility. Previously at a commerce platform, now building mobile-first patterns.',\n },\n {\n value: 'u2',\n name: 'Jordan Lee',\n email: 'jordan@example.com',\n bio: 'Engineer working on performance and offline support. Open source contributor and conference speaker.',\n },\n];\n\ntype ProfileProps = Omit & {\n items?: AccordionVariantProfileItem[];\n};\n\nfunction ProfileTrigger({ name, email }: { name: string; email: string }) {\n const theme = useRegistryTheme();\n const open = useAccordionItemOpen();\n const initials = name\n .split(' ')\n .map((p) => p[0])\n .join('')\n .slice(0, 2)\n .toUpperCase();\n return (\n \n [styles.triggerRow, pressed && styles.pressed]}>\n \n \n \n \n {initials}\n \n \n \n \n {name}\n {email}\n \n \n \n \n \n );\n}\n\n/** Avatar, name, email in the header; bio in the panel. */\nexport function AccordionVariantProfile({\n items = ACCORDION_VARIANT_PROFILE_DEFAULT,\n type = 'single',\n defaultValue,\n ...rest\n}: ProfileProps) {\n const dv = resolveDefaultValue(type, items, defaultValue);\n return (\n \n {items.map((item) => (\n \n \n \n \n {item.bio}\n \n \n \n ))}\n \n );\n}\n\n/** Outer categories with a nested accordion for detail questions. */\nexport function AccordionVariantNested(props: ViewProps) {\n const theme = useRegistryTheme();\n return (\n \n \n \n \n Shipping & delivery\n \n \n \n \n How do I track my order?\n \n \n \n \n \n When will my package arrive?\n \n \n \n \n \n \n \n \n \n Returns & refunds\n \n \n Start a return from your order history. Refunds post after we receive the item.\n \n \n \n \n \n \n );\n}\n\nconst styles = StyleSheet.create({\n faqBody: {\n lineHeight: 22,\n fontSize: 14,\n },\n cardsStack: {\n gap: 12,\n },\n cardWrap: {\n borderRadius: 12,\n borderWidth: StyleSheet.hairlineWidth,\n overflow: 'hidden',\n },\n cardItem: {\n borderBottomWidth: 0,\n },\n groupedOuter: {\n borderRadius: 12,\n borderWidth: StyleSheet.hairlineWidth,\n overflow: 'hidden',\n },\n triggerRow: {\n flexDirection: 'row',\n alignItems: 'center',\n justifyContent: 'space-between',\n paddingVertical: 16,\n width: '100%',\n },\n pressed: {\n opacity: 0.85,\n },\n titleSemibold: {\n fontSize: 15,\n fontWeight: '600',\n flex: 1,\n paddingRight: 12,\n },\n titleMedium: {\n fontSize: 15,\n fontWeight: '500',\n },\n plusMinus: {\n fontSize: 22,\n fontWeight: '400',\n width: 28,\n textAlign: 'center',\n },\n leadLeft: {\n flexDirection: 'row',\n alignItems: 'center',\n gap: 10,\n flex: 1,\n },\n iconCircle: {\n width: 36,\n height: 36,\n borderRadius: 18,\n alignItems: 'center',\n justifyContent: 'center',\n },\n avatar: {\n width: 44,\n height: 44,\n },\n nestedPad: {\n paddingLeft: 4,\n paddingBottom: 8,\n },\n});\n" }, { "slug": "alert-dialog", @@ -83,7 +95,7 @@ "text" ], "sourcePath": "components/ui/alert.tsx", - "source": "/** @jsxImportSource react */\nimport { useRegistryTheme } from '../../lib/theme';\nimport * as React from 'react';\nimport { StyleSheet, View, type TextProps, type ViewProps } from 'react-native';\n\nimport { Text } from './text';\n\ntype AlertVariant = 'default' | 'destructive';\n\nconst AlertVariantContext = React.createContext('default');\n\nfunction useAlertVariant() {\n return React.useContext(AlertVariantContext);\n}\n\ntype AlertProps = ViewProps & {\n className?: string;\n variant?: AlertVariant;\n};\n\nfunction Alert({ style, variant = 'default', children, ...props }: AlertProps) {\n const theme = useRegistryTheme();\n const destructive = variant === 'destructive';\n\n return (\n \n \n {children}\n \n \n );\n}\n\nfunction AlertTitle({ style, ...props }: TextProps & { className?: string }) {\n const theme = useRegistryTheme();\n const variant = useAlertVariant();\n return (\n \n );\n}\n\nfunction AlertDescription({ style, ...props }: TextProps & { className?: string }) {\n const theme = useRegistryTheme();\n const variant = useAlertVariant();\n return (\n \n );\n}\n\nconst styles = StyleSheet.create({\n root: {\n borderRadius: 10,\n borderWidth: StyleSheet.hairlineWidth * 2,\n paddingHorizontal: 16,\n paddingVertical: 14,\n gap: 6,\n alignSelf: 'stretch',\n },\n title: {\n fontSize: 15,\n fontWeight: '600',\n lineHeight: 20,\n },\n description: {\n fontSize: 14,\n lineHeight: 20,\n },\n});\n\nexport { Alert, AlertDescription, AlertTitle };\nexport type { AlertProps };\n" + "source": "/** @jsxImportSource react */\nimport { useRegistryTheme } from '../../lib/theme';\nimport * as React from 'react';\nimport { StyleSheet, View, type TextProps, type ViewProps } from 'react-native';\n\nimport { Text } from './text';\n\ntype AlertVariant = 'default' | 'destructive';\n\nconst AlertVariantContext = React.createContext('default');\n\nfunction useAlertVariant() {\n return React.useContext(AlertVariantContext);\n}\n\ntype AlertProps = ViewProps & {\n className?: string;\n variant?: AlertVariant;\n};\n\nfunction Alert({ style, variant = 'default', children, ...props }: AlertProps) {\n const theme = useRegistryTheme();\n const destructive = variant === 'destructive';\n\n return (\n \n \n {children}\n \n \n );\n}\n\nfunction AlertTitle({ style, ...props }: TextProps & { className?: string }) {\n const theme = useRegistryTheme();\n const variant = useAlertVariant();\n return (\n \n );\n}\n\nfunction AlertDescription({ style, ...props }: TextProps & { className?: string }) {\n const theme = useRegistryTheme();\n const variant = useAlertVariant();\n return (\n \n );\n}\n\nconst styles = StyleSheet.create({\n root: {\n borderRadius: 12,\n borderWidth: 1,\n paddingHorizontal: 18,\n paddingVertical: 16,\n gap: 8,\n alignSelf: 'stretch',\n },\n rootDestructive: {\n borderWidth: 1.5,\n },\n title: {\n fontSize: 16,\n fontWeight: '700',\n lineHeight: 22,\n },\n description: {\n fontSize: 15,\n lineHeight: 22,\n },\n});\n\nexport { Alert, AlertDescription, AlertTitle };\nexport type { AlertProps };\n" }, { "slug": "aspect-ratio", @@ -119,7 +131,7 @@ "input" ], "sourcePath": "components/ui/command.tsx", - "source": "import * as React from 'react';\nimport { Pressable, StyleSheet, View, type PressableProps, type ViewProps } from 'react-native';\n\nimport { Input } from './input';\nimport { Text } from './text';\nimport { useRegistryTheme } from '../../lib/theme';\n\ntype CommandContextValue = {\n query: string;\n setQuery: (value: string) => void;\n};\n\nconst CommandContext = React.createContext(null);\n\nfunction useCommandContext() {\n const context = React.useContext(CommandContext);\n\n if (!context) {\n throw new Error('Command components must be used inside Command.');\n }\n\n return context;\n}\n\nfunction Command({ children, ...props }: ViewProps) {\n const [query, setQuery] = React.useState('');\n\n return (\n \n \n {children}\n \n \n );\n}\n\nfunction CommandInput(props: React.ComponentProps) {\n const { query, setQuery } = useCommandContext();\n return (\n \n );\n}\n\nfunction CommandList({ style, ...props }: ViewProps) {\n return ;\n}\n\nfunction CommandEmpty(props: React.ComponentProps) {\n return ;\n}\n\nfunction CommandGroup({ style, ...props }: ViewProps) {\n return ;\n}\n\nfunction CommandSeparator({ style, ...props }: ViewProps) {\n const theme = useRegistryTheme();\n return ;\n}\n\ntype CommandItemProps = PressableProps & {\n value: string;\n keywords?: string[];\n onSelect?: (value: string) => void;\n children?: React.ReactNode;\n};\n\nfunction CommandItem({ value, keywords, onPress, onSelect, children, style, ...props }: CommandItemProps) {\n const { query } = useCommandContext();\n const theme = useRegistryTheme();\n const haystack = [value, ...(keywords ?? [])].join(' ').toLowerCase();\n const visible = haystack.includes(query.trim().toLowerCase());\n\n if (!visible) {\n return null;\n }\n\n return (\n {\n onPress?.(event);\n onSelect?.(value);\n }}\n style={({ pressed }) => [\n styles.item,\n { backgroundColor: pressed ? theme.muted : 'transparent' },\n style as any,\n ]}\n {...props}\n >\n {typeof children === 'string' ? {children} : children}\n \n );\n}\n\nfunction CommandShortcut(props: React.ComponentProps) {\n return ;\n}\n\nconst styles = StyleSheet.create({\n command: {\n gap: 12,\n },\n list: {\n gap: 4,\n },\n group: {\n gap: 6,\n },\n separator: {\n height: StyleSheet.hairlineWidth,\n width: '100%',\n marginVertical: 4,\n },\n item: {\n minHeight: 36,\n borderRadius: 8,\n paddingHorizontal: 10,\n paddingVertical: 8,\n flexDirection: 'row',\n alignItems: 'center',\n gap: 10,\n },\n shortcut: {\n marginLeft: 'auto',\n },\n});\n\nexport {\n Command,\n CommandEmpty,\n CommandGroup,\n CommandInput,\n CommandItem,\n CommandList,\n CommandSeparator,\n CommandShortcut,\n};\n" + "source": "import * as React from 'react';\nimport { Pressable, StyleSheet, View, type PressableProps, type ViewProps } from 'react-native';\n\nimport { Input } from './input';\nimport { Text } from './text';\nimport { useRegistryTheme } from '../../lib/theme';\n\ntype CommandContextValue = {\n query: string;\n setQuery: (value: string) => void;\n visibleMatchCount: number;\n setItemMatch: (id: string, matched: boolean | null) => void;\n};\n\nconst CommandContext = React.createContext(null);\n\nfunction useCommandContext() {\n const context = React.useContext(CommandContext);\n\n if (!context) {\n throw new Error('Command components must be used inside Command.');\n }\n\n return context;\n}\n\ntype CommandProps = ViewProps & {\n value?: string;\n defaultValue?: string;\n onValueChange?: (value: string) => void;\n};\n\nfunction Command({ value, defaultValue = '', onValueChange, children, ...props }: CommandProps) {\n const [internalQuery, setInternalQuery] = React.useState(defaultValue);\n const isControlled = value !== undefined;\n const query = isControlled ? value : internalQuery;\n const setQuery = React.useCallback(\n (next: string) => {\n if (!isControlled) {\n setInternalQuery(next);\n }\n onValueChange?.(next);\n },\n [isControlled, onValueChange],\n );\n\n const matchesRef = React.useRef(new Map());\n const [visibleMatchCount, setVisibleMatchCount] = React.useState(0);\n\n const setItemMatch = React.useCallback((id: string, matched: boolean | null) => {\n if (matched === null) {\n matchesRef.current.delete(id);\n } else {\n matchesRef.current.set(id, matched);\n }\n let count = 0;\n for (const v of matchesRef.current.values()) {\n if (v) count += 1;\n }\n setVisibleMatchCount(count);\n }, []);\n\n return (\n \n \n {children}\n \n \n );\n}\n\nfunction CommandInput(props: React.ComponentProps) {\n const { query, setQuery } = useCommandContext();\n return (\n \n );\n}\n\nfunction CommandList({ style, ...props }: ViewProps) {\n return ;\n}\n\nfunction CommandEmpty(props: React.ComponentProps) {\n const { query, visibleMatchCount } = useCommandContext();\n if (!query.trim() || visibleMatchCount > 0) {\n return null;\n }\n return ;\n}\n\nfunction CommandGroup({ style, ...props }: ViewProps) {\n return ;\n}\n\nfunction CommandSeparator({ style, ...props }: ViewProps) {\n const theme = useRegistryTheme();\n return ;\n}\n\ntype CommandItemProps = PressableProps & {\n value: string;\n keywords?: string[];\n onSelect?: (value: string) => void;\n children?: React.ReactNode;\n};\n\nfunction CommandItem({ value, keywords, onPress, onSelect, children, style, ...props }: CommandItemProps) {\n const { query, setItemMatch } = useCommandContext();\n const theme = useRegistryTheme();\n const id = React.useId();\n const haystack = [value, ...(keywords ?? [])].join(' ').toLowerCase();\n const visible = haystack.includes(query.trim().toLowerCase());\n\n React.useEffect(() => {\n setItemMatch(id, visible);\n return () => setItemMatch(id, null);\n }, [id, setItemMatch, visible]);\n\n if (!visible) {\n return null;\n }\n\n return (\n {\n onPress?.(event);\n onSelect?.(value);\n }}\n style={({ pressed }) => [\n styles.item,\n { backgroundColor: pressed ? theme.muted : 'transparent' },\n style as any,\n ]}\n {...props}\n >\n {typeof children === 'string' ? {children} : children}\n \n );\n}\n\nfunction CommandShortcut(props: React.ComponentProps) {\n return ;\n}\n\nconst styles = StyleSheet.create({\n command: {\n gap: 12,\n },\n list: {\n gap: 4,\n },\n group: {\n gap: 6,\n },\n separator: {\n height: StyleSheet.hairlineWidth,\n width: '100%',\n marginVertical: 4,\n },\n item: {\n minHeight: 48,\n borderRadius: 10,\n paddingHorizontal: 14,\n paddingVertical: 10,\n flexDirection: 'row',\n alignItems: 'center',\n gap: 10,\n },\n shortcut: {\n marginLeft: 'auto',\n },\n});\n\nexport {\n Command,\n CommandEmpty,\n CommandGroup,\n CommandInput,\n CommandItem,\n CommandList,\n CommandSeparator,\n CommandShortcut,\n};\n" }, { "slug": "label", @@ -243,7 +255,7 @@ "dependencies": [], "registryDependencies": [], "sourcePath": "components/ui/dropdown-menu.tsx", - "source": "import * as React from 'react';\nimport {\n Modal,\n Pressable,\n StyleSheet,\n Text,\n View,\n useWindowDimensions,\n type LayoutChangeEvent,\n type PressableProps,\n type TextProps,\n type ViewProps,\n} from 'react-native';\n\nimport { Text as UiText, TextStyleContext } from './text';\nimport { useRegistryTheme } from '../../lib/theme';\n\ntype DropdownMenuContextValue = {\n open: boolean;\n setOpen: React.Dispatch>;\n triggerLayout: { x: number; y: number; width: number; height: number };\n setTriggerLayout: React.Dispatch<\n React.SetStateAction<{\n x: number;\n y: number;\n width: number;\n height: number;\n }>\n >;\n syncTriggerLayout: () => void;\n};\n\nconst DropdownMenuContext = React.createContext(null);\n\nfunction useDropdownMenuContext() {\n const context = React.useContext(DropdownMenuContext);\n\n if (!context) {\n throw new Error('Dropdown menu components must be used inside DropdownMenu.');\n }\n\n return context;\n}\n\ntype MenuRadioContextValue = {\n value?: string;\n onValueChange?: (value: string) => void;\n};\n\nconst MenuRadioContext = React.createContext(null);\n\nfunction useMenuRadioContext() {\n return React.useContext(MenuRadioContext);\n}\n\nfunction DropdownMenu({\n children,\n defaultOpen = false,\n open,\n onOpenChange,\n}: {\n children?: React.ReactNode;\n defaultOpen?: boolean;\n open?: boolean;\n onOpenChange?: (open: boolean) => void;\n}) {\n const [internalOpen, setInternalOpen] = React.useState(defaultOpen);\n const [triggerLayout, setTriggerLayout] = React.useState({\n x: 0,\n y: 0,\n width: 0,\n height: 0,\n });\n const isControlled = open !== undefined;\n const resolvedOpen = isControlled ? open : internalOpen;\n const triggerRef = React.useRef(null);\n\n const setOpen = React.useCallback>>(\n (value) => {\n const nextValue =\n typeof value === 'function' ? value(resolvedOpen) : value;\n\n if (!isControlled) {\n setInternalOpen(nextValue);\n }\n\n onOpenChange?.(nextValue);\n },\n [isControlled, onOpenChange, resolvedOpen],\n );\n\n const syncTriggerLayout = React.useCallback(() => {\n triggerRef.current?.measureInWindow((x, y, width, height) => {\n setTriggerLayout({\n x,\n y,\n width: Math.max(width, 0),\n height: Math.max(height, 0),\n });\n });\n }, []);\n\n return (\n \n \n {children}\n \n \n );\n}\n\nfunction DropdownMenuTrigger({\n asChild = false,\n children,\n onPress,\n ...props\n}: PressableProps & { asChild?: boolean; children?: React.ReactNode }) {\n const { setOpen, setTriggerLayout, syncTriggerLayout } = useDropdownMenuContext();\n\n const handlePress = React.useCallback(\n (event: Parameters>[0]) => {\n onPress?.(event);\n syncTriggerLayout();\n setOpen((value) => !value);\n },\n [onPress, setOpen, syncTriggerLayout],\n );\n\n const handleLayout = React.useCallback(\n (event: LayoutChangeEvent) => {\n requestAnimationFrame(syncTriggerLayout);\n setTriggerLayout((current) => ({\n ...current,\n width: event.nativeEvent.layout.width,\n height: event.nativeEvent.layout.height,\n }));\n },\n [setTriggerLayout, syncTriggerLayout],\n );\n\n if (asChild && React.isValidElement(children)) {\n return (\n \n {React.cloneElement(children as React.ReactElement<{ onPress?: PressableProps['onPress'] }>, {\n onPress: handlePress,\n })}\n \n );\n }\n\n return (\n \n \n {children}\n \n \n );\n}\n\ntype ContentAlign = 'start' | 'end' | 'center';\n\ntype DropdownMenuContentProps = ViewProps & {\n /** Horizontal alignment of the menu relative to the trigger (shadcn-style). */\n align?: ContentAlign;\n /** Gap between trigger and menu, in px. */\n sideOffset?: number;\n minWidth?: number;\n maxWidth?: number;\n};\n\nfunction DropdownMenuContent({\n children,\n style,\n align = 'start',\n sideOffset = 10,\n minWidth: minWidthProp,\n maxWidth: maxWidthProp,\n ...props\n}: DropdownMenuContentProps) {\n const theme = useRegistryTheme();\n const { width: screenWidth, height: screenHeight } = useWindowDimensions();\n const { open, triggerLayout, setOpen } = useDropdownMenuContext();\n const [contentLayout, setContentLayout] = React.useState({ width: 212, height: 0 });\n\n if (!open) {\n return null;\n }\n\n const margin = 16;\n const gap = sideOffset;\n const minFromTrigger = Math.max(triggerLayout.width + 48, 212);\n const minW = minWidthProp ?? Math.min(minFromTrigger, screenWidth - margin * 2);\n const maxW = maxWidthProp ?? Math.min(320, screenWidth - margin * 2);\n const contentWidth = Math.min(Math.max(contentLayout.width || minW, minW), maxW);\n\n let left = triggerLayout.x;\n if (align === 'end') {\n left = triggerLayout.x + triggerLayout.width - contentWidth;\n } else if (align === 'center') {\n left = triggerLayout.x + triggerLayout.width / 2 - contentWidth / 2;\n }\n\n left = Math.min(Math.max(left, margin), screenWidth - contentWidth - margin);\n\n const belowTop = triggerLayout.y + triggerLayout.height + gap;\n const aboveTop = triggerLayout.y - contentLayout.height - gap;\n const top =\n contentLayout.height > 0 &&\n belowTop + contentLayout.height > screenHeight - margin &&\n aboveTop >= margin\n ? aboveTop\n : Math.min(Math.max(belowTop, margin), screenHeight - contentLayout.height - margin);\n\n return (\n setOpen(false)}>\n \n setOpen(false)} />\n {\n const { width, height } = event.nativeEvent.layout;\n setContentLayout({ width, height });\n }}\n {...props}\n >\n {children}\n \n \n \n );\n}\n\nfunction DropdownMenuGroup(props: ViewProps) {\n return ;\n}\n\nfunction DropdownMenuLabel(props: TextProps) {\n const theme = useRegistryTheme();\n\n return (\n \n );\n}\n\ntype ItemVariant = 'default' | 'destructive';\n\ntype DropdownMenuItemProps = PressableProps & {\n children?: React.ReactNode;\n /** Adds leading inset for icon rows (shadcn `inset`). */\n inset?: boolean;\n variant?: ItemVariant;\n /** When false, the menu stays open after press (default: true). */\n closeOnPress?: boolean;\n};\n\nfunction DropdownMenuItem({\n children,\n onPress,\n style,\n disabled,\n inset,\n variant = 'default',\n closeOnPress = true,\n ...props\n}: DropdownMenuItemProps) {\n const theme = useRegistryTheme();\n const { setOpen } = useDropdownMenuContext();\n const destructive = variant === 'destructive';\n\n return (\n {\n if (disabled) {\n return;\n }\n onPress?.(event);\n if (closeOnPress) {\n setOpen(false);\n }\n }}\n style={({ pressed }) => [\n styles.item,\n inset && styles.itemInset,\n {\n backgroundColor: pressed\n ? destructive\n ? 'rgba(239, 68, 68, 0.12)'\n : theme.secondary\n : theme.background,\n borderColor: 'transparent',\n opacity: disabled ? 0.45 : 1,\n },\n style as object,\n ]}\n {...props}\n >\n \n \n {typeof children === 'string' ? {children} : children}\n \n \n \n );\n}\n\ntype DropdownMenuCheckboxItemProps = PressableProps & {\n checked?: boolean;\n onCheckedChange?: (checked: boolean) => void;\n children?: React.ReactNode;\n};\n\nfunction DropdownMenuCheckboxItem({\n children,\n checked = false,\n onCheckedChange,\n disabled,\n style,\n onPress,\n ...props\n}: DropdownMenuCheckboxItemProps) {\n const theme = useRegistryTheme();\n\n return (\n {\n if (disabled) return;\n onPress?.(event);\n onCheckedChange?.(!checked);\n }}\n style={({ pressed }) => [\n styles.item,\n styles.checkboxItem,\n {\n backgroundColor: pressed ? theme.secondary : theme.background,\n opacity: disabled ? 0.45 : 1,\n },\n style as object,\n ]}\n accessibilityRole=\"checkbox\"\n accessibilityState={{ checked: Boolean(checked), disabled: Boolean(disabled) }}\n {...props}\n >\n \n \n {typeof children === 'string' ? {children} : children}\n \n \n {checked ? (\n \n ) : null}\n \n \n \n );\n}\n\ntype DropdownMenuRadioGroupProps = ViewProps & {\n value?: string;\n onValueChange?: (value: string) => void;\n};\n\nfunction DropdownMenuRadioGroup({\n value,\n onValueChange,\n children,\n style,\n ...props\n}: DropdownMenuRadioGroupProps) {\n const stable = React.useMemo(\n () => ({ value, onValueChange }),\n [value, onValueChange],\n );\n\n return (\n \n \n {children}\n \n \n );\n}\n\ntype DropdownMenuRadioItemProps = PressableProps & {\n value: string;\n children?: React.ReactNode;\n};\n\nfunction DropdownMenuRadioItem({\n value: itemValue,\n children,\n disabled,\n style,\n onPress,\n ...props\n}: DropdownMenuRadioItemProps) {\n const theme = useRegistryTheme();\n const radio = useMenuRadioContext();\n const selected = radio?.value === itemValue;\n\n return (\n {\n if (disabled) return;\n onPress?.(event);\n radio?.onValueChange?.(itemValue);\n }}\n style={({ pressed }) => [\n styles.item,\n styles.checkboxItem,\n {\n backgroundColor: pressed ? theme.secondary : theme.background,\n opacity: disabled ? 0.45 : 1,\n },\n style as object,\n ]}\n accessibilityRole=\"radio\"\n accessibilityState={{ selected: Boolean(selected), disabled: Boolean(disabled) }}\n {...props}\n >\n \n \n {typeof children === 'string' ? {children} : children}\n \n \n {selected ? (\n \n ) : (\n \n )}\n \n \n \n );\n}\n\nfunction DropdownMenuSeparator({ style, ...props }: ViewProps) {\n const theme = useRegistryTheme();\n return ;\n}\n\nfunction DropdownMenuShortcut(props: React.ComponentProps) {\n const theme = useRegistryTheme();\n\n return (\n \n );\n}\n\nconst styles = StyleSheet.create({\n root: {\n alignSelf: 'flex-start',\n },\n modalRoot: {\n flex: 1,\n },\n content: {\n position: 'absolute',\n zIndex: 100,\n borderRadius: 12,\n borderWidth: 1,\n padding: 3,\n gap: 1,\n overflow: 'hidden',\n shadowColor: '#000',\n shadowOffset: { width: 0, height: 8 },\n shadowOpacity: 0.1,\n shadowRadius: 16,\n elevation: 8,\n },\n group: {\n gap: 0,\n },\n item: {\n minHeight: 36,\n borderRadius: 8,\n borderWidth: 1,\n paddingHorizontal: 10,\n paddingVertical: 6,\n flexDirection: 'row',\n alignItems: 'center',\n alignSelf: 'stretch',\n },\n itemInset: {\n paddingLeft: 28,\n },\n itemInner: {\n flex: 1,\n flexDirection: 'row',\n alignItems: 'center',\n gap: 8,\n minWidth: 0,\n },\n checkboxItem: {\n paddingRight: 36,\n },\n checkboxItemInner: {\n flex: 1,\n flexDirection: 'row',\n alignItems: 'center',\n minWidth: 0,\n },\n checkboxIndicatorSlot: {\n position: 'absolute',\n right: 10,\n top: 0,\n bottom: 0,\n justifyContent: 'center',\n alignItems: 'center',\n width: 22,\n },\n checkMark: {\n fontSize: 15,\n fontWeight: '700',\n },\n radioRing: {\n width: 16,\n height: 16,\n borderRadius: 8,\n borderWidth: 2,\n },\n radioDot: {\n width: 8,\n height: 8,\n borderRadius: 4,\n },\n label: {\n fontSize: 12,\n fontWeight: '600',\n letterSpacing: 0,\n lineHeight: 16,\n paddingHorizontal: 8,\n paddingTop: 6,\n paddingBottom: 4,\n },\n separator: {\n height: StyleSheet.hairlineWidth,\n marginVertical: 4,\n marginHorizontal: -3,\n },\n shortcut: {\n marginLeft: 'auto',\n fontSize: 12,\n fontWeight: '500',\n lineHeight: 16,\n },\n});\n\nexport {\n DropdownMenu,\n DropdownMenuCheckboxItem,\n DropdownMenuContent,\n DropdownMenuGroup,\n DropdownMenuItem,\n DropdownMenuLabel,\n DropdownMenuRadioGroup,\n DropdownMenuRadioItem,\n DropdownMenuSeparator,\n DropdownMenuShortcut,\n DropdownMenuTrigger,\n};\n" + "source": "import * as React from 'react';\nimport {\n Modal,\n Platform,\n Pressable,\n StyleSheet,\n Text,\n View,\n useWindowDimensions,\n type LayoutChangeEvent,\n type PressableProps,\n type TextProps,\n type ViewProps,\n} from 'react-native';\nimport { useSafeAreaInsets } from 'react-native-safe-area-context';\n\nimport { Text as UiText, TextStyleContext } from './text';\nimport { useRegistryTheme } from '../../lib/theme';\n\ntype DropdownMenuContextValue = {\n open: boolean;\n setOpen: React.Dispatch>;\n triggerLayout: { x: number; y: number; width: number; height: number };\n setTriggerLayout: React.Dispatch<\n React.SetStateAction<{\n x: number;\n y: number;\n width: number;\n height: number;\n }>\n >;\n syncTriggerLayout: () => void;\n};\n\nconst DropdownMenuContext = React.createContext(null);\n\nfunction useDropdownMenuContext() {\n const context = React.useContext(DropdownMenuContext);\n\n if (!context) {\n throw new Error('Dropdown menu components must be used inside DropdownMenu.');\n }\n\n return context;\n}\n\ntype MenuRadioContextValue = {\n value?: string;\n onValueChange?: (value: string) => void;\n};\n\nconst MenuRadioContext = React.createContext(null);\n\nfunction useMenuRadioContext() {\n return React.useContext(MenuRadioContext);\n}\n\nfunction DropdownMenu({\n children,\n defaultOpen = false,\n open,\n onOpenChange,\n}: {\n children?: React.ReactNode;\n defaultOpen?: boolean;\n open?: boolean;\n onOpenChange?: (open: boolean) => void;\n}) {\n const [internalOpen, setInternalOpen] = React.useState(defaultOpen);\n const [triggerLayout, setTriggerLayout] = React.useState({\n x: 0,\n y: 0,\n width: 0,\n height: 0,\n });\n const isControlled = open !== undefined;\n const resolvedOpen = isControlled ? open : internalOpen;\n const triggerRef = React.useRef(null);\n\n const setOpen = React.useCallback>>(\n (value) => {\n const nextValue =\n typeof value === 'function' ? value(resolvedOpen) : value;\n\n if (!isControlled) {\n setInternalOpen(nextValue);\n }\n\n onOpenChange?.(nextValue);\n },\n [isControlled, onOpenChange, resolvedOpen],\n );\n\n const syncTriggerLayout = React.useCallback(() => {\n triggerRef.current?.measureInWindow((x, y, width, height) => {\n setTriggerLayout({\n x,\n y,\n width: Math.max(width, 0),\n height: Math.max(height, 0),\n });\n });\n }, []);\n\n return (\n \n \n {children}\n \n \n );\n}\n\nfunction DropdownMenuTrigger({\n asChild = false,\n children,\n onPress,\n ...props\n}: PressableProps & { asChild?: boolean; children?: React.ReactNode }) {\n const { setOpen, setTriggerLayout, syncTriggerLayout } = useDropdownMenuContext();\n\n const handlePress = React.useCallback(\n (event: Parameters>[0]) => {\n onPress?.(event);\n syncTriggerLayout();\n setOpen((value) => !value);\n },\n [onPress, setOpen, syncTriggerLayout],\n );\n\n const handleLayout = React.useCallback(\n (event: LayoutChangeEvent) => {\n requestAnimationFrame(syncTriggerLayout);\n setTriggerLayout((current) => ({\n ...current,\n width: event.nativeEvent.layout.width,\n height: event.nativeEvent.layout.height,\n }));\n },\n [setTriggerLayout, syncTriggerLayout],\n );\n\n if (asChild && React.isValidElement(children)) {\n return (\n \n {React.cloneElement(children as React.ReactElement<{ onPress?: PressableProps['onPress'] }>, {\n onPress: handlePress,\n })}\n \n );\n }\n\n return (\n \n \n {children}\n \n \n );\n}\n\ntype ContentAlign = 'start' | 'end' | 'center';\n\ntype DropdownMenuContentProps = ViewProps & {\n /** Horizontal alignment of the menu relative to the trigger (shadcn-style). */\n align?: ContentAlign;\n /** Gap between trigger and menu, in px. */\n sideOffset?: number;\n minWidth?: number;\n maxWidth?: number;\n};\n\nfunction DropdownMenuContent({\n children,\n style,\n align = 'start',\n sideOffset = 10,\n minWidth: minWidthProp,\n maxWidth: maxWidthProp,\n ...props\n}: DropdownMenuContentProps) {\n const theme = useRegistryTheme();\n const insets = useSafeAreaInsets();\n const { width: screenWidth, height: screenHeight } = useWindowDimensions();\n const { open, triggerLayout, setOpen } = useDropdownMenuContext();\n const [contentHeight, setContentHeight] = React.useState(0);\n\n React.useEffect(() => {\n if (!open) {\n setContentHeight(0);\n }\n }, [open]);\n\n if (!open) {\n return null;\n }\n\n const edge = 16;\n const safeLeft = edge + insets.left;\n const safeRight = edge + insets.right;\n const safeTop = edge + insets.top;\n const safeBottom = edge + insets.bottom;\n const usableWidth = Math.max(0, screenWidth - safeLeft - safeRight);\n const maxW = maxWidthProp ?? usableWidth;\n const triggerW = triggerLayout.width || 0;\n const defaultMin = Math.min(Math.max(triggerW > 0 ? triggerW + 12 : 0, 204), maxW);\n const requestedMin =\n minWidthProp ?? (triggerW > 0 ? defaultMin : Math.min(260, maxW));\n const contentWidth = Math.min(maxW, Math.max(requestedMin, 0));\n\n const gap = sideOffset;\n\n let left = triggerLayout.x;\n if (align === 'end') {\n left = triggerLayout.x + triggerLayout.width - contentWidth;\n } else if (align === 'center') {\n left = triggerLayout.x + triggerLayout.width / 2 - contentWidth / 2;\n }\n\n const minLeft = safeLeft;\n const maxLeft = screenWidth - safeRight - contentWidth;\n left = Math.min(Math.max(left, minLeft), Math.max(maxLeft, minLeft));\n\n const belowTop = triggerLayout.y + triggerLayout.height + gap;\n const aboveTop = triggerLayout.y - contentHeight - gap;\n const top =\n contentHeight > 0 &&\n belowTop + contentHeight > screenHeight - safeBottom &&\n aboveTop >= safeTop\n ? aboveTop\n : Math.min(\n Math.max(belowTop, safeTop),\n Math.max(safeTop, screenHeight - safeBottom - contentHeight),\n );\n\n return (\n setOpen(false)}\n >\n \n setOpen(false)} />\n {\n const { height } = event.nativeEvent.layout;\n setContentHeight(height);\n }}\n {...props}\n >\n {children}\n \n \n \n );\n}\n\nfunction DropdownMenuGroup(props: ViewProps) {\n return ;\n}\n\nfunction DropdownMenuLabel(props: TextProps) {\n const theme = useRegistryTheme();\n\n return (\n \n );\n}\n\ntype ItemVariant = 'default' | 'destructive';\n\ntype DropdownMenuItemProps = PressableProps & {\n children?: React.ReactNode;\n /** Adds leading inset for icon rows (shadcn `inset`). */\n inset?: boolean;\n variant?: ItemVariant;\n /** When false, the menu stays open after press (default: true). */\n closeOnPress?: boolean;\n};\n\nfunction DropdownMenuItem({\n children,\n onPress,\n style,\n disabled,\n inset,\n variant = 'default',\n closeOnPress = true,\n ...props\n}: DropdownMenuItemProps) {\n const theme = useRegistryTheme();\n const { setOpen } = useDropdownMenuContext();\n const destructive = variant === 'destructive';\n\n return (\n {\n if (disabled) {\n return;\n }\n onPress?.(event);\n if (closeOnPress) {\n setOpen(false);\n }\n }}\n style={({ pressed }) => [\n styles.item,\n inset && styles.itemInset,\n {\n backgroundColor: pressed\n ? destructive\n ? 'rgba(239, 68, 68, 0.12)'\n : theme.secondary\n : theme.background,\n borderColor: 'transparent',\n opacity: disabled ? 0.45 : 1,\n },\n style as object,\n ]}\n {...props}\n >\n \n \n {typeof children === 'string' ? {children} : children}\n \n \n \n );\n}\n\ntype DropdownMenuCheckboxItemProps = PressableProps & {\n checked?: boolean;\n onCheckedChange?: (checked: boolean) => void;\n children?: React.ReactNode;\n};\n\nfunction DropdownMenuCheckboxItem({\n children,\n checked = false,\n onCheckedChange,\n disabled,\n style,\n onPress,\n ...props\n}: DropdownMenuCheckboxItemProps) {\n const theme = useRegistryTheme();\n\n return (\n {\n if (disabled) return;\n onPress?.(event);\n onCheckedChange?.(!checked);\n }}\n style={({ pressed }) => [\n styles.item,\n styles.checkboxItem,\n {\n backgroundColor: pressed ? theme.secondary : theme.background,\n opacity: disabled ? 0.45 : 1,\n },\n style as object,\n ]}\n accessibilityRole=\"checkbox\"\n accessibilityState={{ checked: Boolean(checked), disabled: Boolean(disabled) }}\n {...props}\n >\n \n \n {typeof children === 'string' ? {children} : children}\n \n \n {checked ? (\n \n ) : null}\n \n \n \n );\n}\n\ntype DropdownMenuRadioGroupProps = ViewProps & {\n value?: string;\n onValueChange?: (value: string) => void;\n};\n\nfunction DropdownMenuRadioGroup({\n value,\n onValueChange,\n children,\n style,\n ...props\n}: DropdownMenuRadioGroupProps) {\n const stable = React.useMemo(\n () => ({ value, onValueChange }),\n [value, onValueChange],\n );\n\n return (\n \n \n {children}\n \n \n );\n}\n\ntype DropdownMenuRadioItemProps = PressableProps & {\n value: string;\n children?: React.ReactNode;\n};\n\nfunction DropdownMenuRadioItem({\n value: itemValue,\n children,\n disabled,\n style,\n onPress,\n ...props\n}: DropdownMenuRadioItemProps) {\n const theme = useRegistryTheme();\n const radio = useMenuRadioContext();\n const selected = radio?.value === itemValue;\n\n return (\n {\n if (disabled) return;\n onPress?.(event);\n radio?.onValueChange?.(itemValue);\n }}\n style={({ pressed }) => [\n styles.item,\n styles.checkboxItem,\n {\n backgroundColor: pressed ? theme.secondary : theme.background,\n opacity: disabled ? 0.45 : 1,\n },\n style as object,\n ]}\n accessibilityRole=\"radio\"\n accessibilityState={{ selected: Boolean(selected), disabled: Boolean(disabled) }}\n {...props}\n >\n \n \n {typeof children === 'string' ? {children} : children}\n \n \n {selected ? (\n \n ) : (\n \n )}\n \n \n \n );\n}\n\nfunction DropdownMenuSeparator({ style, ...props }: ViewProps) {\n const theme = useRegistryTheme();\n return ;\n}\n\ntype DropdownMenuShortcutProps = React.ComponentProps & {\n /** When true, trailing glyphs (e.g. chevrons) still render on iOS/Android. Keyboard hints stay web-only by default. */\n showOnNative?: boolean;\n};\n\nfunction DropdownMenuShortcut({ showOnNative = false, ...props }: DropdownMenuShortcutProps) {\n const theme = useRegistryTheme();\n\n if (Platform.OS !== 'web' && !showOnNative) {\n return null;\n }\n\n return (\n \n );\n}\n\nconst styles = StyleSheet.create({\n root: {\n alignSelf: 'flex-start',\n },\n modalRoot: {\n flex: 1,\n },\n content: {\n position: 'absolute',\n zIndex: 100,\n borderRadius: 12,\n borderWidth: 1,\n padding: 8,\n gap: 2,\n overflow: 'hidden',\n shadowColor: '#000',\n shadowOffset: { width: 0, height: 8 },\n shadowOpacity: 0.1,\n shadowRadius: 16,\n elevation: 8,\n },\n group: {\n gap: 0,\n },\n item: {\n minHeight: 44,\n borderRadius: 8,\n borderWidth: 1,\n paddingHorizontal: 14,\n paddingVertical: 10,\n flexDirection: 'row',\n alignItems: 'center',\n alignSelf: 'stretch',\n },\n itemInset: {\n paddingLeft: 32,\n },\n itemInner: {\n flex: 1,\n flexDirection: 'row',\n alignItems: 'center',\n gap: 8,\n minWidth: 0,\n },\n checkboxItem: {\n paddingRight: 42,\n },\n checkboxItemInner: {\n flex: 1,\n flexDirection: 'row',\n alignItems: 'center',\n minWidth: 0,\n },\n checkboxIndicatorSlot: {\n position: 'absolute',\n right: 12,\n top: 0,\n bottom: 0,\n justifyContent: 'center',\n alignItems: 'center',\n width: 24,\n },\n checkMark: {\n fontSize: 15,\n fontWeight: '700',\n },\n radioRing: {\n width: 16,\n height: 16,\n borderRadius: 8,\n borderWidth: 2,\n },\n radioDot: {\n width: 8,\n height: 8,\n borderRadius: 4,\n },\n label: {\n fontSize: 12,\n fontWeight: '600',\n letterSpacing: 0,\n lineHeight: 16,\n paddingHorizontal: 10,\n paddingTop: 8,\n paddingBottom: 4,\n },\n separator: {\n height: StyleSheet.hairlineWidth,\n marginVertical: 6,\n marginHorizontal: -8,\n },\n shortcut: {\n marginLeft: 'auto',\n marginRight: 0,\n paddingLeft: 12,\n fontSize: 12,\n fontWeight: '500',\n lineHeight: 16,\n flexShrink: 0,\n },\n});\n\nexport {\n DropdownMenu,\n DropdownMenuCheckboxItem,\n DropdownMenuContent,\n DropdownMenuGroup,\n DropdownMenuItem,\n DropdownMenuLabel,\n DropdownMenuRadioGroup,\n DropdownMenuRadioItem,\n DropdownMenuSeparator,\n DropdownMenuShortcut,\n DropdownMenuTrigger,\n};\n" }, { "slug": "input-group", @@ -256,7 +268,7 @@ "textarea" ], "sourcePath": "components/ui/input-group.tsx", - "source": "import * as React from 'react';\nimport { StyleSheet, View, type ViewProps } from 'react-native';\n\nimport { Button, type ButtonProps } from './button';\nimport { Input } from './input';\nimport { Text } from './text';\nimport { Textarea } from './textarea';\nimport { useRegistryTheme } from '../../lib/theme';\n\nfunction InputGroup({ style, ...props }: ViewProps) {\n const theme = useRegistryTheme();\n\n return (\n \n );\n}\n\nfunction InputGroupAddon({ style, ...props }: ViewProps) {\n return ;\n}\n\nfunction InputGroupButton(props: ButtonProps) {\n return