From 2c841aaa73a693f6ab5917a4d51439cb50a47651 Mon Sep 17 00:00:00 2001 From: Khalid Abdi Date: Mon, 2 Nov 2026 17:23:44 +0300 Subject: [PATCH 01/10] feat(icons): export the icon() factory and IconDefaults Apps can now add a glyph the built-in set does not carry and have it resolve colour, weight, direction and fill exactly like the rest. createIcon is the same function under a second name. Refs #256 Co-Authored-By: Claude Opus 5.5 --- .../docs/content/docs/customization/icons.mdx | 27 +++++++++++++++++++ packages/panelui/src/icons/index.tsx | 23 ++++++++++++++-- 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/apps/docs/content/docs/customization/icons.mdx b/apps/docs/content/docs/customization/icons.mdx index 3a8c72daf..58edafaf5 100644 --- a/apps/docs/content/docs/customization/icons.mdx +++ b/apps/docs/content/docs/customization/icons.mdx @@ -176,6 +176,33 @@ passing explicitly. ``` +## Adding a glyph to the built-in set + +To add one icon that behaves like the built-in ones, build it with `icon()`. It +is the factory the whole set is made with, so the new glyph follows the same +colour order (the prop, then `IconColorProvider`, then its default), draws at the +same weight, and takes `filled` and the right-to-left mirror the same way. + +```tsx +import HgViewOffIcon from '@hugeicons/core-free-icons/ViewOffIcon'; +import { icon } from 'panelui-native/icons'; + +export const EyeOffIcon = icon(HgViewOffIcon, { size: 16, color: '#737373' }); +``` + +The second argument is `IconDefaults`, which is exported too: + +| Key | What it sets | +| --- | --- | +| `size` | The width and height when the caller passes no `size`. | +| `color` | The colour when neither a prop nor a provider supplies one. | +| `strokeWidth` | The line weight, if it should differ from the set's `2`. | +| `flip` | `true` for a glyph whose meaning is a horizontal direction, so it mirrors in right-to-left layouts. | +| `fillable` | `true` for a toggle glyph that can be drawn solid with `filled`. | + +In a project that copied the components in with the CLI, import `icon` from +`@/components/ui/icons` instead. + ## Next diff --git a/packages/panelui/src/icons/index.tsx b/packages/panelui/src/icons/index.tsx index 21a690226..a881d81ea 100644 --- a/packages/panelui/src/icons/index.tsx +++ b/packages/panelui/src/icons/index.tsx @@ -206,7 +206,11 @@ const decorative = ( /** The default line weight. See the note on weight above. */ const STROKE = 2; -interface IconDefaults { +/** + * The defaults a glyph is drawn with when the caller passes nothing. + * The second argument to `icon()`. + */ +export interface IconDefaults { /** Both dimensions at the size this glyph is normally drawn. */ size: number; /** The colour used when neither a prop nor a provider supplies one. */ @@ -222,12 +226,24 @@ interface IconDefaults { /** * Builds one icon from a glyph and the defaults it is normally drawn with. * + * Exported so an app can add a glyph the set does not carry, and have it + * behave exactly like the built-in icons: the same colour order (prop, then + * `IconColorProvider`, then the default), the same line weight, the same + * right-to-left mirror and the same `filled` toggle. + * + * ```tsx + * import HgViewOffIcon from '@hugeicons/core-free-icons/ViewOffIcon'; + * import { icon } from 'panelui-native/icons'; + * + * export const EyeOffIcon = icon(HgViewOffIcon, { size: 16, color: '#737373' }); + * ``` + * * A factory rather than seventy near-identical components: what varies between * them is data, and what does not — the colour order, the weight default, the * mirror, the fill toggle, the prop passthrough — is worth having in exactly * one place, where a fix reaches the whole set at once. */ -function icon(glyph: IconSvgElement, defaults: IconDefaults) { +export function icon(glyph: IconSvgElement, defaults: IconDefaults) { function Icon({ size = defaults.size, color, @@ -274,6 +290,9 @@ function icon(glyph: IconSvgElement, defaults: IconDefaults) { return Icon; } +/** Another name for `icon()`, for code that prefers the `create` prefix. */ +export const createIcon = icon; + /* -------------------------------------------------------------------------- */ /* Chrome */ /* -------------------------------------------------------------------------- */ From 95301f58b8282175c72aa7304feee308ef79f329 Mon Sep 17 00:00:00 2001 From: Khalid Abdi Date: Mon, 2 Nov 2026 17:25:18 +0300 Subject: [PATCH 02/10] fix: type forwarded refs with ComponentRef aliases React Native 0.88 declares View, Text and TextInput as function components, so forwardRef stops compiling there. Every forwardRef now names ViewRef, TextRef or TextInputRef (ComponentRef), which compile on 0.86 and 0.88 and keep focus/clear/isFocused on the ref. The aliases are exported from the root, and React.ElementRef is folded into TextRef so there is one way to name a ref type. Refs #257 Co-Authored-By: Claude Opus 5.5 --- .../content/docs/reference/foundations.mdx | 6 ++ apps/docs/content/docs/reference/index.mdx | 4 +- apps/docs/lib/public-api.generated.json | 25 +++++- apps/docs/public/r/accordion.json | 3 +- apps/docs/public/r/alert.json | 3 +- apps/docs/public/r/animated-badge.json | 3 +- apps/docs/public/r/animated-pressable.json | 6 +- apps/docs/public/r/attachment.json | 3 +- apps/docs/public/r/avatar.json | 3 +- apps/docs/public/r/badge.json | 3 +- apps/docs/public/r/breadcrumb.json | 3 +- apps/docs/public/r/button-group.json | 5 +- apps/docs/public/r/button.json | 3 +- apps/docs/public/r/card.json | 3 +- apps/docs/public/r/checkbox.json | 3 +- apps/docs/public/r/chip.json | 3 +- apps/docs/public/r/collapsible.json | 3 +- apps/docs/public/r/color-picker.json | 3 +- apps/docs/public/r/countdown.json | 3 +- apps/docs/public/r/dialog.json | 3 +- apps/docs/public/r/empty-state.json | 3 +- apps/docs/public/r/fab.json | 3 +- apps/docs/public/r/feedback.json | 3 +- apps/docs/public/r/field.json | 3 +- apps/docs/public/r/file-upload.json | 3 +- apps/docs/public/r/flip-card.json | 5 +- apps/docs/public/r/frame.json | 3 +- apps/docs/public/r/glass.json | 3 +- apps/docs/public/r/grid-item.json | 3 +- apps/docs/public/r/heatmap-chart.json | 3 +- apps/docs/public/r/icons.json | 2 +- apps/docs/public/r/index.json | 85 ++++++++++++++++++- apps/docs/public/r/input-group.json | 3 +- apps/docs/public/r/input.json | 3 +- apps/docs/public/r/item.json | 3 +- apps/docs/public/r/kpi.json | 3 +- apps/docs/public/r/label.json | 3 +- apps/docs/public/r/leaderboard.json | 3 +- apps/docs/public/r/markdown-editor.json | 3 +- apps/docs/public/r/marker.json | 3 +- apps/docs/public/r/message.json | 3 +- apps/docs/public/r/meter.json | 3 +- apps/docs/public/r/number-input.json | 3 +- apps/docs/public/r/otp-input.json | 3 +- apps/docs/public/r/page-header.json | 3 +- apps/docs/public/r/pagination.json | 3 +- apps/docs/public/r/planner.json | 3 +- apps/docs/public/r/post.json | 3 +- apps/docs/public/r/progress-button.json | 3 +- apps/docs/public/r/progress.json | 3 +- apps/docs/public/r/qr-code.json | 3 +- apps/docs/public/r/questionnaire.json | 3 +- apps/docs/public/r/radio-group.json | 3 +- apps/docs/public/r/rating.json | 3 +- apps/docs/public/r/refs.json | 15 ++++ apps/docs/public/r/scroll-header.json | 3 +- apps/docs/public/r/search-bar.json | 3 +- apps/docs/public/r/separator.json | 3 +- apps/docs/public/r/slider.json | 3 +- apps/docs/public/r/stack-card.json | 3 +- apps/docs/public/r/steps.json | 3 +- apps/docs/public/r/support.json | 3 +- apps/docs/public/r/surface.json | 6 +- apps/docs/public/r/swipe.json | 3 +- apps/docs/public/r/switch.json | 3 +- apps/docs/public/r/table.json | 3 +- apps/docs/public/r/tag-input.json | 3 +- apps/docs/public/r/textarea.json | 3 +- apps/docs/public/r/timeline.json | 3 +- apps/docs/public/r/toggle-button.json | 3 +- apps/docs/public/r/tree.json | 3 +- apps/docs/public/r/typography.json | 3 +- apps/docs/scripts/build-registry.mjs | 1 + catalogue.json | 8 +- .../src/components/accordion/index.tsx | 13 +-- .../panelui/src/components/alert/index.tsx | 11 +-- .../src/components/animated-badge/index.tsx | 3 +- .../src/components/attachment/index.tsx | 17 ++-- .../panelui/src/components/avatar/index.tsx | 7 +- .../panelui/src/components/badge/index.tsx | 3 +- .../src/components/breadcrumb/index.tsx | 15 ++-- .../src/components/button-group/index.tsx | 3 +- .../panelui/src/components/button/index.tsx | 3 +- .../panelui/src/components/card/index.tsx | 13 +-- .../panelui/src/components/checkbox/index.tsx | 3 +- .../panelui/src/components/chip/index.tsx | 3 +- .../src/components/collapsible/index.tsx | 11 +-- .../src/components/color-picker/index.tsx | 21 ++--- .../src/components/countdown/index.tsx | 3 +- .../panelui/src/components/dialog/index.tsx | 5 +- .../src/components/empty-state/index.tsx | 13 +-- packages/panelui/src/components/fab/index.tsx | 7 +- .../panelui/src/components/feedback/index.tsx | 9 +- .../panelui/src/components/field/index.tsx | 21 ++--- .../src/components/file-upload/index.tsx | 7 +- .../src/components/flip-card/index.tsx | 7 +- .../panelui/src/components/frame/index.tsx | 25 +++--- .../src/components/grid-item/index.tsx | 19 +++-- .../src/components/heatmap-chart/index.tsx | 3 +- .../src/components/input-group/index.tsx | 7 +- .../panelui/src/components/input/index.tsx | 3 +- .../panelui/src/components/item/index.tsx | 21 ++--- packages/panelui/src/components/kpi/index.tsx | 5 +- .../panelui/src/components/label/index.tsx | 5 +- .../src/components/leaderboard/index.tsx | 3 +- .../src/components/markdown-editor/index.tsx | 7 +- .../panelui/src/components/marker/index.tsx | 7 +- .../panelui/src/components/message/index.tsx | 19 +++-- .../panelui/src/components/meter/index.tsx | 3 +- .../src/components/number-input/index.tsx | 3 +- .../src/components/otp-input/index.tsx | 3 +- .../src/components/page-header/index.tsx | 23 ++--- .../src/components/pagination/index.tsx | 11 +-- .../panelui/src/components/planner/index.tsx | 3 +- .../panelui/src/components/post/index.tsx | 23 ++--- .../src/components/progress-button/index.tsx | 3 +- .../panelui/src/components/progress/index.tsx | 3 +- .../panelui/src/components/qr-code/index.tsx | 15 ++-- .../src/components/questionnaire/index.tsx | 5 +- .../src/components/radio-group/index.tsx | 5 +- .../panelui/src/components/rating/index.tsx | 3 +- .../src/components/scroll-header/index.tsx | 15 ++-- .../src/components/search-bar/index.tsx | 3 +- .../src/components/separator/index.tsx | 3 +- .../panelui/src/components/slider/index.tsx | 3 +- .../src/components/stack-card/index.tsx | 11 +-- .../panelui/src/components/steps/index.tsx | 15 ++-- .../panelui/src/components/support/index.tsx | 33 +++---- .../panelui/src/components/surface/index.tsx | 3 +- .../panelui/src/components/swipe/index.tsx | 9 +- .../panelui/src/components/switch/index.tsx | 3 +- .../panelui/src/components/table/index.tsx | 21 ++--- .../src/components/tag-input/index.tsx | 3 +- .../panelui/src/components/textarea/index.tsx | 3 +- .../panelui/src/components/timeline/index.tsx | 33 +++---- .../src/components/toggle-button/index.tsx | 5 +- .../panelui/src/components/tree/index.tsx | 17 ++-- .../src/components/typography/index.tsx | 15 ++-- packages/panelui/src/index.ts | 4 + .../src/primitives/animated-pressable.tsx | 3 +- packages/panelui/src/primitives/glass.tsx | 3 +- packages/panelui/src/primitives/refs.ts | 22 +++++ packages/panelui/tsconfig.type-tests.json | 2 +- .../panelui/type-tests/ref-api.type-test.tsx | 25 ++++++ 144 files changed, 668 insertions(+), 365 deletions(-) create mode 100644 apps/docs/public/r/refs.json create mode 100644 packages/panelui/src/primitives/refs.ts create mode 100644 packages/panelui/type-tests/ref-api.type-test.tsx diff --git a/apps/docs/content/docs/reference/foundations.mdx b/apps/docs/content/docs/reference/foundations.mdx index d92c74235..e8ef0d754 100644 --- a/apps/docs/content/docs/reference/foundations.mdx +++ b/apps/docs/content/docs/reference/foundations.mdx @@ -53,6 +53,12 @@ description: Providers, themes, primitives and optional native bridges. **Types:** — +## refs + +**Values:** — + +**Types:** `ViewRef`, `TextRef`, `TextInputRef` + ## scrim **Values:** `Scrim`, `hasBlur` diff --git a/apps/docs/content/docs/reference/index.mdx b/apps/docs/content/docs/reference/index.mdx index 7ed12ed81..48364749c 100644 --- a/apps/docs/content/docs/reference/index.mdx +++ b/apps/docs/content/docs/reference/index.mdx @@ -1,11 +1,11 @@ --- title: Public API -description: Every supported root export from panelui-native — 1497 symbols across 168 modules. +description: Every supported root export from panelui-native — 1500 symbols across 169 modules. --- {/* Generated by scripts/build-api-reference.mjs. Do not edit. */} -The package root exposes **374 values** and **1123 types**. Task-oriented component guides remain the best place to start; these indexes answer exactly what is public. +The package root exposes **374 values** and **1126 types**. Task-oriented component guides remain the best place to start; these indexes answer exactly what is public. diff --git a/apps/docs/lib/public-api.generated.json b/apps/docs/lib/public-api.generated.json index bd4b34599..bb156b0c2 100644 --- a/apps/docs/lib/public-api.generated.json +++ b/apps/docs/lib/public-api.generated.json @@ -1,10 +1,10 @@ { "version": 1, "counts": { - "exports": 1497, + "exports": 1500, "values": 374, - "types": 1123, - "modules": 168, + "types": 1126, + "modules": 169, "components": 141, "hooks": 13 }, @@ -6725,6 +6725,25 @@ } ] }, + { + "source": "primitives/refs.ts", + "slug": "refs", + "category": "foundations", + "symbols": [ + { + "name": "ViewRef", + "kind": "type" + }, + { + "name": "TextRef", + "kind": "type" + }, + { + "name": "TextInputRef", + "kind": "type" + } + ] + }, { "source": "primitives/scrim.tsx", "slug": "scrim", diff --git a/apps/docs/public/r/accordion.json b/apps/docs/public/r/accordion.json index 6fdb1840e..d30c6111e 100644 --- a/apps/docs/public/r/accordion.json +++ b/apps/docs/public/r/accordion.json @@ -4,6 +4,7 @@ "description": "Collapsible sections with single or multiple selection.", "registryDependencies": [ "icons", + "refs", "text" ], "dependencies": [ @@ -15,7 +16,7 @@ { "path": "ui/accordion.tsx", "type": "registry:ui", - "content": "/**\n * Accordion — collapsible sections.\n *\n * Split into Item / Trigger / Indicator / Content so a section header can hold\n * anything — a badge, a price, a second line — instead of only a string. The\n * chevron rotates rather than swapping glyphs, and the panel expands through a\n * layout transition, both on the UI thread.\n *\n * `selectionMode` decides whether opening one section closes the others, which\n * also changes the shape of `value`: a string when single, an array when\n * multiple.\n *\n * With the operating system set to reduce motion the section arrives at its new\n * height and the chevron at its new angle without travelling to either. The\n * disclosure is the point and it still happens; the movement is what goes.\n *\n * A closed section costs nothing, because its body is unmounted. That is the\n * right default and the wrong one for a body with state in it: a half-filled\n * form, a list scrolled to the middle, a video part-way through. Collapsing\n * such a section throws that away and reopening it starts over. `keepMounted`\n * is the way out — the body stays mounted and is hidden from layout instead,\n * so it takes up no room, the same layout transition carries the change, and\n * everything inside is exactly where it was left.\n */\nimport {\n Children,\n createContext,\n forwardRef,\n isValidElement,\n useCallback,\n useContext,\n useEffect,\n useMemo,\n useRef,\n useState,\n type ReactNode,\n} from 'react';\nimport {\n Pressable,\n View,\n type PressableProps,\n type Text as RNText,\n type ViewProps,\n} from 'react-native';\nimport Animated, {\n LinearTransition,\n useAnimatedStyle,\n useReducedMotion,\n useSharedValue,\n withTiming,\n} from 'react-native-reanimated';\nimport { tv } from 'tailwind-variants';\nimport { ChevronDownIcon } from '@/components/ui/icons';\nimport { Text, type TextProps, textChildren } from '@/components/ui/text';\n\nexport type AccordionVariant =\n | 'default'\n | 'surface'\n | 'separated'\n | 'bordered'\n | 'ghost';\n\nexport type AccordionSelectionMode = 'single' | 'multiple';\n\n/**\n * How long the height change and the chevron take. One constant for both, so\n * the panel and the arrow that describes it finish together.\n */\nconst TRANSITION_DURATION = 200;\n\nconst accordionVariants = tv({\n slots: {\n root: 'w-full flex-col overflow-hidden',\n separator: 'h-px bg-border',\n item: 'flex-col overflow-hidden',\n trigger: 'flex-row items-center justify-between gap-4 py-4',\n title: 'flex-1 text-base font-medium text-foreground',\n indicator: 'items-center justify-center',\n content: 'pb-4',\n contentText: 'text-sm text-muted-foreground',\n },\n variants: {\n variant: {\n default: { trigger: 'px-1', content: 'px-1' },\n surface: {\n root: 'rounded-2xl border border-border bg-surface',\n separator: 'mx-4',\n trigger: 'px-4',\n content: 'px-4',\n },\n separated: {\n root: 'gap-2.5 overflow-visible',\n separator: 'hidden',\n item: 'rounded-xl border border-border bg-card',\n trigger: 'px-4',\n content: 'px-4',\n },\n bordered: {\n separator: 'hidden',\n item: 'mb-2 rounded-xl border border-border',\n trigger: 'px-4',\n content: 'px-4',\n },\n ghost: {\n separator: 'hidden',\n trigger: 'px-0',\n content: 'px-0',\n },\n },\n },\n defaultVariants: {\n variant: 'default',\n },\n});\n\ninterface AccordionContextValue {\n expanded: string[];\n toggle: (value: string) => void;\n variant: AccordionVariant;\n keepMounted: boolean;\n}\n\ninterface AccordionItemContextValue {\n value: string;\n isExpanded: boolean;\n isDisabled: boolean;\n}\n\nconst AccordionContext = createContext(null);\nconst AccordionItemContext = createContext(null);\n\nfunction useAccordion(component: string): AccordionContextValue {\n const context = useContext(AccordionContext);\n if (!context) throw new Error(`${component} must be used within an `);\n return context;\n}\n\nfunction useAccordionItem(component: string): AccordionItemContextValue {\n const context = useContext(AccordionItemContext);\n if (!context) throw new Error(`${component} must be used within an `);\n return context;\n}\n\nexport interface AccordionProps extends ViewProps {\n className?: string;\n variant?: AccordionVariant;\n /** `single` collapses the open item when another opens. */\n selectionMode?: AccordionSelectionMode;\n /** Expanded item value(s), controlled. */\n value?: string | string[];\n defaultValue?: string | string[];\n onValueChange?: (value: string | string[]) => void;\n /** Hide the hairlines drawn between items. */\n hideSeparator?: boolean;\n /**\n * Keep every body mounted while its section is closed, so state inside it —\n * a part-filled form, a scroll position, a running animation — survives being\n * collapsed. Costs the render of every section up front; set it per section\n * on `Accordion.Content` instead when only one of them needs it.\n */\n keepMounted?: boolean;\n children?: ReactNode;\n}\n\nconst toArray = (value: string | string[] | undefined): string[] =>\n value === undefined ? [] : Array.isArray(value) ? value : [value];\n\nconst AccordionRoot = forwardRef(\n (\n {\n className,\n variant = 'default',\n selectionMode = 'single',\n value,\n defaultValue,\n onValueChange,\n hideSeparator = false,\n keepMounted = false,\n children,\n ...props\n },\n ref\n ) => {\n const reducedMotion = useReducedMotion();\n const [internal, setInternal] = useState(() => toArray(defaultValue));\n const isControlled = value !== undefined;\n const expanded = isControlled ? toArray(value) : internal;\n\n const toggle = useCallback(\n (itemValue: string) => {\n const isOpen = expanded.includes(itemValue);\n const next =\n selectionMode === 'single'\n ? isOpen\n ? []\n : [itemValue]\n : isOpen\n ? expanded.filter((entry) => entry !== itemValue)\n : [...expanded, itemValue];\n\n if (!isControlled) setInternal(next);\n // Hand back the shape the caller gave us.\n onValueChange?.(selectionMode === 'single' ? (next[0] ?? '') : next);\n },\n [expanded, selectionMode, isControlled, onValueChange]\n );\n\n const context = useMemo(\n () => ({ expanded, toggle, variant, keepMounted }),\n [expanded, toggle, variant, keepMounted]\n );\n\n const { root, separator } = accordionVariants({ variant });\n const items = Children.toArray(children).filter(isValidElement);\n\n return (\n \n \n {items.map((child, index) => (\n \n {child}\n {!hideSeparator && index < items.length - 1 ? (\n \n ) : null}\n \n ))}\n \n \n );\n }\n);\nAccordionRoot.displayName = 'Accordion';\n\nexport interface AccordionItemProps extends ViewProps {\n className?: string;\n /** Identifies this item in the accordion's value. */\n value: string;\n isDisabled?: boolean;\n children?: ReactNode;\n}\n\nconst AccordionItem = forwardRef(\n ({ className, value, isDisabled = false, children, ...props }, ref) => {\n const { expanded, variant } = useAccordion('Accordion.Item');\n const reducedMotion = useReducedMotion();\n const isExpanded = expanded.includes(value);\n const { item } = accordionVariants({ variant });\n\n const context = useMemo(\n () => ({ value, isExpanded, isDisabled }),\n [value, isExpanded, isDisabled]\n );\n\n return (\n \n \n {textChildren(children)}\n \n \n );\n }\n);\nAccordionItem.displayName = 'Accordion.Item';\n\nexport interface AccordionTriggerProps extends Omit {\n className?: string;\n children?: ReactNode;\n}\n\n/** The pressable header row. Bare strings are wrapped in the title style. */\nconst AccordionTrigger = forwardRef(\n ({ className, children, onPress, disabled = false, ...props }, ref) => {\n const { toggle, variant } = useAccordion('Accordion.Trigger');\n const { value, isExpanded, isDisabled } = useAccordionItem('Accordion.Trigger');\n const { trigger, title } = accordionVariants({ variant });\n const triggerDisabled = Boolean(isDisabled || disabled);\n\n return (\n {\n onPress?.(event);\n toggle(value);\n }}\n >\n {textChildren(children, (text) => (\n {text}\n ))}\n \n );\n }\n);\nAccordionTrigger.displayName = 'Accordion.Trigger';\n\nexport interface AccordionIndicatorProps extends ViewProps {\n className?: string;\n /** Replaces the default chevron. */\n children?: ReactNode;\n}\n\n/** Chevron that rotates 180° while the item is open. */\nconst AccordionIndicator = forwardRef(\n ({ className, children, ...props }, ref) => {\n const { variant } = useAccordion('Accordion.Indicator');\n const { isExpanded } = useAccordionItem('Accordion.Indicator');\n const { indicator } = accordionVariants({ variant });\n const reducedMotion = useReducedMotion();\n const progress = useSharedValue(isExpanded ? 1 : 0);\n const first = useRef(true);\n\n useEffect(() => {\n // There is nothing to travel on mount: the item is already open or shut,\n // and the chevron already points that way. Animating anyway would\n // schedule a 0-to-0 timing per indicator, and a screen that mounts a list\n // of them pays for every one before it can draw. The angle is assigned\n // rather than assumed — a plain number registers no animation, and an\n // indicator left at the wrong angle stays wrong for as long as it lives.\n if (first.current) {\n first.current = false;\n progress.value = isExpanded ? 1 : 0;\n return;\n }\n progress.value = reducedMotion\n ? isExpanded\n ? 1\n : 0\n : withTiming(isExpanded ? 1 : 0, { duration: TRANSITION_DURATION });\n }, [isExpanded, reducedMotion, progress]);\n\n const style = useAnimatedStyle(() => ({\n transform: [{ rotate: `${progress.value * 180}deg` }],\n }));\n\n return (\n \n \n {children ?? }\n \n \n );\n }\n);\nAccordionIndicator.displayName = 'Accordion.Indicator';\n\nexport interface AccordionContentProps extends ViewProps {\n className?: string;\n /**\n * Stay mounted while closed instead of unmounting, so state inside the body\n * survives the section being collapsed. Overrides the accordion's own\n * setting, either way round.\n */\n keepMounted?: boolean;\n children?: ReactNode;\n}\n\n/**\n * The collapsible body. Unmounts when closed, per the repo's convention for\n * conditionally shown content — the layout transition on the item animates\n * the height change.\n *\n * `keepMounted` swaps the unmount for `display: 'none'`, which is the one way\n * to hide a view that also takes it out of Yoga's layout. That matters twice\n * over: the item's height changes by exactly as much as it would have on an\n * unmount, so the same layout transition plays and the two modes are\n * indistinguishable to look at — and everything inside stays mounted, so a\n * text field keeps what was typed into it. A hidden subtree is still in the\n * accessibility tree, though, so it is explicitly taken out of that too;\n * otherwise a screen reader would read out a section the eye cannot see.\n */\nconst AccordionContent = forwardRef(\n ({ className, keepMounted, children, ...props }, ref) => {\n const { variant, keepMounted: keepMountedDefault } = useAccordion('Accordion.Content');\n const { isExpanded } = useAccordionItem('Accordion.Content');\n const { content, contentText } = accordionVariants({ variant });\n\n const stayMounted = keepMounted ?? keepMountedDefault;\n if (!isExpanded && !stayMounted) return null;\n\n const isHidden = !isExpanded;\n\n return (\n \n {textChildren(children, (text) => (\n {text}\n ))}\n \n );\n }\n);\nAccordionContent.displayName = 'Accordion.Content';\n\nconst AccordionTitle = forwardRef(({ className, ...props }, ref) => {\n const { variant } = useAccordion('Accordion.Title');\n const { title } = accordionVariants({ variant });\n return ;\n});\nAccordionTitle.displayName = 'Accordion.Title';\n\nexport const Accordion = Object.assign(AccordionRoot, {\n Item: AccordionItem,\n Trigger: AccordionTrigger,\n Title: AccordionTitle,\n Indicator: AccordionIndicator,\n Content: AccordionContent,\n});\n" + "content": "/**\n * Accordion — collapsible sections.\n *\n * Split into Item / Trigger / Indicator / Content so a section header can hold\n * anything — a badge, a price, a second line — instead of only a string. The\n * chevron rotates rather than swapping glyphs, and the panel expands through a\n * layout transition, both on the UI thread.\n *\n * `selectionMode` decides whether opening one section closes the others, which\n * also changes the shape of `value`: a string when single, an array when\n * multiple.\n *\n * With the operating system set to reduce motion the section arrives at its new\n * height and the chevron at its new angle without travelling to either. The\n * disclosure is the point and it still happens; the movement is what goes.\n *\n * A closed section costs nothing, because its body is unmounted. That is the\n * right default and the wrong one for a body with state in it: a half-filled\n * form, a list scrolled to the middle, a video part-way through. Collapsing\n * such a section throws that away and reopening it starts over. `keepMounted`\n * is the way out — the body stays mounted and is hidden from layout instead,\n * so it takes up no room, the same layout transition carries the change, and\n * everything inside is exactly where it was left.\n */\nimport {\n Children,\n createContext,\n forwardRef,\n isValidElement,\n useCallback,\n useContext,\n useEffect,\n useMemo,\n useRef,\n useState,\n type ReactNode,\n} from 'react';\nimport {\n Pressable,\n View,\n type PressableProps,\n type Text as RNText,\n type ViewProps,\n} from 'react-native';\nimport Animated, {\n LinearTransition,\n useAnimatedStyle,\n useReducedMotion,\n useSharedValue,\n withTiming,\n} from 'react-native-reanimated';\nimport { tv } from 'tailwind-variants';\nimport { ChevronDownIcon } from '@/components/ui/icons';\nimport { Text, type TextProps, textChildren } from '@/components/ui/text';\nimport type { TextRef, ViewRef } from '@/components/ui/refs';\n\nexport type AccordionVariant =\n | 'default'\n | 'surface'\n | 'separated'\n | 'bordered'\n | 'ghost';\n\nexport type AccordionSelectionMode = 'single' | 'multiple';\n\n/**\n * How long the height change and the chevron take. One constant for both, so\n * the panel and the arrow that describes it finish together.\n */\nconst TRANSITION_DURATION = 200;\n\nconst accordionVariants = tv({\n slots: {\n root: 'w-full flex-col overflow-hidden',\n separator: 'h-px bg-border',\n item: 'flex-col overflow-hidden',\n trigger: 'flex-row items-center justify-between gap-4 py-4',\n title: 'flex-1 text-base font-medium text-foreground',\n indicator: 'items-center justify-center',\n content: 'pb-4',\n contentText: 'text-sm text-muted-foreground',\n },\n variants: {\n variant: {\n default: { trigger: 'px-1', content: 'px-1' },\n surface: {\n root: 'rounded-2xl border border-border bg-surface',\n separator: 'mx-4',\n trigger: 'px-4',\n content: 'px-4',\n },\n separated: {\n root: 'gap-2.5 overflow-visible',\n separator: 'hidden',\n item: 'rounded-xl border border-border bg-card',\n trigger: 'px-4',\n content: 'px-4',\n },\n bordered: {\n separator: 'hidden',\n item: 'mb-2 rounded-xl border border-border',\n trigger: 'px-4',\n content: 'px-4',\n },\n ghost: {\n separator: 'hidden',\n trigger: 'px-0',\n content: 'px-0',\n },\n },\n },\n defaultVariants: {\n variant: 'default',\n },\n});\n\ninterface AccordionContextValue {\n expanded: string[];\n toggle: (value: string) => void;\n variant: AccordionVariant;\n keepMounted: boolean;\n}\n\ninterface AccordionItemContextValue {\n value: string;\n isExpanded: boolean;\n isDisabled: boolean;\n}\n\nconst AccordionContext = createContext(null);\nconst AccordionItemContext = createContext(null);\n\nfunction useAccordion(component: string): AccordionContextValue {\n const context = useContext(AccordionContext);\n if (!context) throw new Error(`${component} must be used within an `);\n return context;\n}\n\nfunction useAccordionItem(component: string): AccordionItemContextValue {\n const context = useContext(AccordionItemContext);\n if (!context) throw new Error(`${component} must be used within an `);\n return context;\n}\n\nexport interface AccordionProps extends ViewProps {\n className?: string;\n variant?: AccordionVariant;\n /** `single` collapses the open item when another opens. */\n selectionMode?: AccordionSelectionMode;\n /** Expanded item value(s), controlled. */\n value?: string | string[];\n defaultValue?: string | string[];\n onValueChange?: (value: string | string[]) => void;\n /** Hide the hairlines drawn between items. */\n hideSeparator?: boolean;\n /**\n * Keep every body mounted while its section is closed, so state inside it —\n * a part-filled form, a scroll position, a running animation — survives being\n * collapsed. Costs the render of every section up front; set it per section\n * on `Accordion.Content` instead when only one of them needs it.\n */\n keepMounted?: boolean;\n children?: ReactNode;\n}\n\nconst toArray = (value: string | string[] | undefined): string[] =>\n value === undefined ? [] : Array.isArray(value) ? value : [value];\n\nconst AccordionRoot = forwardRef(\n (\n {\n className,\n variant = 'default',\n selectionMode = 'single',\n value,\n defaultValue,\n onValueChange,\n hideSeparator = false,\n keepMounted = false,\n children,\n ...props\n },\n ref\n ) => {\n const reducedMotion = useReducedMotion();\n const [internal, setInternal] = useState(() => toArray(defaultValue));\n const isControlled = value !== undefined;\n const expanded = isControlled ? toArray(value) : internal;\n\n const toggle = useCallback(\n (itemValue: string) => {\n const isOpen = expanded.includes(itemValue);\n const next =\n selectionMode === 'single'\n ? isOpen\n ? []\n : [itemValue]\n : isOpen\n ? expanded.filter((entry) => entry !== itemValue)\n : [...expanded, itemValue];\n\n if (!isControlled) setInternal(next);\n // Hand back the shape the caller gave us.\n onValueChange?.(selectionMode === 'single' ? (next[0] ?? '') : next);\n },\n [expanded, selectionMode, isControlled, onValueChange]\n );\n\n const context = useMemo(\n () => ({ expanded, toggle, variant, keepMounted }),\n [expanded, toggle, variant, keepMounted]\n );\n\n const { root, separator } = accordionVariants({ variant });\n const items = Children.toArray(children).filter(isValidElement);\n\n return (\n \n \n {items.map((child, index) => (\n \n {child}\n {!hideSeparator && index < items.length - 1 ? (\n \n ) : null}\n \n ))}\n \n \n );\n }\n);\nAccordionRoot.displayName = 'Accordion';\n\nexport interface AccordionItemProps extends ViewProps {\n className?: string;\n /** Identifies this item in the accordion's value. */\n value: string;\n isDisabled?: boolean;\n children?: ReactNode;\n}\n\nconst AccordionItem = forwardRef(\n ({ className, value, isDisabled = false, children, ...props }, ref) => {\n const { expanded, variant } = useAccordion('Accordion.Item');\n const reducedMotion = useReducedMotion();\n const isExpanded = expanded.includes(value);\n const { item } = accordionVariants({ variant });\n\n const context = useMemo(\n () => ({ value, isExpanded, isDisabled }),\n [value, isExpanded, isDisabled]\n );\n\n return (\n \n \n {textChildren(children)}\n \n \n );\n }\n);\nAccordionItem.displayName = 'Accordion.Item';\n\nexport interface AccordionTriggerProps extends Omit {\n className?: string;\n children?: ReactNode;\n}\n\n/** The pressable header row. Bare strings are wrapped in the title style. */\nconst AccordionTrigger = forwardRef(\n ({ className, children, onPress, disabled = false, ...props }, ref) => {\n const { toggle, variant } = useAccordion('Accordion.Trigger');\n const { value, isExpanded, isDisabled } = useAccordionItem('Accordion.Trigger');\n const { trigger, title } = accordionVariants({ variant });\n const triggerDisabled = Boolean(isDisabled || disabled);\n\n return (\n {\n onPress?.(event);\n toggle(value);\n }}\n >\n {textChildren(children, (text) => (\n {text}\n ))}\n \n );\n }\n);\nAccordionTrigger.displayName = 'Accordion.Trigger';\n\nexport interface AccordionIndicatorProps extends ViewProps {\n className?: string;\n /** Replaces the default chevron. */\n children?: ReactNode;\n}\n\n/** Chevron that rotates 180° while the item is open. */\nconst AccordionIndicator = forwardRef(\n ({ className, children, ...props }, ref) => {\n const { variant } = useAccordion('Accordion.Indicator');\n const { isExpanded } = useAccordionItem('Accordion.Indicator');\n const { indicator } = accordionVariants({ variant });\n const reducedMotion = useReducedMotion();\n const progress = useSharedValue(isExpanded ? 1 : 0);\n const first = useRef(true);\n\n useEffect(() => {\n // There is nothing to travel on mount: the item is already open or shut,\n // and the chevron already points that way. Animating anyway would\n // schedule a 0-to-0 timing per indicator, and a screen that mounts a list\n // of them pays for every one before it can draw. The angle is assigned\n // rather than assumed — a plain number registers no animation, and an\n // indicator left at the wrong angle stays wrong for as long as it lives.\n if (first.current) {\n first.current = false;\n progress.value = isExpanded ? 1 : 0;\n return;\n }\n progress.value = reducedMotion\n ? isExpanded\n ? 1\n : 0\n : withTiming(isExpanded ? 1 : 0, { duration: TRANSITION_DURATION });\n }, [isExpanded, reducedMotion, progress]);\n\n const style = useAnimatedStyle(() => ({\n transform: [{ rotate: `${progress.value * 180}deg` }],\n }));\n\n return (\n \n \n {children ?? }\n \n \n );\n }\n);\nAccordionIndicator.displayName = 'Accordion.Indicator';\n\nexport interface AccordionContentProps extends ViewProps {\n className?: string;\n /**\n * Stay mounted while closed instead of unmounting, so state inside the body\n * survives the section being collapsed. Overrides the accordion's own\n * setting, either way round.\n */\n keepMounted?: boolean;\n children?: ReactNode;\n}\n\n/**\n * The collapsible body. Unmounts when closed, per the repo's convention for\n * conditionally shown content — the layout transition on the item animates\n * the height change.\n *\n * `keepMounted` swaps the unmount for `display: 'none'`, which is the one way\n * to hide a view that also takes it out of Yoga's layout. That matters twice\n * over: the item's height changes by exactly as much as it would have on an\n * unmount, so the same layout transition plays and the two modes are\n * indistinguishable to look at — and everything inside stays mounted, so a\n * text field keeps what was typed into it. A hidden subtree is still in the\n * accessibility tree, though, so it is explicitly taken out of that too;\n * otherwise a screen reader would read out a section the eye cannot see.\n */\nconst AccordionContent = forwardRef(\n ({ className, keepMounted, children, ...props }, ref) => {\n const { variant, keepMounted: keepMountedDefault } = useAccordion('Accordion.Content');\n const { isExpanded } = useAccordionItem('Accordion.Content');\n const { content, contentText } = accordionVariants({ variant });\n\n const stayMounted = keepMounted ?? keepMountedDefault;\n if (!isExpanded && !stayMounted) return null;\n\n const isHidden = !isExpanded;\n\n return (\n \n {textChildren(children, (text) => (\n {text}\n ))}\n \n );\n }\n);\nAccordionContent.displayName = 'Accordion.Content';\n\nconst AccordionTitle = forwardRef(({ className, ...props }, ref) => {\n const { variant } = useAccordion('Accordion.Title');\n const { title } = accordionVariants({ variant });\n return ;\n});\nAccordionTitle.displayName = 'Accordion.Title';\n\nexport const Accordion = Object.assign(AccordionRoot, {\n Item: AccordionItem,\n Trigger: AccordionTrigger,\n Title: AccordionTitle,\n Indicator: AccordionIndicator,\n Content: AccordionContent,\n});\n" } ] } diff --git a/apps/docs/public/r/alert.json b/apps/docs/public/r/alert.json index b3573f24d..706c57269 100644 --- a/apps/docs/public/r/alert.json +++ b/apps/docs/public/r/alert.json @@ -5,6 +5,7 @@ "registryDependencies": [ "cn", "icons", + "refs", "text" ], "dependencies": [ @@ -16,7 +17,7 @@ { "path": "ui/alert.tsx", "type": "registry:ui", - "content": "/**\n * Alert — a status message with an optional leading status icon.\n *\n * The indicator derives its icon and colour from the variant, so a success\n * alert cannot end up with a warning icon. Pass `icon` on the root to override\n * it everywhere, or give `Alert.Indicator` children to override one.\n */\nimport { createContext, forwardRef, useContext, type ReactNode } from 'react';\nimport { View, type Text as RNText, type ViewProps } from 'react-native';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport { useCSSVariable } from 'uniwind';\nimport { cn } from '@/lib/cn';\nimport { Text, type TextProps, textChildren } from '@/components/ui/text';\nimport { AlertTriangleIcon, CheckCircleIcon, InfoIcon } from '@/components/ui/icons';\n\nconst alertVariants = tv({\n slots: {\n root: 'w-full flex-row gap-3 rounded-xl border px-3.5 py-3',\n indicator: 'pt-px',\n content: 'flex-1 gap-0.5',\n title: 'text-sm font-medium',\n description: 'text-sm text-muted-foreground',\n },\n variants: {\n variant: {\n default: {\n root: 'border-border bg-surface',\n title: 'text-card-foreground',\n },\n info: {\n root: 'border-info/32 bg-info-soft',\n title: 'text-info-foreground',\n },\n success: {\n root: 'border-success/32 bg-success-soft',\n title: 'text-success-foreground',\n },\n warning: {\n root: 'border-warning/32 bg-warning-soft',\n title: 'text-warning-foreground',\n },\n destructive: {\n root: 'border-destructive/32 bg-destructive-soft',\n title: 'text-destructive-foreground',\n },\n },\n },\n defaultVariants: {\n variant: 'default',\n },\n});\n\ntype AlertVariant = NonNullable['variant']>;\n\nconst AlertContext = createContext('default');\n\n/** CSS variable each variant's indicator icon takes its colour from. */\nconst INDICATOR_COLOR_VAR: Record = {\n default: '--color-muted-foreground',\n info: '--color-info-foreground',\n success: '--color-success-foreground',\n warning: '--color-warning-foreground',\n destructive: '--color-destructive-foreground',\n};\n\nconst INDICATOR_ICON: Record = {\n default: InfoIcon,\n info: InfoIcon,\n success: CheckCircleIcon,\n warning: AlertTriangleIcon,\n destructive: AlertTriangleIcon,\n};\n\nexport interface AlertProps extends ViewProps, VariantProps {\n className?: string;\n /**\n * Leading element rendered before the content.\n *\n * @deprecated Prefer ``, which picks a status icon for you.\n * Still honoured so v0.2 call sites keep rendering.\n */\n icon?: ReactNode;\n children?: ReactNode;\n}\n\nconst AlertRoot = forwardRef(\n ({ className, variant = 'default', icon, children, ...props }, ref) => {\n const { root, indicator, content } = alertVariants({ variant });\n\n return (\n \n \n {icon ? (\n <>\n {icon}\n {textChildren(children)}\n \n ) : (\n children\n )}\n \n \n );\n }\n);\nAlertRoot.displayName = 'Alert';\n\nexport interface AlertIndicatorProps extends ViewProps {\n className?: string;\n /** Overrides the size/colour of the default status icon. */\n iconProps?: { size?: number; color?: string };\n /** Replaces the default status icon entirely. */\n children?: ReactNode;\n}\n\n/**\n * Leading status icon. Resolves both the icon and its colour from the parent\n * Alert's variant, so it follows the active theme.\n */\nconst AlertIndicator = forwardRef(\n ({ className, iconProps, children, ...props }, ref) => {\n const variant = useContext(AlertContext);\n const { indicator } = alertVariants({ variant });\n const themeColor = useCSSVariable(INDICATOR_COLOR_VAR[variant]);\n const Icon = INDICATOR_ICON[variant];\n\n const color =\n iconProps?.color ?? (typeof themeColor === 'string' ? themeColor : undefined);\n\n return (\n \n {children ?? }\n \n );\n }\n);\nAlertIndicator.displayName = 'Alert.Indicator';\n\n/** Flex-1 wrapper for Alert.Title and Alert.Description. */\nconst AlertContent = forwardRef(\n ({ className, ...props }, ref) => {\n const variant = useContext(AlertContext);\n const { content } = alertVariants({ variant });\n return ;\n }\n);\nAlertContent.displayName = 'Alert.Content';\n\nconst AlertTitle = forwardRef(\n ({ className, ...props }, ref) => {\n const variant = useContext(AlertContext);\n const { title } = alertVariants({ variant });\n return ;\n }\n);\nAlertTitle.displayName = 'Alert.Title';\n\nconst AlertDescription = forwardRef(\n ({ className, ...props }, ref) => {\n const variant = useContext(AlertContext);\n const { description } = alertVariants({ variant });\n return ;\n }\n);\nAlertDescription.displayName = 'Alert.Description';\n\nexport const Alert = Object.assign(AlertRoot, {\n /** @optional Status icon rendered as the leading visual element. */\n Indicator: AlertIndicator,\n /** @optional Wrapper for title and description. */\n Content: AlertContent,\n Title: AlertTitle,\n Description: AlertDescription,\n});\n" + "content": "/**\n * Alert — a status message with an optional leading status icon.\n *\n * The indicator derives its icon and colour from the variant, so a success\n * alert cannot end up with a warning icon. Pass `icon` on the root to override\n * it everywhere, or give `Alert.Indicator` children to override one.\n */\nimport { createContext, forwardRef, useContext, type ReactNode } from 'react';\nimport { View, type Text as RNText, type ViewProps } from 'react-native';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport { useCSSVariable } from 'uniwind';\nimport { cn } from '@/lib/cn';\nimport { Text, type TextProps, textChildren } from '@/components/ui/text';\nimport { AlertTriangleIcon, CheckCircleIcon, InfoIcon } from '@/components/ui/icons';\nimport type { TextRef, ViewRef } from '@/components/ui/refs';\n\nconst alertVariants = tv({\n slots: {\n root: 'w-full flex-row gap-3 rounded-xl border px-3.5 py-3',\n indicator: 'pt-px',\n content: 'flex-1 gap-0.5',\n title: 'text-sm font-medium',\n description: 'text-sm text-muted-foreground',\n },\n variants: {\n variant: {\n default: {\n root: 'border-border bg-surface',\n title: 'text-card-foreground',\n },\n info: {\n root: 'border-info/32 bg-info-soft',\n title: 'text-info-foreground',\n },\n success: {\n root: 'border-success/32 bg-success-soft',\n title: 'text-success-foreground',\n },\n warning: {\n root: 'border-warning/32 bg-warning-soft',\n title: 'text-warning-foreground',\n },\n destructive: {\n root: 'border-destructive/32 bg-destructive-soft',\n title: 'text-destructive-foreground',\n },\n },\n },\n defaultVariants: {\n variant: 'default',\n },\n});\n\ntype AlertVariant = NonNullable['variant']>;\n\nconst AlertContext = createContext('default');\n\n/** CSS variable each variant's indicator icon takes its colour from. */\nconst INDICATOR_COLOR_VAR: Record = {\n default: '--color-muted-foreground',\n info: '--color-info-foreground',\n success: '--color-success-foreground',\n warning: '--color-warning-foreground',\n destructive: '--color-destructive-foreground',\n};\n\nconst INDICATOR_ICON: Record = {\n default: InfoIcon,\n info: InfoIcon,\n success: CheckCircleIcon,\n warning: AlertTriangleIcon,\n destructive: AlertTriangleIcon,\n};\n\nexport interface AlertProps extends ViewProps, VariantProps {\n className?: string;\n /**\n * Leading element rendered before the content.\n *\n * @deprecated Prefer ``, which picks a status icon for you.\n * Still honoured so v0.2 call sites keep rendering.\n */\n icon?: ReactNode;\n children?: ReactNode;\n}\n\nconst AlertRoot = forwardRef(\n ({ className, variant = 'default', icon, children, ...props }, ref) => {\n const { root, indicator, content } = alertVariants({ variant });\n\n return (\n \n \n {icon ? (\n <>\n {icon}\n {textChildren(children)}\n \n ) : (\n children\n )}\n \n \n );\n }\n);\nAlertRoot.displayName = 'Alert';\n\nexport interface AlertIndicatorProps extends ViewProps {\n className?: string;\n /** Overrides the size/colour of the default status icon. */\n iconProps?: { size?: number; color?: string };\n /** Replaces the default status icon entirely. */\n children?: ReactNode;\n}\n\n/**\n * Leading status icon. Resolves both the icon and its colour from the parent\n * Alert's variant, so it follows the active theme.\n */\nconst AlertIndicator = forwardRef(\n ({ className, iconProps, children, ...props }, ref) => {\n const variant = useContext(AlertContext);\n const { indicator } = alertVariants({ variant });\n const themeColor = useCSSVariable(INDICATOR_COLOR_VAR[variant]);\n const Icon = INDICATOR_ICON[variant];\n\n const color =\n iconProps?.color ?? (typeof themeColor === 'string' ? themeColor : undefined);\n\n return (\n \n {children ?? }\n \n );\n }\n);\nAlertIndicator.displayName = 'Alert.Indicator';\n\n/** Flex-1 wrapper for Alert.Title and Alert.Description. */\nconst AlertContent = forwardRef(\n ({ className, ...props }, ref) => {\n const variant = useContext(AlertContext);\n const { content } = alertVariants({ variant });\n return ;\n }\n);\nAlertContent.displayName = 'Alert.Content';\n\nconst AlertTitle = forwardRef(\n ({ className, ...props }, ref) => {\n const variant = useContext(AlertContext);\n const { title } = alertVariants({ variant });\n return ;\n }\n);\nAlertTitle.displayName = 'Alert.Title';\n\nconst AlertDescription = forwardRef(\n ({ className, ...props }, ref) => {\n const variant = useContext(AlertContext);\n const { description } = alertVariants({ variant });\n return ;\n }\n);\nAlertDescription.displayName = 'Alert.Description';\n\nexport const Alert = Object.assign(AlertRoot, {\n /** @optional Status icon rendered as the leading visual element. */\n Indicator: AlertIndicator,\n /** @optional Wrapper for title and description. */\n Content: AlertContent,\n Title: AlertTitle,\n Description: AlertDescription,\n});\n" } ] } diff --git a/apps/docs/public/r/animated-badge.json b/apps/docs/public/r/animated-badge.json index db1d4cda5..9fb556527 100644 --- a/apps/docs/public/r/animated-badge.json +++ b/apps/docs/public/r/animated-badge.json @@ -4,6 +4,7 @@ "description": "Status pill whose icon and label roll over when the status changes.", "registryDependencies": [ "icons", + "refs", "text" ], "dependencies": [ @@ -16,7 +17,7 @@ { "path": "ui/animated-badge.tsx", "type": "registry:ui", - "content": "/**\n * AnimatedBadge — a status pill whose icon and label roll over when the status\n * changes.\n *\n * A badge that swaps its word between one frame and the next is a badge people\n * miss. The status is the smallest thing on the screen and usually not the\n * thing being looked at, so a change with no movement in it registers as\n * having always said that. Rolling the old glyph out and the new one in is\n * what makes the change itself visible.\n *\n * ```tsx\n * Deploying\n * Live\n * ```\n *\n * ## The roll, and the width\n *\n * The outgoing glyph rises out of the pill and fades; the incoming one comes\n * up from below and settles on a spring, with a slight rotation and scale so\n * it reads as turning over rather than sliding. Both are clipped to the pill,\n * which is what keeps the movement inside the badge instead of over whatever\n * it sits beside.\n *\n * The pill's width springs to the new word rather than jumping, because a\n * badge in a row of them shoves its neighbours as it changes and a jump does\n * that in one frame. Everything runs on the UI thread.\n *\n * That spring covers position as well as width, so a badge set inside a\n * paragraph slides across a line the text engine has already finished laying\n * out. Pass `animateLayout={false}` there: the figure still rolls, and the\n * pill stays where the sentence puts it.\n *\n * ## Which change counts as a change\n *\n * The label is keyed on what it says, so `\"Queued\"` to `\"Building\"` rolls and\n * a re-render with the same word does not. Where the label is an element\n * rather than a string, or where two different states share a word, pass\n * `contentKey` — the badge cannot tell those apart on its own, and without a\n * key it either animates on every render or never.\n *\n * ## Reduced motion\n *\n * With the preference on, every part of this is skipped: the glyph and the\n * label cut over, the pill resizes immediately, and the pulse does not run.\n * The badge still says what it says, which is the part that mattered.\n */\nimport {\n forwardRef,\n useCallback,\n useEffect,\n useRef,\n useState,\n type ReactNode,\n} from 'react';\nimport { View, type ViewProps } from 'react-native';\nimport Animated, {\n Easing,\n LinearTransition,\n runOnJS,\n useAnimatedStyle,\n useReducedMotion,\n useSharedValue,\n withRepeat,\n withSequence,\n withSpring,\n withTiming,\n} from 'react-native-reanimated';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport { useCSSVariable } from 'uniwind';\nimport {\n AlertTriangleIcon,\n CheckCircleIcon,\n CircleIcon,\n InfoIcon,\n XIcon,\n} from '@/components/ui/icons';\nimport { IconColorProvider } from '@/components/ui/icons';\nimport { Text, textChildren, textLabel } from '@/components/ui/text';\n\n/** How long the pulse takes to swell and settle again, in milliseconds. */\nconst PULSE_DURATION = 800;\n\n/** The roll's exit: short, because it is over before anyone looks at it. */\nconst ROLL_OUT = 180;\n\n/** The roll's entrance. Springy, so the glyph lands rather than arriving. */\nconst ROLL_IN = { damping: 18, stiffness: 210, mass: 0.85 } as const;\n\nexport type AnimatedBadgeStatus =\n | 'neutral'\n | 'info'\n | 'success'\n | 'warning'\n | 'danger'\n | 'loading';\n\nexport type AnimatedBadgeSize = 'sm' | 'md';\n\nconst animatedBadgeVariants = tv({\n slots: {\n /*\n * Clipped, which is the whole mechanism: the glyphs travel a full line box\n * in and out, and without this they would be drawn over whatever the badge\n * is sitting next to.\n */\n /*\n * No border. A ring would have to be a colour per status, and there is no\n * token for one — `border` on its own resolves to `currentColor`, which\n * comes out black on every pill in every theme. The tinted fill is what\n * separates the badge from the surface, which is how `Badge` does it too.\n */\n root: 'flex-row items-center self-start overflow-hidden rounded-full',\n label: 'font-medium',\n /** The pulse's fill, behind the content and inside the same clip. */\n pulse: 'absolute inset-0',\n /** One clipped column per rolling element, so the two move independently. */\n slot: 'items-center justify-center overflow-hidden',\n },\n variants: {\n status: {\n neutral: {\n root: 'bg-muted',\n label: 'text-muted-foreground',\n pulse: 'bg-muted-foreground',\n },\n info: {\n root: 'bg-info-subtle',\n label: 'text-info-foreground',\n pulse: 'bg-info',\n },\n success: {\n root: 'bg-success-subtle',\n label: 'text-success-foreground',\n pulse: 'bg-success',\n },\n warning: {\n root: 'bg-warning-subtle',\n label: 'text-warning-foreground',\n pulse: 'bg-warning',\n },\n danger: {\n root: 'bg-destructive-subtle',\n label: 'text-destructive-foreground',\n pulse: 'bg-destructive',\n },\n loading: {\n root: 'bg-info-subtle',\n label: 'text-info-foreground',\n pulse: 'bg-info',\n },\n },\n size: {\n sm: { root: 'h-6 gap-1.5 px-2', label: 'text-[11px]' },\n md: { root: 'h-8 gap-2 px-3', label: 'text-xs' },\n },\n },\n defaultVariants: {\n status: 'neutral',\n size: 'md',\n },\n});\n\n/** The glyph each status carries, when none is passed. */\nconst STATUS_ICON: Record = {\n neutral: CircleIcon,\n info: InfoIcon,\n success: CheckCircleIcon,\n warning: AlertTriangleIcon,\n danger: XIcon,\n // Never drawn: `loading` uses the spinner instead, because a still glyph\n // beside the word \"loading\" is the one status that has to move.\n loading: CircleIcon,\n};\n\n/** Which token the glyph is tinted from, so it matches the label beside it. */\nconst STATUS_COLOR_VAR: Record = {\n neutral: '--color-muted-foreground',\n info: '--color-info-foreground',\n success: '--color-success-foreground',\n warning: '--color-warning-foreground',\n danger: '--color-destructive-foreground',\n loading: '--color-info-foreground',\n};\n\n/** Glyph sizes per badge size — the icon tracks the text, not the box. */\nconst ICON_SIZE: Record = { sm: 12, md: 14 };\n\n/** Milliseconds for one full turn of the loading ring. */\nconst SPIN_DURATION = 800;\n\n/** Whether a value is one of the statuses the badge knows how to draw. */\nfunction isStatus(value: unknown): value is AnimatedBadgeStatus {\n return typeof value === 'string' && Object.prototype.hasOwnProperty.call(STATUS_ICON, value);\n}\n\n/** Values already warned about, so a list of badges does not warn once per row. */\nconst warnedStatuses = new Set();\n\nexport interface AnimatedBadgeProps\n extends ViewProps,\n VariantProps {\n /**\n * Which state the badge shows. Anything other than the six statuses is drawn\n * as `neutral`, with a warning in development — map a boolean or a state of\n * your own onto one first, as in `status={synced ? 'success' : 'neutral'}`.\n */\n status?: AnimatedBadgeStatus;\n size?: AnimatedBadgeSize;\n /** The word. Changing it rolls the old one out and the new one in. */\n children?: ReactNode;\n /** A glyph of your own, in place of the status's. */\n icon?: ReactNode;\n /** Whether a glyph is drawn at all. */\n showIcon?: boolean;\n /**\n * A slow swell behind the content, for a status that is still happening.\n * On by default while `status` is `loading`, and off otherwise — pass it\n * explicitly for a state of your own that is also still running.\n */\n pulse?: boolean;\n /**\n * What counts as a change, when the label cannot say. The label is keyed on\n * its own text, so this is only needed where it is an element rather than a\n * string, or where two states share a word.\n */\n contentKey?: string | number;\n /**\n * Whether the pill springs to its new size, and to its new place when what\n * is around it moves.\n *\n * On by default, which is what a badge standing on its own wants: growing to\n * a longer word in one frame shoves whatever is beside it.\n *\n * Turn it off for a badge set in a line of text. The animation covers\n * position as well as size, and the words around it are placed by the text\n * engine and simply appear where they now belong — so an animating badge\n * slides across a sentence that has already finished moving, which reads as\n * the badge coming loose from the line rather than as the figure changing.\n */\n animateLayout?: boolean;\n className?: string;\n labelClassName?: string;\n}\n\nexport const AnimatedBadge = forwardRef(\n (\n {\n status: statusProp = 'neutral',\n size = 'md',\n children,\n icon,\n showIcon = true,\n pulse,\n contentKey,\n animateLayout = true,\n className,\n labelClassName,\n ...props\n },\n ref\n ) => {\n const reducedMotion = useReducedMotion();\n\n /*\n * Every lookup below is keyed on the status, so a value outside the six\n * finds no icon and the badge renders `undefined` as a component. That\n * crash names the badge but not the prop, so resolve it here instead.\n */\n const status: AnimatedBadgeStatus = isStatus(statusProp) ? statusProp : 'neutral';\n if (__DEV__ && !isStatus(statusProp)) {\n const received = String(statusProp);\n if (!warnedStatuses.has(received)) {\n warnedStatuses.add(received);\n console.warn(\n `AnimatedBadge: status must be one of ${Object.keys(STATUS_ICON).join(', ')}; ` +\n `received ${JSON.stringify(statusProp) ?? received}. Drawing it as neutral.`\n );\n }\n }\n\n const slots = animatedBadgeVariants({ status, size });\n\n const themeColor = useCSSVariable(STATUS_COLOR_VAR[status]);\n const iconColor = typeof themeColor === 'string' ? themeColor : undefined;\n const Icon = STATUS_ICON[status];\n\n const pulsing = (pulse ?? status === 'loading') && !reducedMotion;\n\n /*\n * Keyed on what it says, so a re-render with the same word does not roll.\n * Falling back to the status rather than to a constant means an element\n * label at least changes when the state does, which is the common case for\n * one — a caller with two states sharing a word passes `contentKey`.\n */\n const labelKey =\n contentKey ??\n (typeof children === 'string' || typeof children === 'number'\n ? children\n : status);\n\n const accessibleName = props.accessibilityLabel ?? textLabel(children);\n\n /*\n * The width spring is for a badge changing, not for one arriving.\n *\n * Arming it on mount is not enough, and that is the whole subtlety: the\n * pill's first *measured* layout lands a frame or two after the first\n * render — text measures, the row settles — so a spring switched on at\n * mount still catches that settling and animates it. From the outside the\n * badge appears, drifts, and stops, on every screen that shows one.\n *\n * So it arms on the first real change instead. Nothing has to be guessed\n * about when layout has finished, because until the status or the word\n * actually moves there is nothing the spring is for.\n */\n const [armed, setArmed] = useState(false);\n const first = useRef(null);\n useEffect(() => {\n const key = `${status}\\u0000${labelKey}`;\n if (first.current === null) {\n first.current = key;\n return;\n }\n if (key !== first.current) setArmed(true);\n }, [status, labelKey]);\n\n const swell = useSharedValue(0);\n useEffect(() => {\n if (!pulsing) {\n swell.value = withTiming(0, { duration: 200 });\n return;\n }\n swell.value = withRepeat(\n withSequence(\n withTiming(1, { duration: PULSE_DURATION, easing: Easing.inOut(Easing.quad) }),\n withTiming(0, { duration: PULSE_DURATION, easing: Easing.inOut(Easing.quad) })\n ),\n -1,\n false\n );\n }, [pulsing, swell]);\n\n const pulseStyle = useAnimatedStyle(() => ({\n opacity: 0.08 + swell.value * 0.1,\n transform: [{ scale: 0.96 + swell.value * 0.08 }],\n }));\n\n return (\n \n {pulsing ? (\n \n ) : null}\n\n {showIcon ? (\n \n {/*\n A glyph passed in is somebody else's, from any set, and it reads\n the ambient colour rather than the badge's status. Provided here\n so `icon` comes out the same colour as the word beside it instead\n of the icon set's own grey.\n */}\n \n \n {icon ??\n (status === 'loading' ? (\n \n ) : (\n \n ))}\n \n \n \n ) : null}\n\n {/* `{flag && 'Label'}` leaves a boolean here, which React draws as\n nothing — so it gets no slot either, rather than an empty gap. */}\n {children != null && typeof children !== 'boolean' ? (\n \n \n {textChildren(children, (text) => (\n {text}\n ))}\n \n \n ) : null}\n \n );\n }\n);\n\nAnimatedBadge.displayName = 'AnimatedBadge';\n\n/**\n * The turning ring `loading` draws in place of a glyph.\n *\n * Built here rather than borrowed from `Spinner` because this one has to be\n * the status's colour and the badge's glyph size, and `Spinner` takes both\n * from classes — a badge overriding them ends up fighting the merge, and an\n * arbitrary border width that fails to compile leaves a ring with no border at\n * all, which is an empty hole where the icon should be.\n *\n * The track and the arc are two views of the same colour rather than two\n * colours: the track is the same stroke at low opacity, so there is nothing to\n * resolve but the one value the label already uses.\n */\nfunction LoadingRing({\n size,\n color,\n reducedMotion,\n}: {\n size: number;\n color: string | undefined;\n reducedMotion: boolean;\n}) {\n const turn = useSharedValue(0);\n\n useEffect(() => {\n if (reducedMotion) return;\n turn.value = withRepeat(\n withTiming(1, { duration: SPIN_DURATION, easing: Easing.linear }),\n -1,\n false\n );\n }, [reducedMotion, turn]);\n\n const style = useAnimatedStyle(() => ({\n transform: [{ rotate: `${turn.value * 360}deg` }],\n }));\n\n const ring = {\n position: 'absolute',\n inset: 0,\n borderRadius: size / 2,\n borderWidth: Math.max(1, Math.round(size / 8)),\n } as const;\n\n return (\n \n \n \n \n );\n}\n\n/**\n * One element's turn: the old one out through the top, the new one up from\n * below.\n *\n * ## Why it is one view rather than two\n *\n * The obvious build is to key the element on its content and let the old one\n * animate out while the new one animates in. It does not work here: for the\n * length of the transition both are mounted in the same box, and the box\n * becomes as large as the pair of them — a badge that swells and collapses\n * around every change.\n *\n * So there is one view throughout, and the content is swapped at the far end\n * of the roll: it travels out carrying the old word, the word is changed while\n * it is off-screen, and it comes back with the new one. Nothing is ever in the\n * badge twice.\n *\n * The two halves are deliberately not symmetrical. The entrance is what the\n * reader is meant to follow, so it springs and takes its time; the exit is\n * only getting out of the way, and an exit that lingers holds the badge empty.\n *\n * The travel is a percentage rather than a distance, so a glyph and a word of\n * different heights each clear their own box by the same amount.\n */\nfunction Roll({\n contentKey,\n children,\n reducedMotion,\n turn = false,\n}: {\n /** What counts as a change. A new value rolls; the same value does not. */\n contentKey: string | number;\n children: ReactNode;\n reducedMotion: boolean;\n /** Add a little rotation, for a glyph. A rotating word is a gimmick. */\n turn?: boolean;\n}) {\n const [shownKey, setShownKey] = useState(contentKey);\n const settled = Object.is(shownKey, contentKey);\n\n /*\n * What is on screen while the swap is in flight. Held in a ref rather than\n * state because it is only read at the moment of the swap, and holding it in\n * state would re-render the badge on every parent render to store an element\n * nobody is looking at yet.\n */\n const outgoing = useRef(children);\n const incomingKey = useRef(contentKey);\n useEffect(() => {\n incomingKey.current = contentKey;\n if (settled) outgoing.current = children;\n });\n\n // -1 fully below, 0 at rest, +1 fully above.\n const phase = useSharedValue(0);\n\n const commit = useCallback(() => {\n setShownKey(incomingKey.current);\n phase.value = -1;\n phase.value = withSpring(0, ROLL_IN);\n }, [phase]);\n\n useEffect(() => {\n if (settled) {\n /*\n * Changed and changed back before the roll finished. The swap that would\n * have brought the element home is never scheduled, so without this it\n * stays parked outside the badge — an empty slot where the glyph should\n * be, for as long as the status holds.\n */\n if (phase.value !== 0) phase.value = withSpring(0, ROLL_IN);\n return;\n }\n if (reducedMotion) {\n setShownKey(incomingKey.current);\n return;\n }\n phase.value = withTiming(1, { duration: ROLL_OUT }, (finished) => {\n 'worklet';\n // Interrupted means another change arrived mid-roll; that change's own\n // effect owns the swap, and committing here too would swap twice.\n if (finished) runOnJS(commit)();\n });\n }, [contentKey, settled, reducedMotion, commit, phase]);\n\n const style = useAnimatedStyle(() => {\n const p = phase.value;\n const distance = Math.min(Math.abs(p), 1);\n return {\n opacity: 1 - distance * 0.9,\n transform: [\n { translateY: `${p * -90}%` },\n { scale: 1 - distance * 0.1 },\n { rotate: turn ? `${p * 12}deg` : '0deg' },\n ],\n };\n });\n\n return {settled ? children : outgoing.current};\n}\n" + "content": "/**\n * AnimatedBadge — a status pill whose icon and label roll over when the status\n * changes.\n *\n * A badge that swaps its word between one frame and the next is a badge people\n * miss. The status is the smallest thing on the screen and usually not the\n * thing being looked at, so a change with no movement in it registers as\n * having always said that. Rolling the old glyph out and the new one in is\n * what makes the change itself visible.\n *\n * ```tsx\n * Deploying\n * Live\n * ```\n *\n * ## The roll, and the width\n *\n * The outgoing glyph rises out of the pill and fades; the incoming one comes\n * up from below and settles on a spring, with a slight rotation and scale so\n * it reads as turning over rather than sliding. Both are clipped to the pill,\n * which is what keeps the movement inside the badge instead of over whatever\n * it sits beside.\n *\n * The pill's width springs to the new word rather than jumping, because a\n * badge in a row of them shoves its neighbours as it changes and a jump does\n * that in one frame. Everything runs on the UI thread.\n *\n * That spring covers position as well as width, so a badge set inside a\n * paragraph slides across a line the text engine has already finished laying\n * out. Pass `animateLayout={false}` there: the figure still rolls, and the\n * pill stays where the sentence puts it.\n *\n * ## Which change counts as a change\n *\n * The label is keyed on what it says, so `\"Queued\"` to `\"Building\"` rolls and\n * a re-render with the same word does not. Where the label is an element\n * rather than a string, or where two different states share a word, pass\n * `contentKey` — the badge cannot tell those apart on its own, and without a\n * key it either animates on every render or never.\n *\n * ## Reduced motion\n *\n * With the preference on, every part of this is skipped: the glyph and the\n * label cut over, the pill resizes immediately, and the pulse does not run.\n * The badge still says what it says, which is the part that mattered.\n */\nimport {\n forwardRef,\n useCallback,\n useEffect,\n useRef,\n useState,\n type ReactNode,\n} from 'react';\nimport { View, type ViewProps } from 'react-native';\nimport Animated, {\n Easing,\n LinearTransition,\n runOnJS,\n useAnimatedStyle,\n useReducedMotion,\n useSharedValue,\n withRepeat,\n withSequence,\n withSpring,\n withTiming,\n} from 'react-native-reanimated';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport { useCSSVariable } from 'uniwind';\nimport {\n AlertTriangleIcon,\n CheckCircleIcon,\n CircleIcon,\n InfoIcon,\n XIcon,\n} from '@/components/ui/icons';\nimport { IconColorProvider } from '@/components/ui/icons';\nimport { Text, textChildren, textLabel } from '@/components/ui/text';\nimport type { ViewRef } from '@/components/ui/refs';\n\n/** How long the pulse takes to swell and settle again, in milliseconds. */\nconst PULSE_DURATION = 800;\n\n/** The roll's exit: short, because it is over before anyone looks at it. */\nconst ROLL_OUT = 180;\n\n/** The roll's entrance. Springy, so the glyph lands rather than arriving. */\nconst ROLL_IN = { damping: 18, stiffness: 210, mass: 0.85 } as const;\n\nexport type AnimatedBadgeStatus =\n | 'neutral'\n | 'info'\n | 'success'\n | 'warning'\n | 'danger'\n | 'loading';\n\nexport type AnimatedBadgeSize = 'sm' | 'md';\n\nconst animatedBadgeVariants = tv({\n slots: {\n /*\n * Clipped, which is the whole mechanism: the glyphs travel a full line box\n * in and out, and without this they would be drawn over whatever the badge\n * is sitting next to.\n */\n /*\n * No border. A ring would have to be a colour per status, and there is no\n * token for one — `border` on its own resolves to `currentColor`, which\n * comes out black on every pill in every theme. The tinted fill is what\n * separates the badge from the surface, which is how `Badge` does it too.\n */\n root: 'flex-row items-center self-start overflow-hidden rounded-full',\n label: 'font-medium',\n /** The pulse's fill, behind the content and inside the same clip. */\n pulse: 'absolute inset-0',\n /** One clipped column per rolling element, so the two move independently. */\n slot: 'items-center justify-center overflow-hidden',\n },\n variants: {\n status: {\n neutral: {\n root: 'bg-muted',\n label: 'text-muted-foreground',\n pulse: 'bg-muted-foreground',\n },\n info: {\n root: 'bg-info-subtle',\n label: 'text-info-foreground',\n pulse: 'bg-info',\n },\n success: {\n root: 'bg-success-subtle',\n label: 'text-success-foreground',\n pulse: 'bg-success',\n },\n warning: {\n root: 'bg-warning-subtle',\n label: 'text-warning-foreground',\n pulse: 'bg-warning',\n },\n danger: {\n root: 'bg-destructive-subtle',\n label: 'text-destructive-foreground',\n pulse: 'bg-destructive',\n },\n loading: {\n root: 'bg-info-subtle',\n label: 'text-info-foreground',\n pulse: 'bg-info',\n },\n },\n size: {\n sm: { root: 'h-6 gap-1.5 px-2', label: 'text-[11px]' },\n md: { root: 'h-8 gap-2 px-3', label: 'text-xs' },\n },\n },\n defaultVariants: {\n status: 'neutral',\n size: 'md',\n },\n});\n\n/** The glyph each status carries, when none is passed. */\nconst STATUS_ICON: Record = {\n neutral: CircleIcon,\n info: InfoIcon,\n success: CheckCircleIcon,\n warning: AlertTriangleIcon,\n danger: XIcon,\n // Never drawn: `loading` uses the spinner instead, because a still glyph\n // beside the word \"loading\" is the one status that has to move.\n loading: CircleIcon,\n};\n\n/** Which token the glyph is tinted from, so it matches the label beside it. */\nconst STATUS_COLOR_VAR: Record = {\n neutral: '--color-muted-foreground',\n info: '--color-info-foreground',\n success: '--color-success-foreground',\n warning: '--color-warning-foreground',\n danger: '--color-destructive-foreground',\n loading: '--color-info-foreground',\n};\n\n/** Glyph sizes per badge size — the icon tracks the text, not the box. */\nconst ICON_SIZE: Record = { sm: 12, md: 14 };\n\n/** Milliseconds for one full turn of the loading ring. */\nconst SPIN_DURATION = 800;\n\n/** Whether a value is one of the statuses the badge knows how to draw. */\nfunction isStatus(value: unknown): value is AnimatedBadgeStatus {\n return typeof value === 'string' && Object.prototype.hasOwnProperty.call(STATUS_ICON, value);\n}\n\n/** Values already warned about, so a list of badges does not warn once per row. */\nconst warnedStatuses = new Set();\n\nexport interface AnimatedBadgeProps\n extends ViewProps,\n VariantProps {\n /**\n * Which state the badge shows. Anything other than the six statuses is drawn\n * as `neutral`, with a warning in development — map a boolean or a state of\n * your own onto one first, as in `status={synced ? 'success' : 'neutral'}`.\n */\n status?: AnimatedBadgeStatus;\n size?: AnimatedBadgeSize;\n /** The word. Changing it rolls the old one out and the new one in. */\n children?: ReactNode;\n /** A glyph of your own, in place of the status's. */\n icon?: ReactNode;\n /** Whether a glyph is drawn at all. */\n showIcon?: boolean;\n /**\n * A slow swell behind the content, for a status that is still happening.\n * On by default while `status` is `loading`, and off otherwise — pass it\n * explicitly for a state of your own that is also still running.\n */\n pulse?: boolean;\n /**\n * What counts as a change, when the label cannot say. The label is keyed on\n * its own text, so this is only needed where it is an element rather than a\n * string, or where two states share a word.\n */\n contentKey?: string | number;\n /**\n * Whether the pill springs to its new size, and to its new place when what\n * is around it moves.\n *\n * On by default, which is what a badge standing on its own wants: growing to\n * a longer word in one frame shoves whatever is beside it.\n *\n * Turn it off for a badge set in a line of text. The animation covers\n * position as well as size, and the words around it are placed by the text\n * engine and simply appear where they now belong — so an animating badge\n * slides across a sentence that has already finished moving, which reads as\n * the badge coming loose from the line rather than as the figure changing.\n */\n animateLayout?: boolean;\n className?: string;\n labelClassName?: string;\n}\n\nexport const AnimatedBadge = forwardRef(\n (\n {\n status: statusProp = 'neutral',\n size = 'md',\n children,\n icon,\n showIcon = true,\n pulse,\n contentKey,\n animateLayout = true,\n className,\n labelClassName,\n ...props\n },\n ref\n ) => {\n const reducedMotion = useReducedMotion();\n\n /*\n * Every lookup below is keyed on the status, so a value outside the six\n * finds no icon and the badge renders `undefined` as a component. That\n * crash names the badge but not the prop, so resolve it here instead.\n */\n const status: AnimatedBadgeStatus = isStatus(statusProp) ? statusProp : 'neutral';\n if (__DEV__ && !isStatus(statusProp)) {\n const received = String(statusProp);\n if (!warnedStatuses.has(received)) {\n warnedStatuses.add(received);\n console.warn(\n `AnimatedBadge: status must be one of ${Object.keys(STATUS_ICON).join(', ')}; ` +\n `received ${JSON.stringify(statusProp) ?? received}. Drawing it as neutral.`\n );\n }\n }\n\n const slots = animatedBadgeVariants({ status, size });\n\n const themeColor = useCSSVariable(STATUS_COLOR_VAR[status]);\n const iconColor = typeof themeColor === 'string' ? themeColor : undefined;\n const Icon = STATUS_ICON[status];\n\n const pulsing = (pulse ?? status === 'loading') && !reducedMotion;\n\n /*\n * Keyed on what it says, so a re-render with the same word does not roll.\n * Falling back to the status rather than to a constant means an element\n * label at least changes when the state does, which is the common case for\n * one — a caller with two states sharing a word passes `contentKey`.\n */\n const labelKey =\n contentKey ??\n (typeof children === 'string' || typeof children === 'number'\n ? children\n : status);\n\n const accessibleName = props.accessibilityLabel ?? textLabel(children);\n\n /*\n * The width spring is for a badge changing, not for one arriving.\n *\n * Arming it on mount is not enough, and that is the whole subtlety: the\n * pill's first *measured* layout lands a frame or two after the first\n * render — text measures, the row settles — so a spring switched on at\n * mount still catches that settling and animates it. From the outside the\n * badge appears, drifts, and stops, on every screen that shows one.\n *\n * So it arms on the first real change instead. Nothing has to be guessed\n * about when layout has finished, because until the status or the word\n * actually moves there is nothing the spring is for.\n */\n const [armed, setArmed] = useState(false);\n const first = useRef(null);\n useEffect(() => {\n const key = `${status}\\u0000${labelKey}`;\n if (first.current === null) {\n first.current = key;\n return;\n }\n if (key !== first.current) setArmed(true);\n }, [status, labelKey]);\n\n const swell = useSharedValue(0);\n useEffect(() => {\n if (!pulsing) {\n swell.value = withTiming(0, { duration: 200 });\n return;\n }\n swell.value = withRepeat(\n withSequence(\n withTiming(1, { duration: PULSE_DURATION, easing: Easing.inOut(Easing.quad) }),\n withTiming(0, { duration: PULSE_DURATION, easing: Easing.inOut(Easing.quad) })\n ),\n -1,\n false\n );\n }, [pulsing, swell]);\n\n const pulseStyle = useAnimatedStyle(() => ({\n opacity: 0.08 + swell.value * 0.1,\n transform: [{ scale: 0.96 + swell.value * 0.08 }],\n }));\n\n return (\n \n {pulsing ? (\n \n ) : null}\n\n {showIcon ? (\n \n {/*\n A glyph passed in is somebody else's, from any set, and it reads\n the ambient colour rather than the badge's status. Provided here\n so `icon` comes out the same colour as the word beside it instead\n of the icon set's own grey.\n */}\n \n \n {icon ??\n (status === 'loading' ? (\n \n ) : (\n \n ))}\n \n \n \n ) : null}\n\n {/* `{flag && 'Label'}` leaves a boolean here, which React draws as\n nothing — so it gets no slot either, rather than an empty gap. */}\n {children != null && typeof children !== 'boolean' ? (\n \n \n {textChildren(children, (text) => (\n {text}\n ))}\n \n \n ) : null}\n \n );\n }\n);\n\nAnimatedBadge.displayName = 'AnimatedBadge';\n\n/**\n * The turning ring `loading` draws in place of a glyph.\n *\n * Built here rather than borrowed from `Spinner` because this one has to be\n * the status's colour and the badge's glyph size, and `Spinner` takes both\n * from classes — a badge overriding them ends up fighting the merge, and an\n * arbitrary border width that fails to compile leaves a ring with no border at\n * all, which is an empty hole where the icon should be.\n *\n * The track and the arc are two views of the same colour rather than two\n * colours: the track is the same stroke at low opacity, so there is nothing to\n * resolve but the one value the label already uses.\n */\nfunction LoadingRing({\n size,\n color,\n reducedMotion,\n}: {\n size: number;\n color: string | undefined;\n reducedMotion: boolean;\n}) {\n const turn = useSharedValue(0);\n\n useEffect(() => {\n if (reducedMotion) return;\n turn.value = withRepeat(\n withTiming(1, { duration: SPIN_DURATION, easing: Easing.linear }),\n -1,\n false\n );\n }, [reducedMotion, turn]);\n\n const style = useAnimatedStyle(() => ({\n transform: [{ rotate: `${turn.value * 360}deg` }],\n }));\n\n const ring = {\n position: 'absolute',\n inset: 0,\n borderRadius: size / 2,\n borderWidth: Math.max(1, Math.round(size / 8)),\n } as const;\n\n return (\n \n \n \n \n );\n}\n\n/**\n * One element's turn: the old one out through the top, the new one up from\n * below.\n *\n * ## Why it is one view rather than two\n *\n * The obvious build is to key the element on its content and let the old one\n * animate out while the new one animates in. It does not work here: for the\n * length of the transition both are mounted in the same box, and the box\n * becomes as large as the pair of them — a badge that swells and collapses\n * around every change.\n *\n * So there is one view throughout, and the content is swapped at the far end\n * of the roll: it travels out carrying the old word, the word is changed while\n * it is off-screen, and it comes back with the new one. Nothing is ever in the\n * badge twice.\n *\n * The two halves are deliberately not symmetrical. The entrance is what the\n * reader is meant to follow, so it springs and takes its time; the exit is\n * only getting out of the way, and an exit that lingers holds the badge empty.\n *\n * The travel is a percentage rather than a distance, so a glyph and a word of\n * different heights each clear their own box by the same amount.\n */\nfunction Roll({\n contentKey,\n children,\n reducedMotion,\n turn = false,\n}: {\n /** What counts as a change. A new value rolls; the same value does not. */\n contentKey: string | number;\n children: ReactNode;\n reducedMotion: boolean;\n /** Add a little rotation, for a glyph. A rotating word is a gimmick. */\n turn?: boolean;\n}) {\n const [shownKey, setShownKey] = useState(contentKey);\n const settled = Object.is(shownKey, contentKey);\n\n /*\n * What is on screen while the swap is in flight. Held in a ref rather than\n * state because it is only read at the moment of the swap, and holding it in\n * state would re-render the badge on every parent render to store an element\n * nobody is looking at yet.\n */\n const outgoing = useRef(children);\n const incomingKey = useRef(contentKey);\n useEffect(() => {\n incomingKey.current = contentKey;\n if (settled) outgoing.current = children;\n });\n\n // -1 fully below, 0 at rest, +1 fully above.\n const phase = useSharedValue(0);\n\n const commit = useCallback(() => {\n setShownKey(incomingKey.current);\n phase.value = -1;\n phase.value = withSpring(0, ROLL_IN);\n }, [phase]);\n\n useEffect(() => {\n if (settled) {\n /*\n * Changed and changed back before the roll finished. The swap that would\n * have brought the element home is never scheduled, so without this it\n * stays parked outside the badge — an empty slot where the glyph should\n * be, for as long as the status holds.\n */\n if (phase.value !== 0) phase.value = withSpring(0, ROLL_IN);\n return;\n }\n if (reducedMotion) {\n setShownKey(incomingKey.current);\n return;\n }\n phase.value = withTiming(1, { duration: ROLL_OUT }, (finished) => {\n 'worklet';\n // Interrupted means another change arrived mid-roll; that change's own\n // effect owns the swap, and committing here too would swap twice.\n if (finished) runOnJS(commit)();\n });\n }, [contentKey, settled, reducedMotion, commit, phase]);\n\n const style = useAnimatedStyle(() => {\n const p = phase.value;\n const distance = Math.min(Math.abs(p), 1);\n return {\n opacity: 1 - distance * 0.9,\n transform: [\n { translateY: `${p * -90}%` },\n { scale: 1 - distance * 0.1 },\n { rotate: turn ? `${p * 12}deg` : '0deg' },\n ],\n };\n });\n\n return {settled ? children : outgoing.current};\n}\n" } ] } diff --git a/apps/docs/public/r/animated-pressable.json b/apps/docs/public/r/animated-pressable.json index efd0a89e2..c0a276849 100644 --- a/apps/docs/public/r/animated-pressable.json +++ b/apps/docs/public/r/animated-pressable.json @@ -2,7 +2,9 @@ "name": "animated-pressable", "type": "registry:ui", "description": "Pressable with UI-thread press feedback.", - "registryDependencies": [], + "registryDependencies": [ + "refs" + ], "dependencies": [ "react-native-reanimated", "uniwind" @@ -12,7 +14,7 @@ { "path": "ui/animated-pressable.tsx", "type": "registry:ui", - "content": "import { forwardRef, useCallback } from 'react';\nimport {\n Pressable,\n StyleSheet,\n type GestureResponderEvent,\n type PressableProps,\n type View,\n} from 'react-native';\nimport Animated, {\n useAnimatedStyle,\n useSharedValue,\n withSpring,\n withTiming,\n} from 'react-native-reanimated';\nimport { useResolveClassNames } from 'uniwind';\n\nconst AnimatedPressableBase = Animated.createAnimatedComponent(Pressable);\n\nconst SPRING_CONFIG = { damping: 20, stiffness: 300, mass: 0.4 } as const;\n\nexport interface AnimatedPressableProps extends PressableProps {\n className?: string;\n /** Scale applied while pressed. Set to 1 to disable. Default 0.97. */\n pressScale?: number;\n /**\n * Opacity applied while pressed, as a fraction of the resting opacity. Set\n * to 1 to disable. Default 1.\n */\n pressOpacity?: number;\n}\n\n/**\n * Pressable with UI-thread press feedback (scale/opacity via Reanimated).\n * The shared base for every interactive PanelUI component.\n *\n * The animated style is applied after the `className` styles, so any key it\n * writes beats the same key from a class. That is why it writes `opacity` only\n * when the press actually fades: a component that dims itself when disabled\n * (`opacity-[0.64]`, or a caller's `disabled:opacity-50`) does it through the\n * class, and an animated `opacity: 1` at rest would undo it on every render.\n *\n * A pressable that does fade owns its opacity outright, so it reads the\n * resting value from `style` or `className` and fades from there rather than\n * from 1. Keep `pressOpacity` either 1 or not for the life of a component:\n * the style's keys follow it.\n */\nexport const AnimatedPressable = forwardRef(\n (\n {\n pressScale = 0.97,\n pressOpacity = 1,\n onPressIn,\n onPressOut,\n style,\n className,\n ...props\n },\n ref\n ) => {\n const pressed = useSharedValue(0);\n const fades = pressOpacity !== 1;\n\n // Only resolved when the press fades; an empty class resolves to nothing.\n const classStyle = useResolveClassNames(fades ? (className ?? '') : '');\n const ownStyle = typeof style === 'function' ? undefined : StyleSheet.flatten(style);\n const restingOpacity =\n typeof ownStyle?.opacity === 'number'\n ? ownStyle.opacity\n : typeof classStyle.opacity === 'number'\n ? classStyle.opacity\n : 1;\n\n const handlePressIn = useCallback(\n (event: GestureResponderEvent) => {\n pressed.value = withTiming(1, { duration: 80 });\n onPressIn?.(event);\n },\n [onPressIn, pressed]\n );\n\n const handlePressOut = useCallback(\n (event: GestureResponderEvent) => {\n pressed.value = withSpring(0, SPRING_CONFIG);\n onPressOut?.(event);\n },\n [onPressOut, pressed]\n );\n\n const animatedStyle = useAnimatedStyle(() => {\n const transform = [{ scale: 1 + pressed.value * (pressScale - 1) }];\n return fades\n ? { transform, opacity: restingOpacity * (1 + pressed.value * (pressOpacity - 1)) }\n : { transform };\n });\n\n return (\n \n );\n }\n);\n\nAnimatedPressable.displayName = 'AnimatedPressable';\n" + "content": "import { forwardRef, useCallback } from 'react';\nimport {\n Pressable,\n StyleSheet,\n type GestureResponderEvent,\n type PressableProps,\n type View,\n} from 'react-native';\nimport Animated, {\n useAnimatedStyle,\n useSharedValue,\n withSpring,\n withTiming,\n} from 'react-native-reanimated';\nimport { useResolveClassNames } from 'uniwind';\nimport type { ViewRef } from '@/components/ui/refs';\n\nconst AnimatedPressableBase = Animated.createAnimatedComponent(Pressable);\n\nconst SPRING_CONFIG = { damping: 20, stiffness: 300, mass: 0.4 } as const;\n\nexport interface AnimatedPressableProps extends PressableProps {\n className?: string;\n /** Scale applied while pressed. Set to 1 to disable. Default 0.97. */\n pressScale?: number;\n /**\n * Opacity applied while pressed, as a fraction of the resting opacity. Set\n * to 1 to disable. Default 1.\n */\n pressOpacity?: number;\n}\n\n/**\n * Pressable with UI-thread press feedback (scale/opacity via Reanimated).\n * The shared base for every interactive PanelUI component.\n *\n * The animated style is applied after the `className` styles, so any key it\n * writes beats the same key from a class. That is why it writes `opacity` only\n * when the press actually fades: a component that dims itself when disabled\n * (`opacity-[0.64]`, or a caller's `disabled:opacity-50`) does it through the\n * class, and an animated `opacity: 1` at rest would undo it on every render.\n *\n * A pressable that does fade owns its opacity outright, so it reads the\n * resting value from `style` or `className` and fades from there rather than\n * from 1. Keep `pressOpacity` either 1 or not for the life of a component:\n * the style's keys follow it.\n */\nexport const AnimatedPressable = forwardRef(\n (\n {\n pressScale = 0.97,\n pressOpacity = 1,\n onPressIn,\n onPressOut,\n style,\n className,\n ...props\n },\n ref\n ) => {\n const pressed = useSharedValue(0);\n const fades = pressOpacity !== 1;\n\n // Only resolved when the press fades; an empty class resolves to nothing.\n const classStyle = useResolveClassNames(fades ? (className ?? '') : '');\n const ownStyle = typeof style === 'function' ? undefined : StyleSheet.flatten(style);\n const restingOpacity =\n typeof ownStyle?.opacity === 'number'\n ? ownStyle.opacity\n : typeof classStyle.opacity === 'number'\n ? classStyle.opacity\n : 1;\n\n const handlePressIn = useCallback(\n (event: GestureResponderEvent) => {\n pressed.value = withTiming(1, { duration: 80 });\n onPressIn?.(event);\n },\n [onPressIn, pressed]\n );\n\n const handlePressOut = useCallback(\n (event: GestureResponderEvent) => {\n pressed.value = withSpring(0, SPRING_CONFIG);\n onPressOut?.(event);\n },\n [onPressOut, pressed]\n );\n\n const animatedStyle = useAnimatedStyle(() => {\n const transform = [{ scale: 1 + pressed.value * (pressScale - 1) }];\n return fades\n ? { transform, opacity: restingOpacity * (1 + pressed.value * (pressOpacity - 1)) }\n : { transform };\n });\n\n return (\n \n );\n }\n);\n\nAnimatedPressable.displayName = 'AnimatedPressable';\n" } ] } diff --git a/apps/docs/public/r/attachment.json b/apps/docs/public/r/attachment.json index 61d6f250a..ab3030765 100644 --- a/apps/docs/public/r/attachment.json +++ b/apps/docs/public/r/attachment.json @@ -6,6 +6,7 @@ "animated-pressable", "cn", "item", + "refs", "shimmer", "text" ], @@ -18,7 +19,7 @@ { "path": "ui/attachment.tsx", "type": "registry:ui", - "content": "/**\n * Attachment — a file, with its upload life.\n *\n * A file in a chat composer or an upload list is the same row shape `Item`\n * already draws — media, a name, a size, a remove button — so this is that row,\n * not a second one. What it adds is the part `Item` has no opinion on: the\n * file's *state*. A file is picked, then uploading, then processing, then done\n * (or failed), and each of those has to look different or the user cannot tell\n * a stuck upload from a finished one.\n *\n * ```tsx\n * \n * \n * \n * report.pdf\n * PDF · 2.4 MB\n * \n * \n * \n * \n * \n * ```\n *\n * While uploading or processing the title shimmers — the same sweep the rest of\n * the library uses for \"in progress\" — and an optional bar tracks `progress`.\n * A failed file tints its name and description in the destructive colour, which\n * carries the state without relying on an icon nobody reads.\n */\nimport { createContext, forwardRef, useContext, type ReactNode } from 'react';\nimport { View, type ViewProps } from 'react-native';\nimport { tv } from 'tailwind-variants';\nimport { useCSSVariable } from 'uniwind';\nimport { AnimatedPressable, type AnimatedPressableProps } from '@/components/ui/animated-pressable';\nimport { Text, type TextProps, textChildren } from '@/components/ui/text';\nimport { cn } from '@/lib/cn';\nimport { Item } from '@/components/ui/item';\nimport { Shimmer } from '@/components/ui/shimmer';\n\nexport type AttachmentState = 'idle' | 'uploading' | 'processing' | 'error' | 'done';\ntype AttachmentSize = 'default' | 'sm' | 'xs';\n\ninterface AttachmentContextValue {\n state: AttachmentState;\n size: AttachmentSize;\n}\n\nconst AttachmentContext = createContext({\n state: 'done',\n size: 'default',\n});\n\n/** True while the file is still moving — the two states that shimmer. */\nfunction isBusy(state: AttachmentState): boolean {\n return state === 'uploading' || state === 'processing';\n}\n\nconst rootVariants = tv({\n base: '',\n variants: {\n state: {\n idle: '',\n uploading: '',\n processing: '',\n // The border carries the failure, so it reads at a glance in a list.\n error: 'border border-destructive',\n done: '',\n },\n },\n});\n\nexport interface AttachmentProps\n extends Omit {\n className?: string;\n /**\n * Where the file is in its life. `uploading`/`processing` shimmer the name;\n * `error` tints the row destructive. Default `done`.\n */\n state?: AttachmentState;\n /** Row density, passed through to the underlying Item. */\n size?: AttachmentSize;\n /** Stack the row into a card — for a horizontal group of thumbnails. */\n orientation?: 'horizontal' | 'vertical';\n /** Upload progress 0–1. Draws a thin bar along the bottom while busy. */\n progress?: number;\n disabled?: boolean;\n children?: ReactNode;\n}\n\nconst AttachmentRoot = forwardRef(\n (\n { className, state = 'done', size = 'default', orientation, progress, children, ...props },\n ref\n ) => {\n const track = useCSSVariable('--color-primary');\n\n return (\n \n \n {children}\n\n {/* A determinate bar while busy, if progress is known. Absolute so it\n rides the row's bottom edge without shifting the layout. */}\n {isBusy(state) && progress !== undefined ? (\n \n \n \n ) : null}\n \n \n );\n }\n);\nAttachmentRoot.displayName = 'Attachment';\n\nexport interface AttachmentGroupProps extends ViewProps {\n className?: string;\n orientation?: 'horizontal' | 'vertical';\n children?: ReactNode;\n}\n\n/** A row (or column) of attachments — the same stack `Item.Group` provides. */\nconst AttachmentGroup = forwardRef(\n ({ className, orientation = 'vertical', children, ...props }, ref) => (\n \n {children}\n \n )\n);\nAttachmentGroup.displayName = 'Attachment.Group';\n\nexport interface AttachmentMediaProps extends ViewProps {\n className?: string;\n /** `icon` frames it in a tile; `image` clips it for a thumbnail. */\n variant?: 'default' | 'icon' | 'image';\n children?: ReactNode;\n}\n\n/** Leading slot — a file-type icon tile, or an image thumbnail. */\nconst AttachmentMedia = forwardRef(\n ({ className, variant = 'icon', children, ...props }, ref) => (\n \n {children}\n \n )\n);\nAttachmentMedia.displayName = 'Attachment.Media';\n\nexport interface AttachmentContentProps extends ViewProps {\n className?: string;\n children?: ReactNode;\n}\n\nconst AttachmentContent = forwardRef(\n ({ className, children, ...props }, ref) => (\n \n {children}\n \n )\n);\nAttachmentContent.displayName = 'Attachment.Content';\n\nexport interface AttachmentTitleProps extends TextProps {\n className?: string;\n children?: ReactNode;\n}\n\n/** The file name. Shimmers while the file is uploading or processing. */\nconst AttachmentTitle = forwardRef, AttachmentTitleProps>(\n ({ className, children, ...props }, ref) => {\n const { state } = useContext(AttachmentContext);\n\n if (isBusy(state)) {\n // Shimmer owns the text so it can mask the sweep to the glyphs.\n return {children};\n }\n\n return (\n \n {children}\n \n );\n }\n);\nAttachmentTitle.displayName = 'Attachment.Title';\n\nexport interface AttachmentDescriptionProps extends TextProps {\n className?: string;\n children?: ReactNode;\n}\n\n/** File type, size or status. Tints destructive on `error`. */\nconst AttachmentDescription = forwardRef<\n React.ElementRef,\n AttachmentDescriptionProps\n>(({ className, children, ...props }, ref) => {\n const { state } = useContext(AttachmentContext);\n\n return (\n \n {children}\n \n );\n});\nAttachmentDescription.displayName = 'Attachment.Description';\n\nexport interface AttachmentActionsProps extends ViewProps {\n className?: string;\n children?: ReactNode;\n}\n\nconst AttachmentActions = forwardRef(\n ({ className, children, ...props }, ref) => (\n \n {children}\n \n )\n);\nAttachmentActions.displayName = 'Attachment.Actions';\n\nexport interface AttachmentActionProps\n extends Omit {\n className?: string;\n accessibilityLabel: string;\n children?: ReactNode;\n}\n\n/**\n * A single icon button in the actions slot — remove, retry, download. An icon\n * on its own says nothing to a screen reader, so the label is required.\n */\nconst AttachmentAction = forwardRef(\n ({ className, accessibilityLabel, children, ...props }, ref) => (\n \n {textChildren(children)}\n \n )\n);\nAttachmentAction.displayName = 'Attachment.Action';\n\nexport const Attachment = Object.assign(AttachmentRoot, {\n Group: AttachmentGroup,\n Media: AttachmentMedia,\n Content: AttachmentContent,\n Title: AttachmentTitle,\n Description: AttachmentDescription,\n Actions: AttachmentActions,\n Action: AttachmentAction,\n});\n" + "content": "/**\n * Attachment — a file, with its upload life.\n *\n * A file in a chat composer or an upload list is the same row shape `Item`\n * already draws — media, a name, a size, a remove button — so this is that row,\n * not a second one. What it adds is the part `Item` has no opinion on: the\n * file's *state*. A file is picked, then uploading, then processing, then done\n * (or failed), and each of those has to look different or the user cannot tell\n * a stuck upload from a finished one.\n *\n * ```tsx\n * \n * \n * \n * report.pdf\n * PDF · 2.4 MB\n * \n * \n * \n * \n * \n * ```\n *\n * While uploading or processing the title shimmers — the same sweep the rest of\n * the library uses for \"in progress\" — and an optional bar tracks `progress`.\n * A failed file tints its name and description in the destructive colour, which\n * carries the state without relying on an icon nobody reads.\n */\nimport { createContext, forwardRef, useContext, type ReactNode } from 'react';\nimport { View, type ViewProps } from 'react-native';\nimport { tv } from 'tailwind-variants';\nimport { useCSSVariable } from 'uniwind';\nimport { AnimatedPressable, type AnimatedPressableProps } from '@/components/ui/animated-pressable';\nimport { Text, type TextProps, textChildren } from '@/components/ui/text';\nimport { cn } from '@/lib/cn';\nimport { Item } from '@/components/ui/item';\nimport { Shimmer } from '@/components/ui/shimmer';\nimport type { TextRef, ViewRef } from '@/components/ui/refs';\n\nexport type AttachmentState = 'idle' | 'uploading' | 'processing' | 'error' | 'done';\ntype AttachmentSize = 'default' | 'sm' | 'xs';\n\ninterface AttachmentContextValue {\n state: AttachmentState;\n size: AttachmentSize;\n}\n\nconst AttachmentContext = createContext({\n state: 'done',\n size: 'default',\n});\n\n/** True while the file is still moving — the two states that shimmer. */\nfunction isBusy(state: AttachmentState): boolean {\n return state === 'uploading' || state === 'processing';\n}\n\nconst rootVariants = tv({\n base: '',\n variants: {\n state: {\n idle: '',\n uploading: '',\n processing: '',\n // The border carries the failure, so it reads at a glance in a list.\n error: 'border border-destructive',\n done: '',\n },\n },\n});\n\nexport interface AttachmentProps\n extends Omit {\n className?: string;\n /**\n * Where the file is in its life. `uploading`/`processing` shimmer the name;\n * `error` tints the row destructive. Default `done`.\n */\n state?: AttachmentState;\n /** Row density, passed through to the underlying Item. */\n size?: AttachmentSize;\n /** Stack the row into a card — for a horizontal group of thumbnails. */\n orientation?: 'horizontal' | 'vertical';\n /** Upload progress 0–1. Draws a thin bar along the bottom while busy. */\n progress?: number;\n disabled?: boolean;\n children?: ReactNode;\n}\n\nconst AttachmentRoot = forwardRef(\n (\n { className, state = 'done', size = 'default', orientation, progress, children, ...props },\n ref\n ) => {\n const track = useCSSVariable('--color-primary');\n\n return (\n \n \n {children}\n\n {/* A determinate bar while busy, if progress is known. Absolute so it\n rides the row's bottom edge without shifting the layout. */}\n {isBusy(state) && progress !== undefined ? (\n \n \n \n ) : null}\n \n \n );\n }\n);\nAttachmentRoot.displayName = 'Attachment';\n\nexport interface AttachmentGroupProps extends ViewProps {\n className?: string;\n orientation?: 'horizontal' | 'vertical';\n children?: ReactNode;\n}\n\n/** A row (or column) of attachments — the same stack `Item.Group` provides. */\nconst AttachmentGroup = forwardRef(\n ({ className, orientation = 'vertical', children, ...props }, ref) => (\n \n {children}\n \n )\n);\nAttachmentGroup.displayName = 'Attachment.Group';\n\nexport interface AttachmentMediaProps extends ViewProps {\n className?: string;\n /** `icon` frames it in a tile; `image` clips it for a thumbnail. */\n variant?: 'default' | 'icon' | 'image';\n children?: ReactNode;\n}\n\n/** Leading slot — a file-type icon tile, or an image thumbnail. */\nconst AttachmentMedia = forwardRef(\n ({ className, variant = 'icon', children, ...props }, ref) => (\n \n {children}\n \n )\n);\nAttachmentMedia.displayName = 'Attachment.Media';\n\nexport interface AttachmentContentProps extends ViewProps {\n className?: string;\n children?: ReactNode;\n}\n\nconst AttachmentContent = forwardRef(\n ({ className, children, ...props }, ref) => (\n \n {children}\n \n )\n);\nAttachmentContent.displayName = 'Attachment.Content';\n\nexport interface AttachmentTitleProps extends TextProps {\n className?: string;\n children?: ReactNode;\n}\n\n/** The file name. Shimmers while the file is uploading or processing. */\nconst AttachmentTitle = forwardRef(\n ({ className, children, ...props }, ref) => {\n const { state } = useContext(AttachmentContext);\n\n if (isBusy(state)) {\n // Shimmer owns the text so it can mask the sweep to the glyphs.\n return {children};\n }\n\n return (\n \n {children}\n \n );\n }\n);\nAttachmentTitle.displayName = 'Attachment.Title';\n\nexport interface AttachmentDescriptionProps extends TextProps {\n className?: string;\n children?: ReactNode;\n}\n\n/** File type, size or status. Tints destructive on `error`. */\nconst AttachmentDescription = forwardRef<\n TextRef,\n AttachmentDescriptionProps\n>(({ className, children, ...props }, ref) => {\n const { state } = useContext(AttachmentContext);\n\n return (\n \n {children}\n \n );\n});\nAttachmentDescription.displayName = 'Attachment.Description';\n\nexport interface AttachmentActionsProps extends ViewProps {\n className?: string;\n children?: ReactNode;\n}\n\nconst AttachmentActions = forwardRef(\n ({ className, children, ...props }, ref) => (\n \n {children}\n \n )\n);\nAttachmentActions.displayName = 'Attachment.Actions';\n\nexport interface AttachmentActionProps\n extends Omit {\n className?: string;\n accessibilityLabel: string;\n children?: ReactNode;\n}\n\n/**\n * A single icon button in the actions slot — remove, retry, download. An icon\n * on its own says nothing to a screen reader, so the label is required.\n */\nconst AttachmentAction = forwardRef(\n ({ className, accessibilityLabel, children, ...props }, ref) => (\n \n {textChildren(children)}\n \n )\n);\nAttachmentAction.displayName = 'Attachment.Action';\n\nexport const Attachment = Object.assign(AttachmentRoot, {\n Group: AttachmentGroup,\n Media: AttachmentMedia,\n Content: AttachmentContent,\n Title: AttachmentTitle,\n Description: AttachmentDescription,\n Actions: AttachmentActions,\n Action: AttachmentAction,\n});\n" } ] } diff --git a/apps/docs/public/r/avatar.json b/apps/docs/public/r/avatar.json index f3fcda7ce..f62895152 100644 --- a/apps/docs/public/r/avatar.json +++ b/apps/docs/public/r/avatar.json @@ -4,6 +4,7 @@ "description": "User image with an initials fallback, a badge overlay, and a stack for a group of them.", "registryDependencies": [ "cn", + "refs", "text" ], "dependencies": [ @@ -24,7 +25,7 @@ { "path": "ui/avatar.tsx", "type": "registry:ui", - "content": "import {\n Children,\n cloneElement,\n forwardRef,\n isValidElement,\n useState,\n type ReactElement,\n type ReactNode,\n} from 'react';\nimport {\n Image,\n View,\n type ImageProps,\n type ImageSourcePropType,\n type ViewProps,\n} from 'react-native';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport { Text, textChildren } from '@/components/ui/text';\nimport { cn } from '@/lib/cn';\nimport {\n avatarGroupCount,\n avatarGroupOverlap,\n type AvatarSizeName,\n} from '@/components/ui/avatar-group';\nimport { avatarSourceIdentity } from '@/components/ui/avatar-source';\n\nconst avatarVariants = tv({\n slots: {\n /*\n * An opaque fill, not `bg-muted`. Muted is a translucent tint in every\n * theme, which is right for a surface and wrong for an object: in a group\n * each face slides over the next, and a translucent face lets the one\n * underneath show through it.\n */\n root: 'items-center justify-center overflow-hidden rounded-full border border-border bg-surface-secondary',\n /*\n * Rounded itself, not only clipped by the circle around it.\n *\n * The root carries a hairline border, and an absolutely positioned child\n * is laid out inside it — so the parent clips to the *outer* radius while\n * the image keeps square corners a border-width further in. What shows is\n * a sliver of the photo's corner at each of the four diagonals, which\n * reads as the picture being bent out of the circle.\n */\n image: 'absolute inset-0 h-full w-full rounded-full',\n fallback: 'font-medium text-muted-foreground',\n },\n variants: {\n size: {\n sm: { root: 'h-8 w-8', fallback: 'text-xs' },\n md: { root: 'h-10 w-10', fallback: 'text-sm' },\n lg: { root: 'h-14 w-14', fallback: 'text-lg' },\n xl: { root: 'h-20 w-20', fallback: 'text-2xl' },\n },\n },\n defaultVariants: {\n size: 'md',\n },\n});\n\nexport interface AvatarProps extends ViewProps, VariantProps {\n className?: string;\n /** Image source; falls back to initials when missing or on load error. */\n source?: ImageSourcePropType;\n /** Fallback text, e.g. initials (\"KA\"). */\n fallback?: string;\n imageProps?: Omit;\n}\n\nconst AvatarRoot = forwardRef(\n ({ className, size, source, fallback, imageProps, children, ...props }, ref) => {\n const [failedSource, setFailedSource] = useState();\n const { root, image, fallback: fallbackSlot } = avatarVariants({ size });\n const sourceIdentity = avatarSourceIdentity(source);\n const showImage = !!source && failedSource !== sourceIdentity;\n\n const face = showImage ? (\n {\n setFailedSource(sourceIdentity);\n imageProps?.onError?.(event);\n }}\n />\n ) : (\n {fallback ?? '?'}\n );\n\n // The plain avatar is a single clipped node.\n if (!children) {\n return (\n \n {face}\n \n );\n }\n\n // With an overlay it needs two: the face keeps overflow-hidden to round\n // the image, which would otherwise cut a corner badge in half, so the\n // overlay hangs off an unclipped wrapper around it.\n return (\n \n \n {face}\n \n {textChildren(children)}\n \n );\n }\n);\nAvatarRoot.displayName = 'Avatar';\n\nexport interface AvatarBadgeProps extends ViewProps {\n className?: string;\n children?: ReactNode;\n}\n\n/**\n * Overlay pinned to the avatar's top-right — an unread count, a presence dot.\n *\n * The ring is `border-background` so the badge separates from the image\n * whatever surface the avatar sits on.\n */\nconst AvatarBadge = forwardRef(\n ({ className, children, ...props }, ref) => (\n \n {textChildren(children)}\n \n )\n);\nAvatarBadge.displayName = 'Avatar.Badge';\n\nexport interface AvatarGroupProps extends ViewProps {\n className?: string;\n /** Size for every avatar in the stack. A child's own `size` still wins. */\n size?: AvatarSizeName;\n /**\n * How many faces to show. The rest are counted into a trailing `+N`.\n *\n * It caps the faces, not the row: `max={3}` with five people shows three\n * avatars and a `+2`.\n */\n max?: number;\n /**\n * How many people there are, when the children are only the first few of\n * them. The count is measured against this instead of against the number of\n * children, so a stack of three out of forty reads `+37`.\n */\n total?: number;\n /** Points each avatar slides under the one before it. Defaults to a third of the size. */\n overlap?: number;\n}\n\n/**\n * A row of avatars, each overlapping the one after it, with the people who did\n * not fit counted at the end.\n *\n * The faces stay in logical child order for assistive traversal. Explicit\n * z-indices, rather than a reversed render/layout order, put the first face on\n * top while the overflow count remains the final list item.\n *\n * Each face gets a ring in the page background so it separates from the one\n * underneath whatever surface the stack sits on.\n *\n * ```tsx\n * \n * {members.map((member) => (\n * \n * ))}\n * \n * ```\n */\nconst AvatarGroup = forwardRef(\n ({ className, size = 'md', max, total, overlap, children, ...props }, ref) => {\n const faces = Children.toArray(children).filter(isValidElement) as ReactElement[];\n const { visible, overflow } = avatarGroupCount(faces.length, max, total);\n const slide = avatarGroupOverlap(size, overlap);\n\n // Logical reading order: visible people first, then the overflow summary.\n // Painting order is independent and applied to each wrapper below.\n const stack: { key: string; node: ReactNode }[] = [];\n\n for (let index = 0; index < visible; index += 1) {\n const face = faces[index]!;\n stack.push({\n key: String(face.key ?? index),\n node: cloneElement(face, {\n size: face.props.size ?? size,\n className: cn('border-2 border-background', face.props.className),\n }),\n });\n }\n\n if (overflow > 0) {\n stack.push({\n key: 'overflow',\n node: (\n \n ),\n });\n }\n\n return (\n \n {stack.map((entry, index) => (\n \n {entry.node}\n \n ))}\n \n );\n }\n);\nAvatarGroup.displayName = 'Avatar.Group';\n\nexport const Avatar = Object.assign(AvatarRoot, {\n Badge: AvatarBadge,\n Group: AvatarGroup,\n});\n\nexport {\n AVATAR_GROUP_OVERLAP_RATIO,\n AVATAR_SIZE_POINTS,\n avatarGroupCount,\n avatarGroupOverlap,\n type AvatarGroupCount,\n type AvatarSizeName,\n} from '@/components/ui/avatar-group';\n" + "content": "import {\n Children,\n cloneElement,\n forwardRef,\n isValidElement,\n useState,\n type ReactElement,\n type ReactNode,\n} from 'react';\nimport {\n Image,\n View,\n type ImageProps,\n type ImageSourcePropType,\n type ViewProps,\n} from 'react-native';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport { Text, textChildren } from '@/components/ui/text';\nimport { cn } from '@/lib/cn';\nimport {\n avatarGroupCount,\n avatarGroupOverlap,\n type AvatarSizeName,\n} from '@/components/ui/avatar-group';\nimport { avatarSourceIdentity } from '@/components/ui/avatar-source';\nimport type { ViewRef } from '@/components/ui/refs';\n\nconst avatarVariants = tv({\n slots: {\n /*\n * An opaque fill, not `bg-muted`. Muted is a translucent tint in every\n * theme, which is right for a surface and wrong for an object: in a group\n * each face slides over the next, and a translucent face lets the one\n * underneath show through it.\n */\n root: 'items-center justify-center overflow-hidden rounded-full border border-border bg-surface-secondary',\n /*\n * Rounded itself, not only clipped by the circle around it.\n *\n * The root carries a hairline border, and an absolutely positioned child\n * is laid out inside it — so the parent clips to the *outer* radius while\n * the image keeps square corners a border-width further in. What shows is\n * a sliver of the photo's corner at each of the four diagonals, which\n * reads as the picture being bent out of the circle.\n */\n image: 'absolute inset-0 h-full w-full rounded-full',\n fallback: 'font-medium text-muted-foreground',\n },\n variants: {\n size: {\n sm: { root: 'h-8 w-8', fallback: 'text-xs' },\n md: { root: 'h-10 w-10', fallback: 'text-sm' },\n lg: { root: 'h-14 w-14', fallback: 'text-lg' },\n xl: { root: 'h-20 w-20', fallback: 'text-2xl' },\n },\n },\n defaultVariants: {\n size: 'md',\n },\n});\n\nexport interface AvatarProps extends ViewProps, VariantProps {\n className?: string;\n /** Image source; falls back to initials when missing or on load error. */\n source?: ImageSourcePropType;\n /** Fallback text, e.g. initials (\"KA\"). */\n fallback?: string;\n imageProps?: Omit;\n}\n\nconst AvatarRoot = forwardRef(\n ({ className, size, source, fallback, imageProps, children, ...props }, ref) => {\n const [failedSource, setFailedSource] = useState();\n const { root, image, fallback: fallbackSlot } = avatarVariants({ size });\n const sourceIdentity = avatarSourceIdentity(source);\n const showImage = !!source && failedSource !== sourceIdentity;\n\n const face = showImage ? (\n {\n setFailedSource(sourceIdentity);\n imageProps?.onError?.(event);\n }}\n />\n ) : (\n {fallback ?? '?'}\n );\n\n // The plain avatar is a single clipped node.\n if (!children) {\n return (\n \n {face}\n \n );\n }\n\n // With an overlay it needs two: the face keeps overflow-hidden to round\n // the image, which would otherwise cut a corner badge in half, so the\n // overlay hangs off an unclipped wrapper around it.\n return (\n \n \n {face}\n \n {textChildren(children)}\n \n );\n }\n);\nAvatarRoot.displayName = 'Avatar';\n\nexport interface AvatarBadgeProps extends ViewProps {\n className?: string;\n children?: ReactNode;\n}\n\n/**\n * Overlay pinned to the avatar's top-right — an unread count, a presence dot.\n *\n * The ring is `border-background` so the badge separates from the image\n * whatever surface the avatar sits on.\n */\nconst AvatarBadge = forwardRef(\n ({ className, children, ...props }, ref) => (\n \n {textChildren(children)}\n \n )\n);\nAvatarBadge.displayName = 'Avatar.Badge';\n\nexport interface AvatarGroupProps extends ViewProps {\n className?: string;\n /** Size for every avatar in the stack. A child's own `size` still wins. */\n size?: AvatarSizeName;\n /**\n * How many faces to show. The rest are counted into a trailing `+N`.\n *\n * It caps the faces, not the row: `max={3}` with five people shows three\n * avatars and a `+2`.\n */\n max?: number;\n /**\n * How many people there are, when the children are only the first few of\n * them. The count is measured against this instead of against the number of\n * children, so a stack of three out of forty reads `+37`.\n */\n total?: number;\n /** Points each avatar slides under the one before it. Defaults to a third of the size. */\n overlap?: number;\n}\n\n/**\n * A row of avatars, each overlapping the one after it, with the people who did\n * not fit counted at the end.\n *\n * The faces stay in logical child order for assistive traversal. Explicit\n * z-indices, rather than a reversed render/layout order, put the first face on\n * top while the overflow count remains the final list item.\n *\n * Each face gets a ring in the page background so it separates from the one\n * underneath whatever surface the stack sits on.\n *\n * ```tsx\n * \n * {members.map((member) => (\n * \n * ))}\n * \n * ```\n */\nconst AvatarGroup = forwardRef(\n ({ className, size = 'md', max, total, overlap, children, ...props }, ref) => {\n const faces = Children.toArray(children).filter(isValidElement) as ReactElement[];\n const { visible, overflow } = avatarGroupCount(faces.length, max, total);\n const slide = avatarGroupOverlap(size, overlap);\n\n // Logical reading order: visible people first, then the overflow summary.\n // Painting order is independent and applied to each wrapper below.\n const stack: { key: string; node: ReactNode }[] = [];\n\n for (let index = 0; index < visible; index += 1) {\n const face = faces[index]!;\n stack.push({\n key: String(face.key ?? index),\n node: cloneElement(face, {\n size: face.props.size ?? size,\n className: cn('border-2 border-background', face.props.className),\n }),\n });\n }\n\n if (overflow > 0) {\n stack.push({\n key: 'overflow',\n node: (\n \n ),\n });\n }\n\n return (\n \n {stack.map((entry, index) => (\n \n {entry.node}\n \n ))}\n \n );\n }\n);\nAvatarGroup.displayName = 'Avatar.Group';\n\nexport const Avatar = Object.assign(AvatarRoot, {\n Badge: AvatarBadge,\n Group: AvatarGroup,\n});\n\nexport {\n AVATAR_GROUP_OVERLAP_RATIO,\n AVATAR_SIZE_POINTS,\n avatarGroupCount,\n avatarGroupOverlap,\n type AvatarGroupCount,\n type AvatarSizeName,\n} from '@/components/ui/avatar-group';\n" } ] } diff --git a/apps/docs/public/r/badge.json b/apps/docs/public/r/badge.json index e91cdc129..3e3666514 100644 --- a/apps/docs/public/r/badge.json +++ b/apps/docs/public/r/badge.json @@ -3,6 +3,7 @@ "type": "registry:ui", "description": "Compact status label, dot, or notification count.", "registryDependencies": [ + "refs", "text" ], "dependencies": [ @@ -13,7 +14,7 @@ { "path": "ui/badge.tsx", "type": "registry:ui", - "content": "import { forwardRef, type ReactNode } from 'react';\nimport { View, type ViewProps } from 'react-native';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport { Text, textChildren } from '@/components/ui/text';\n\nconst badgeVariants = tv({\n slots: {\n root: 'flex-row items-center justify-center gap-1 self-start rounded-sm border border-transparent',\n label: 'font-medium',\n },\n variants: {\n /*\n * Listed before `shape` on purpose. Later variants win a conflict, and a\n * dot or a count has to keep its own padding whatever size it is given;\n * their dimensions per size are the compound variants below.\n */\n size: {\n xs: { root: 'px-1 py-px', label: 'text-[10px]' },\n sm: { root: 'px-1.5 py-px', label: 'text-[11px]' },\n md: { root: 'px-2 py-0.5', label: 'text-xs' },\n lg: { root: 'px-2.5 py-1', label: 'text-sm' },\n xl: { root: 'px-3 py-1.5', label: 'text-base' },\n },\n shape: {\n default: {},\n /** A bare status dot, no label. */\n dot: { root: 'h-2 w-2 rounded-full px-0 py-0' },\n /** Circular, for a count sitting over an avatar or an icon. */\n count: { root: 'h-5 min-w-5 rounded-full px-1.5 py-0' },\n },\n variant: {\n default: { root: 'bg-primary', label: 'text-primary-foreground' },\n secondary: { root: 'bg-secondary', label: 'text-secondary-foreground' },\n outline: { root: 'border-border bg-transparent', label: 'text-foreground' },\n destructive: {\n root: 'bg-destructive',\n label: 'text-destructive-solid-foreground',\n },\n success: { root: 'bg-success-subtle', label: 'text-success-foreground' },\n warning: { root: 'bg-warning-subtle', label: 'text-warning-foreground' },\n info: { root: 'bg-info-subtle', label: 'text-info-foreground' },\n },\n },\n compoundVariants: [\n { shape: 'dot', size: 'xs', class: { root: 'h-1 w-1' } },\n { shape: 'dot', size: 'sm', class: { root: 'h-1.5 w-1.5' } },\n { shape: 'dot', size: 'lg', class: { root: 'h-2.5 w-2.5' } },\n { shape: 'dot', size: 'xl', class: { root: 'h-3 w-3' } },\n { shape: 'count', size: 'xs', class: { root: 'h-3.5 min-w-3.5 px-1' } },\n { shape: 'count', size: 'sm', class: { root: 'h-4 min-w-4 px-1' } },\n { shape: 'count', size: 'lg', class: { root: 'h-6 min-w-6 px-2' } },\n { shape: 'count', size: 'xl', class: { root: 'h-7 min-w-7 px-2' } },\n ],\n defaultVariants: {\n variant: 'default',\n shape: 'default',\n size: 'md',\n },\n});\n\n/** How big a badge is. */\nexport type BadgeSize = NonNullable['size']>;\n\n/** Counts past this render as \"99+\" so the badge keeps its shape. */\nconst MAX_COUNT = 99;\n\nexport interface BadgeProps\n extends ViewProps,\n VariantProps {\n children?: ReactNode;\n className?: string;\n labelClassName?: string;\n /**\n * Renders a number instead of children, clamped to \"99+\". Implies the\n * `count` shape unless you set one.\n */\n count?: number;\n}\n\nexport const Badge = forwardRef(\n ({ children, className, labelClassName, variant, shape, size, count, ...props }, ref) => {\n const resolvedShape = shape ?? (count !== undefined ? 'count' : 'default');\n const { root, label } = badgeVariants({ variant, shape: resolvedShape, size });\n\n const content =\n count !== undefined\n ? `${Math.min(count, MAX_COUNT)}${count > MAX_COUNT ? '+' : ''}`\n : children;\n\n return (\n \n {resolvedShape === 'dot'\n ? null\n : textChildren(content, (text) => (\n {text}\n ))}\n \n );\n }\n);\n\nBadge.displayName = 'Badge';\n" + "content": "import { forwardRef, type ReactNode } from 'react';\nimport { View, type ViewProps } from 'react-native';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport { Text, textChildren } from '@/components/ui/text';\nimport type { ViewRef } from '@/components/ui/refs';\n\nconst badgeVariants = tv({\n slots: {\n root: 'flex-row items-center justify-center gap-1 self-start rounded-sm border border-transparent',\n label: 'font-medium',\n },\n variants: {\n /*\n * Listed before `shape` on purpose. Later variants win a conflict, and a\n * dot or a count has to keep its own padding whatever size it is given;\n * their dimensions per size are the compound variants below.\n */\n size: {\n xs: { root: 'px-1 py-px', label: 'text-[10px]' },\n sm: { root: 'px-1.5 py-px', label: 'text-[11px]' },\n md: { root: 'px-2 py-0.5', label: 'text-xs' },\n lg: { root: 'px-2.5 py-1', label: 'text-sm' },\n xl: { root: 'px-3 py-1.5', label: 'text-base' },\n },\n shape: {\n default: {},\n /** A bare status dot, no label. */\n dot: { root: 'h-2 w-2 rounded-full px-0 py-0' },\n /** Circular, for a count sitting over an avatar or an icon. */\n count: { root: 'h-5 min-w-5 rounded-full px-1.5 py-0' },\n },\n variant: {\n default: { root: 'bg-primary', label: 'text-primary-foreground' },\n secondary: { root: 'bg-secondary', label: 'text-secondary-foreground' },\n outline: { root: 'border-border bg-transparent', label: 'text-foreground' },\n destructive: {\n root: 'bg-destructive',\n label: 'text-destructive-solid-foreground',\n },\n success: { root: 'bg-success-subtle', label: 'text-success-foreground' },\n warning: { root: 'bg-warning-subtle', label: 'text-warning-foreground' },\n info: { root: 'bg-info-subtle', label: 'text-info-foreground' },\n },\n },\n compoundVariants: [\n { shape: 'dot', size: 'xs', class: { root: 'h-1 w-1' } },\n { shape: 'dot', size: 'sm', class: { root: 'h-1.5 w-1.5' } },\n { shape: 'dot', size: 'lg', class: { root: 'h-2.5 w-2.5' } },\n { shape: 'dot', size: 'xl', class: { root: 'h-3 w-3' } },\n { shape: 'count', size: 'xs', class: { root: 'h-3.5 min-w-3.5 px-1' } },\n { shape: 'count', size: 'sm', class: { root: 'h-4 min-w-4 px-1' } },\n { shape: 'count', size: 'lg', class: { root: 'h-6 min-w-6 px-2' } },\n { shape: 'count', size: 'xl', class: { root: 'h-7 min-w-7 px-2' } },\n ],\n defaultVariants: {\n variant: 'default',\n shape: 'default',\n size: 'md',\n },\n});\n\n/** How big a badge is. */\nexport type BadgeSize = NonNullable['size']>;\n\n/** Counts past this render as \"99+\" so the badge keeps its shape. */\nconst MAX_COUNT = 99;\n\nexport interface BadgeProps\n extends ViewProps,\n VariantProps {\n children?: ReactNode;\n className?: string;\n labelClassName?: string;\n /**\n * Renders a number instead of children, clamped to \"99+\". Implies the\n * `count` shape unless you set one.\n */\n count?: number;\n}\n\nexport const Badge = forwardRef(\n ({ children, className, labelClassName, variant, shape, size, count, ...props }, ref) => {\n const resolvedShape = shape ?? (count !== undefined ? 'count' : 'default');\n const { root, label } = badgeVariants({ variant, shape: resolvedShape, size });\n\n const content =\n count !== undefined\n ? `${Math.min(count, MAX_COUNT)}${count > MAX_COUNT ? '+' : ''}`\n : children;\n\n return (\n \n {resolvedShape === 'dot'\n ? null\n : textChildren(content, (text) => (\n {text}\n ))}\n \n );\n }\n);\n\nBadge.displayName = 'Badge';\n" } ] } diff --git a/apps/docs/public/r/breadcrumb.json b/apps/docs/public/r/breadcrumb.json index 11ee63796..71ed6ed4e 100644 --- a/apps/docs/public/r/breadcrumb.json +++ b/apps/docs/public/r/breadcrumb.json @@ -6,6 +6,7 @@ "animated-pressable", "cn", "icons", + "refs", "text" ], "dependencies": [ @@ -17,7 +18,7 @@ { "path": "ui/breadcrumb.tsx", "type": "registry:ui", - "content": "/**\n * Breadcrumb — the trail of where you are in a hierarchy.\n *\n * A row of links back up the tree, ending in the page you are on. The last\n * crumb is not a link: you cannot navigate to where you already are, so it is\n * a plain, current-marked label rather than a dead tappable target.\n *\n * The separators are the component's job, not yours. `Breadcrumb.List` drops a\n * chevron between each crumb it holds, so a trail is just its items — there is\n * no separator to forget, mis-order, or leave dangling at the end. Change the\n * glyph once with `separator` on the root and every gap follows.\n *\n * ```tsx\n * \n * \n * \n * Home\n * \n * \n * Projects\n * \n * \n * PanelUI\n * \n * \n * \n * ```\n *\n * A deep trail on a narrow phone does not wrap into a paragraph: give the list\n * a `maxItems` and it keeps the first and last crumbs, folding the middle into\n * a single ellipsis. Hand the ellipsis an `onEllipsisPress` and it becomes the\n * handle for a menu of the hidden steps.\n */\nimport {\n Children,\n createContext,\n forwardRef,\n isValidElement,\n useContext,\n type ReactNode,\n} from 'react';\nimport { View, type ViewProps } from 'react-native';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport { useCSSVariable } from 'uniwind';\nimport { ChevronRightIcon, EllipsisIcon } from '@/components/ui/icons';\nimport {\n AnimatedPressable,\n type AnimatedPressableProps,\n} from '@/components/ui/animated-pressable';\nimport { Text, type TextProps, textChildren } from '@/components/ui/text';\nimport { cn } from '@/lib/cn';\n\ntype BreadcrumbSize = 'sm' | 'default';\n\nconst crumbText = tv({\n base: '',\n variants: {\n size: {\n sm: 'text-xs',\n default: 'text-sm',\n },\n },\n defaultVariants: { size: 'default' },\n});\n\n/**\n * Sub-parts read the density and the shared separator glyph from here, so the\n * trail is configured once on the root rather than repeated per crumb.\n */\nconst BreadcrumbContext = createContext<{\n size: BreadcrumbSize;\n separator: ReactNode;\n}>({\n size: 'default',\n separator: null,\n});\n\nconst useBreadcrumb = () => useContext(BreadcrumbContext);\n\nexport interface BreadcrumbProps extends ViewProps {\n className?: string;\n /** Text density for every crumb. `sm` for a dense header bar. */\n size?: BreadcrumbSize;\n /**\n * The glyph `Breadcrumb.List` places between crumbs. Defaults to a chevron;\n * pass a `/`, a slash, or any node to change every gap at once.\n */\n separator?: ReactNode;\n children?: ReactNode;\n}\n\n/**\n * The trail's landmark. Labelled for assistive tech so the row is announced as\n * \"Breadcrumb\" rather than an anonymous run of links.\n */\nconst BreadcrumbRoot = forwardRef(\n ({ className, size = 'default', separator, children, ...props }, ref) => (\n \n \n {textChildren(children)}\n \n \n )\n);\nBreadcrumbRoot.displayName = 'Breadcrumb';\n\nexport interface BreadcrumbListProps extends ViewProps {\n className?: string;\n /**\n * Collapse the trail once it holds more than this many crumbs, so a deep\n * path never wraps into a block of text on a narrow screen. The first\n * `itemsBeforeCollapse` and last `itemsAfterCollapse` survive; the middle\n * folds into a single ellipsis.\n */\n maxItems?: number;\n /** How many leading crumbs to keep when collapsing. Default 1. */\n itemsBeforeCollapse?: number;\n /** How many trailing crumbs to keep when collapsing. Default 1. */\n itemsAfterCollapse?: number;\n /**\n * Makes the collapsed ellipsis pressable — the handle for a menu listing the\n * hidden steps. Without it the ellipsis is a static marker.\n */\n onEllipsisPress?: () => void;\n children?: ReactNode;\n}\n\n/** A private marker: the slot the collapsed middle crumbs fold into. */\nconst COLLAPSE = Symbol('breadcrumb-collapse');\n\n/**\n * The crumb row. It owns the separators — one between every pair of crumbs and\n * none at the ends — and the collapsing, so a caller only ever lists items.\n */\nconst BreadcrumbList = forwardRef(\n (\n {\n className,\n maxItems,\n itemsBeforeCollapse = 1,\n itemsAfterCollapse = 1,\n onEllipsisPress,\n children,\n ...props\n },\n ref\n ) => {\n const crumbs = Children.toArray(children).filter(isValidElement);\n\n // Fold the middle only when hiding something actually shortens the row:\n // keeping N-1 of N crumbs plus an ellipsis saves nothing.\n let sequence: Array = crumbs;\n if (\n maxItems !== undefined &&\n crumbs.length > maxItems &&\n itemsBeforeCollapse + itemsAfterCollapse < crumbs.length\n ) {\n const head = crumbs.slice(0, Math.max(0, itemsBeforeCollapse));\n const tail =\n itemsAfterCollapse > 0 ? crumbs.slice(crumbs.length - itemsAfterCollapse) : [];\n sequence = [...head, COLLAPSE, ...tail];\n }\n\n return (\n \n {sequence.map((node, i) => (\n \n {i > 0 ? : null}\n {node === COLLAPSE ? (\n \n ) : (\n node\n )}\n \n ))}\n \n );\n }\n);\nBreadcrumbList.displayName = 'Breadcrumb.List';\n\nexport interface BreadcrumbItemProps extends ViewProps {\n className?: string;\n children?: ReactNode;\n}\n\n/** One crumb: wraps a link or the current page. */\nconst BreadcrumbItem = forwardRef(\n ({ className, children, ...props }, ref) => (\n \n {textChildren(children)}\n \n )\n);\nBreadcrumbItem.displayName = 'Breadcrumb.Item';\n\nexport interface BreadcrumbLinkProps\n extends Omit {\n className?: string;\n /** Text style for the crumb's label. */\n textClassName?: string;\n children?: ReactNode;\n}\n\n/**\n * A navigable crumb — an ancestor you can jump back to. Muted until pressed,\n * so the current page (which is not a link) reads as the emphasised end of the\n * trail. A string child is wrapped in the crumb text style; anything else\n * renders as given.\n */\nconst BreadcrumbLink = forwardRef(\n ({ className, textClassName, children, ...props }, ref) => {\n const { size } = useBreadcrumb();\n\n return (\n \n {textChildren(children, (text) => (\n \n {text}\n \n ))}\n \n );\n }\n);\nBreadcrumbLink.displayName = 'Breadcrumb.Link';\n\nexport interface BreadcrumbPageProps extends TextProps {\n className?: string;\n}\n\n/**\n * The trailing crumb: where you are now. Not a link — `aria-current=\"page\"`\n * marks it as the destination, and it is painted in the full foreground so the\n * trail resolves to it.\n */\nconst BreadcrumbPage = forwardRef, BreadcrumbPageProps>(\n ({ className, ...props }, ref) => {\n const { size } = useBreadcrumb();\n\n return (\n \n );\n }\n);\nBreadcrumbPage.displayName = 'Breadcrumb.Page';\n\nexport interface BreadcrumbSeparatorProps extends ViewProps {\n className?: string;\n /** Override the glyph for this one gap. Falls back to the root's separator. */\n children?: ReactNode;\n}\n\n/**\n * The glyph between crumbs. `Breadcrumb.List` inserts it for you; it is public\n * only for the rare hand-assembled trail. Hidden from screen readers — the\n * order of the crumbs already conveys the hierarchy.\n */\nconst BreadcrumbSeparator = forwardRef(\n ({ className, children, ...props }, ref) => {\n const { separator } = useBreadcrumb();\n const rawTint = useCSSVariable('--color-muted-foreground');\n const tint = typeof rawTint === 'string' ? rawTint : undefined;\n const glyph = children ?? separator;\n\n return (\n \n {glyph ?? }\n \n );\n }\n);\nBreadcrumbSeparator.displayName = 'Breadcrumb.Separator';\n\nexport interface BreadcrumbEllipsisProps\n extends Omit {\n className?: string;\n}\n\n/**\n * Stands in for the crumbs a collapsed trail hides. Static by default; give it\n * an `onPress` (via the list's `onEllipsisPress`) and it becomes the trigger\n * for a menu of the hidden steps. Labelled \"Show more\" so the collapse is not\n * silent to assistive tech.\n */\nconst BreadcrumbEllipsis = forwardRef(\n ({ className, onPress, ...props }, ref) => {\n const rawTint = useCSSVariable('--color-muted-foreground');\n const tint = typeof rawTint === 'string' ? rawTint : undefined;\n const glyph = ;\n\n if (!onPress) {\n return (\n \n {glyph}\n \n );\n }\n\n return (\n \n {glyph}\n \n );\n }\n);\nBreadcrumbEllipsis.displayName = 'Breadcrumb.Ellipsis';\n\nexport const Breadcrumb = Object.assign(BreadcrumbRoot, {\n List: BreadcrumbList,\n Item: BreadcrumbItem,\n Link: BreadcrumbLink,\n Page: BreadcrumbPage,\n Separator: BreadcrumbSeparator,\n Ellipsis: BreadcrumbEllipsis,\n});\n" + "content": "/**\n * Breadcrumb — the trail of where you are in a hierarchy.\n *\n * A row of links back up the tree, ending in the page you are on. The last\n * crumb is not a link: you cannot navigate to where you already are, so it is\n * a plain, current-marked label rather than a dead tappable target.\n *\n * The separators are the component's job, not yours. `Breadcrumb.List` drops a\n * chevron between each crumb it holds, so a trail is just its items — there is\n * no separator to forget, mis-order, or leave dangling at the end. Change the\n * glyph once with `separator` on the root and every gap follows.\n *\n * ```tsx\n * \n * \n * \n * Home\n * \n * \n * Projects\n * \n * \n * PanelUI\n * \n * \n * \n * ```\n *\n * A deep trail on a narrow phone does not wrap into a paragraph: give the list\n * a `maxItems` and it keeps the first and last crumbs, folding the middle into\n * a single ellipsis. Hand the ellipsis an `onEllipsisPress` and it becomes the\n * handle for a menu of the hidden steps.\n */\nimport {\n Children,\n createContext,\n forwardRef,\n isValidElement,\n useContext,\n type ReactNode,\n} from 'react';\nimport { View, type ViewProps } from 'react-native';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport { useCSSVariable } from 'uniwind';\nimport { ChevronRightIcon, EllipsisIcon } from '@/components/ui/icons';\nimport {\n AnimatedPressable,\n type AnimatedPressableProps,\n} from '@/components/ui/animated-pressable';\nimport { Text, type TextProps, textChildren } from '@/components/ui/text';\nimport { cn } from '@/lib/cn';\nimport type { TextRef, ViewRef } from '@/components/ui/refs';\n\ntype BreadcrumbSize = 'sm' | 'default';\n\nconst crumbText = tv({\n base: '',\n variants: {\n size: {\n sm: 'text-xs',\n default: 'text-sm',\n },\n },\n defaultVariants: { size: 'default' },\n});\n\n/**\n * Sub-parts read the density and the shared separator glyph from here, so the\n * trail is configured once on the root rather than repeated per crumb.\n */\nconst BreadcrumbContext = createContext<{\n size: BreadcrumbSize;\n separator: ReactNode;\n}>({\n size: 'default',\n separator: null,\n});\n\nconst useBreadcrumb = () => useContext(BreadcrumbContext);\n\nexport interface BreadcrumbProps extends ViewProps {\n className?: string;\n /** Text density for every crumb. `sm` for a dense header bar. */\n size?: BreadcrumbSize;\n /**\n * The glyph `Breadcrumb.List` places between crumbs. Defaults to a chevron;\n * pass a `/`, a slash, or any node to change every gap at once.\n */\n separator?: ReactNode;\n children?: ReactNode;\n}\n\n/**\n * The trail's landmark. Labelled for assistive tech so the row is announced as\n * \"Breadcrumb\" rather than an anonymous run of links.\n */\nconst BreadcrumbRoot = forwardRef(\n ({ className, size = 'default', separator, children, ...props }, ref) => (\n \n \n {textChildren(children)}\n \n \n )\n);\nBreadcrumbRoot.displayName = 'Breadcrumb';\n\nexport interface BreadcrumbListProps extends ViewProps {\n className?: string;\n /**\n * Collapse the trail once it holds more than this many crumbs, so a deep\n * path never wraps into a block of text on a narrow screen. The first\n * `itemsBeforeCollapse` and last `itemsAfterCollapse` survive; the middle\n * folds into a single ellipsis.\n */\n maxItems?: number;\n /** How many leading crumbs to keep when collapsing. Default 1. */\n itemsBeforeCollapse?: number;\n /** How many trailing crumbs to keep when collapsing. Default 1. */\n itemsAfterCollapse?: number;\n /**\n * Makes the collapsed ellipsis pressable — the handle for a menu listing the\n * hidden steps. Without it the ellipsis is a static marker.\n */\n onEllipsisPress?: () => void;\n children?: ReactNode;\n}\n\n/** A private marker: the slot the collapsed middle crumbs fold into. */\nconst COLLAPSE = Symbol('breadcrumb-collapse');\n\n/**\n * The crumb row. It owns the separators — one between every pair of crumbs and\n * none at the ends — and the collapsing, so a caller only ever lists items.\n */\nconst BreadcrumbList = forwardRef(\n (\n {\n className,\n maxItems,\n itemsBeforeCollapse = 1,\n itemsAfterCollapse = 1,\n onEllipsisPress,\n children,\n ...props\n },\n ref\n ) => {\n const crumbs = Children.toArray(children).filter(isValidElement);\n\n // Fold the middle only when hiding something actually shortens the row:\n // keeping N-1 of N crumbs plus an ellipsis saves nothing.\n let sequence: Array = crumbs;\n if (\n maxItems !== undefined &&\n crumbs.length > maxItems &&\n itemsBeforeCollapse + itemsAfterCollapse < crumbs.length\n ) {\n const head = crumbs.slice(0, Math.max(0, itemsBeforeCollapse));\n const tail =\n itemsAfterCollapse > 0 ? crumbs.slice(crumbs.length - itemsAfterCollapse) : [];\n sequence = [...head, COLLAPSE, ...tail];\n }\n\n return (\n \n {sequence.map((node, i) => (\n \n {i > 0 ? : null}\n {node === COLLAPSE ? (\n \n ) : (\n node\n )}\n \n ))}\n \n );\n }\n);\nBreadcrumbList.displayName = 'Breadcrumb.List';\n\nexport interface BreadcrumbItemProps extends ViewProps {\n className?: string;\n children?: ReactNode;\n}\n\n/** One crumb: wraps a link or the current page. */\nconst BreadcrumbItem = forwardRef(\n ({ className, children, ...props }, ref) => (\n \n {textChildren(children)}\n \n )\n);\nBreadcrumbItem.displayName = 'Breadcrumb.Item';\n\nexport interface BreadcrumbLinkProps\n extends Omit {\n className?: string;\n /** Text style for the crumb's label. */\n textClassName?: string;\n children?: ReactNode;\n}\n\n/**\n * A navigable crumb — an ancestor you can jump back to. Muted until pressed,\n * so the current page (which is not a link) reads as the emphasised end of the\n * trail. A string child is wrapped in the crumb text style; anything else\n * renders as given.\n */\nconst BreadcrumbLink = forwardRef(\n ({ className, textClassName, children, ...props }, ref) => {\n const { size } = useBreadcrumb();\n\n return (\n \n {textChildren(children, (text) => (\n \n {text}\n \n ))}\n \n );\n }\n);\nBreadcrumbLink.displayName = 'Breadcrumb.Link';\n\nexport interface BreadcrumbPageProps extends TextProps {\n className?: string;\n}\n\n/**\n * The trailing crumb: where you are now. Not a link — `aria-current=\"page\"`\n * marks it as the destination, and it is painted in the full foreground so the\n * trail resolves to it.\n */\nconst BreadcrumbPage = forwardRef(\n ({ className, ...props }, ref) => {\n const { size } = useBreadcrumb();\n\n return (\n \n );\n }\n);\nBreadcrumbPage.displayName = 'Breadcrumb.Page';\n\nexport interface BreadcrumbSeparatorProps extends ViewProps {\n className?: string;\n /** Override the glyph for this one gap. Falls back to the root's separator. */\n children?: ReactNode;\n}\n\n/**\n * The glyph between crumbs. `Breadcrumb.List` inserts it for you; it is public\n * only for the rare hand-assembled trail. Hidden from screen readers — the\n * order of the crumbs already conveys the hierarchy.\n */\nconst BreadcrumbSeparator = forwardRef(\n ({ className, children, ...props }, ref) => {\n const { separator } = useBreadcrumb();\n const rawTint = useCSSVariable('--color-muted-foreground');\n const tint = typeof rawTint === 'string' ? rawTint : undefined;\n const glyph = children ?? separator;\n\n return (\n \n {glyph ?? }\n \n );\n }\n);\nBreadcrumbSeparator.displayName = 'Breadcrumb.Separator';\n\nexport interface BreadcrumbEllipsisProps\n extends Omit {\n className?: string;\n}\n\n/**\n * Stands in for the crumbs a collapsed trail hides. Static by default; give it\n * an `onPress` (via the list's `onEllipsisPress`) and it becomes the trigger\n * for a menu of the hidden steps. Labelled \"Show more\" so the collapse is not\n * silent to assistive tech.\n */\nconst BreadcrumbEllipsis = forwardRef(\n ({ className, onPress, ...props }, ref) => {\n const rawTint = useCSSVariable('--color-muted-foreground');\n const tint = typeof rawTint === 'string' ? rawTint : undefined;\n const glyph = ;\n\n if (!onPress) {\n return (\n \n {glyph}\n \n );\n }\n\n return (\n \n {glyph}\n \n );\n }\n);\nBreadcrumbEllipsis.displayName = 'Breadcrumb.Ellipsis';\n\nexport const Breadcrumb = Object.assign(BreadcrumbRoot, {\n List: BreadcrumbList,\n Item: BreadcrumbItem,\n Link: BreadcrumbLink,\n Page: BreadcrumbPage,\n Separator: BreadcrumbSeparator,\n Ellipsis: BreadcrumbEllipsis,\n});\n" } ] } diff --git a/apps/docs/public/r/button-group.json b/apps/docs/public/r/button-group.json index adcf6afd7..ef7dbcfd6 100644 --- a/apps/docs/public/r/button-group.json +++ b/apps/docs/public/r/button-group.json @@ -3,7 +3,8 @@ "type": "registry:ui", "description": "Several buttons drawn as one control — a segmented run, a split action, a toolbar.", "registryDependencies": [ - "button" + "button", + "refs" ], "dependencies": [ "tailwind-variants" @@ -13,7 +14,7 @@ { "path": "ui/button-group.tsx", "type": "registry:ui", - "content": "/**\n * ButtonGroup — several buttons drawn as one control.\n *\n * ```tsx\n * \n * \n * \n * \n * \n * ```\n *\n * The buttons stay buttons. Anything a `Button` does — an icon, a badge, a\n * loading state, a disabled segment, opening a Popover — it still does inside a\n * group, because the group is a container rather than a component that takes a\n * list of items and renders them for you. A list-of-items API has to grow a\n * prop for every one of those things; this one has none of them and can do all\n * of them.\n *\n * ## Why the container draws the border\n *\n * A joined run could be built by giving the first and last segments their\n * corners and squaring the ones between, then collapsing every shared edge with\n * a negative margin. That works on the web and is a stack of off-by-one\n * problems on a phone: the hairlines land on different fractions of a pixel per\n * device, and a run that wraps has no first or last segment any more.\n *\n * So the group draws the shape once — one border, one radius, one shadow,\n * clipped — and the buttons inside it draw none of their own. The dividers are\n * real one-pixel views the group puts between its children, which is also why\n * they are always exactly one pixel and always in the same place.\n *\n * ## What it passes down\n *\n * `variant` and `size` fill in for a button that did not choose its own, so a\n * run of six does not repeat the same two props six times. A segment that wants\n * to stand out — the selected one, the destructive one — sets its own and wins.\n *\n * ```tsx\n * \n * \n * {/* the selected one *\\/}\n * \n * \n * ```\n *\n * ## Not a selection control\n *\n * This joins buttons; what they mean is yours. For a control that owns which\n * one is on, reach for `ToggleButtonGroup`, and for switching between panels of\n * content reach for `Tabs` — a segmented run of buttons that swaps a screen is\n * navigation, and navigation should say so to a screen reader.\n */\nimport { Children, Fragment, forwardRef, useMemo, type ReactNode } from 'react';\nimport { View, type ViewProps } from 'react-native';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport {\n ButtonGroupProvider,\n type ButtonGroupContextValue,\n type ButtonSize,\n type ButtonVariant,\n} from '@/components/ui/button';\n\n/** Which way the run reads. */\nexport type ButtonGroupOrientation = 'horizontal' | 'vertical';\n\nconst buttonGroupVariants = tv({\n slots: {\n root: 'items-stretch',\n divider: 'bg-border',\n },\n variants: {\n orientation: {\n horizontal: { root: 'flex-row', divider: 'w-px' },\n vertical: { root: 'flex-col', divider: 'h-px' },\n },\n attached: {\n // `overflow-hidden` is what squares the inner corners and rounds the\n // outer ones without any segment knowing which it is.\n true: { root: 'overflow-hidden border border-input bg-popover shadow-sm' },\n false: { root: 'gap-2' },\n },\n /* Matched to the height the buttons will be, since a radius that suited a\n 36pt run reads as a rectangle around a 48pt one. */\n size: {\n xs: { root: 'rounded-md' },\n sm: { root: 'rounded-lg' },\n md: { root: 'rounded-xl' },\n lg: { root: 'rounded-xl' },\n xl: { root: 'rounded-2xl' },\n icon: { root: 'rounded-xl' },\n },\n fullWidth: {\n true: { root: 'w-full' },\n },\n },\n compoundVariants: [\n // Nothing is being joined, so the radius belongs to each button instead.\n { attached: false, class: { root: 'rounded-none' } },\n ],\n defaultVariants: {\n orientation: 'horizontal',\n attached: true,\n size: 'md',\n },\n});\n\ntype ButtonGroupVariantProps = VariantProps;\n\nexport interface ButtonGroupProps\n extends ViewProps,\n Omit {\n className?: string;\n /** Which way the run reads. Vertical is the toolbar down the side of a canvas. */\n orientation?: ButtonGroupOrientation;\n /** Fills in for any button that did not choose its own. */\n variant?: ButtonVariant;\n /** Fills in for any button that did not choose its own, and sets the radius. */\n size?: ButtonSize;\n /**\n * Draw the run as one joined shape.\n *\n * On by default — that is what a group is. Turn it off for a plain row of\n * separate buttons that should still share a variant and a size, which is a\n * toolbar rather than a segmented control.\n */\n attached?: boolean;\n /**\n * Span the container, with the segments sharing it equally.\n *\n * Equally, not by content: a row of segments at their natural widths is a row\n * whose divisions move when the labels change, and a picker whose halves are\n * different sizes reads as though one of them matters more.\n */\n fullWidth?: boolean;\n children?: ReactNode;\n}\n\nconst ButtonGroupRoot = forwardRef(\n (\n {\n className,\n orientation = 'horizontal',\n variant,\n size = 'md',\n attached = true,\n fullWidth = false,\n children,\n ...props\n },\n ref\n ) => {\n const { root, divider } = buttonGroupVariants({\n orientation,\n attached,\n size,\n fullWidth,\n });\n\n const context = useMemo(\n () => ({ variant, size, attached, fullWidth }),\n [variant, size, attached, fullWidth]\n );\n\n /*\n * `Children.toArray` rather than the raw children: it drops the nulls a\n * conditional segment leaves behind and flattens fragments, so a run built\n * by a `map` or by `{canEdit && \n * \n * \n * \n * ```\n *\n * The buttons stay buttons. Anything a `Button` does — an icon, a badge, a\n * loading state, a disabled segment, opening a Popover — it still does inside a\n * group, because the group is a container rather than a component that takes a\n * list of items and renders them for you. A list-of-items API has to grow a\n * prop for every one of those things; this one has none of them and can do all\n * of them.\n *\n * ## Why the container draws the border\n *\n * A joined run could be built by giving the first and last segments their\n * corners and squaring the ones between, then collapsing every shared edge with\n * a negative margin. That works on the web and is a stack of off-by-one\n * problems on a phone: the hairlines land on different fractions of a pixel per\n * device, and a run that wraps has no first or last segment any more.\n *\n * So the group draws the shape once — one border, one radius, one shadow,\n * clipped — and the buttons inside it draw none of their own. The dividers are\n * real one-pixel views the group puts between its children, which is also why\n * they are always exactly one pixel and always in the same place.\n *\n * ## What it passes down\n *\n * `variant` and `size` fill in for a button that did not choose its own, so a\n * run of six does not repeat the same two props six times. A segment that wants\n * to stand out — the selected one, the destructive one — sets its own and wins.\n *\n * ```tsx\n * \n * \n * {/* the selected one *\\/}\n * \n * \n * ```\n *\n * ## Not a selection control\n *\n * This joins buttons; what they mean is yours. For a control that owns which\n * one is on, reach for `ToggleButtonGroup`, and for switching between panels of\n * content reach for `Tabs` — a segmented run of buttons that swaps a screen is\n * navigation, and navigation should say so to a screen reader.\n */\nimport { Children, Fragment, forwardRef, useMemo, type ReactNode } from 'react';\nimport { View, type ViewProps } from 'react-native';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport {\n ButtonGroupProvider,\n type ButtonGroupContextValue,\n type ButtonSize,\n type ButtonVariant,\n} from '@/components/ui/button';\nimport type { ViewRef } from '@/components/ui/refs';\n\n/** Which way the run reads. */\nexport type ButtonGroupOrientation = 'horizontal' | 'vertical';\n\nconst buttonGroupVariants = tv({\n slots: {\n root: 'items-stretch',\n divider: 'bg-border',\n },\n variants: {\n orientation: {\n horizontal: { root: 'flex-row', divider: 'w-px' },\n vertical: { root: 'flex-col', divider: 'h-px' },\n },\n attached: {\n // `overflow-hidden` is what squares the inner corners and rounds the\n // outer ones without any segment knowing which it is.\n true: { root: 'overflow-hidden border border-input bg-popover shadow-sm' },\n false: { root: 'gap-2' },\n },\n /* Matched to the height the buttons will be, since a radius that suited a\n 36pt run reads as a rectangle around a 48pt one. */\n size: {\n xs: { root: 'rounded-md' },\n sm: { root: 'rounded-lg' },\n md: { root: 'rounded-xl' },\n lg: { root: 'rounded-xl' },\n xl: { root: 'rounded-2xl' },\n icon: { root: 'rounded-xl' },\n },\n fullWidth: {\n true: { root: 'w-full' },\n },\n },\n compoundVariants: [\n // Nothing is being joined, so the radius belongs to each button instead.\n { attached: false, class: { root: 'rounded-none' } },\n ],\n defaultVariants: {\n orientation: 'horizontal',\n attached: true,\n size: 'md',\n },\n});\n\ntype ButtonGroupVariantProps = VariantProps;\n\nexport interface ButtonGroupProps\n extends ViewProps,\n Omit {\n className?: string;\n /** Which way the run reads. Vertical is the toolbar down the side of a canvas. */\n orientation?: ButtonGroupOrientation;\n /** Fills in for any button that did not choose its own. */\n variant?: ButtonVariant;\n /** Fills in for any button that did not choose its own, and sets the radius. */\n size?: ButtonSize;\n /**\n * Draw the run as one joined shape.\n *\n * On by default — that is what a group is. Turn it off for a plain row of\n * separate buttons that should still share a variant and a size, which is a\n * toolbar rather than a segmented control.\n */\n attached?: boolean;\n /**\n * Span the container, with the segments sharing it equally.\n *\n * Equally, not by content: a row of segments at their natural widths is a row\n * whose divisions move when the labels change, and a picker whose halves are\n * different sizes reads as though one of them matters more.\n */\n fullWidth?: boolean;\n children?: ReactNode;\n}\n\nconst ButtonGroupRoot = forwardRef(\n (\n {\n className,\n orientation = 'horizontal',\n variant,\n size = 'md',\n attached = true,\n fullWidth = false,\n children,\n ...props\n },\n ref\n ) => {\n const { root, divider } = buttonGroupVariants({\n orientation,\n attached,\n size,\n fullWidth,\n });\n\n const context = useMemo(\n () => ({ variant, size, attached, fullWidth }),\n [variant, size, attached, fullWidth]\n );\n\n /*\n * `Children.toArray` rather than the raw children: it drops the nulls a\n * conditional segment leaves behind and flattens fragments, so a run built\n * by a `map` or by `{canEdit && \n * \n * \n * ```\n *\n * All the padding lives on the slots and none of it on the root, which is why\n * a card whose media reaches its own corners needs nothing but\n * `overflow-hidden`.\n *\n * Every part is a plain view: the card draws a surface and gets out of the way,\n * and nothing here reaches for an animation driver or a native module. A card\n * that wants a decorative backing layer composes one as its first child.\n */\nimport { forwardRef } from 'react';\nimport { View, type ViewProps } from 'react-native';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport { cn } from '@/lib/cn';\nimport { Text, type TextProps } from '@/components/ui/text';\n\nconst footerVariants = tv({\n base: 'flex-row items-center gap-2',\n variants: {\n /**\n * Whether the footer draws a surface of its own.\n *\n * `plain` is part of the card: same background, padding continuing from the\n * content above it. `panel` is a band set into it — a rule across the top,\n * a step darker, and the card's own bottom corners — so the buttons read as\n * the card's actions rather than as the last thing in its body. Use it when\n * the footer is what somebody does with the card, not more of what it says.\n */\n variant: {\n plain: 'p-6 pt-0',\n panel: 'rounded-b-2xl border-t border-border bg-inset p-6',\n },\n },\n defaultVariants: {\n variant: 'plain',\n },\n});\n\nexport interface CardProps extends ViewProps {\n className?: string;\n}\n\nexport interface CardFooterProps\n extends CardProps,\n VariantProps {}\n\nconst CardRoot = forwardRef(({ className, ...props }, ref) => (\n \n));\nCardRoot.displayName = 'Card';\n\nconst CardHeader = forwardRef(({ className, ...props }, ref) => (\n \n));\nCardHeader.displayName = 'Card.Header';\n\nconst CardTitle = forwardRef, TextProps>(\n ({ className, ...props }, ref) => (\n \n )\n);\nCardTitle.displayName = 'Card.Title';\n\nconst CardDescription = forwardRef, TextProps>(\n ({ className, ...props }, ref) => (\n \n )\n);\nCardDescription.displayName = 'Card.Description';\n\nconst CardContent = forwardRef(({ className, ...props }, ref) => (\n \n));\nCardContent.displayName = 'Card.Content';\n\nconst CardFooter = forwardRef(\n ({ className, variant, ...props }, ref) => (\n \n )\n);\nCardFooter.displayName = 'Card.Footer';\n\nexport const Card = Object.assign(CardRoot, {\n Header: CardHeader,\n Title: CardTitle,\n Description: CardDescription,\n Content: CardContent,\n Footer: CardFooter,\n});\n" + "content": "/**\n * Card — a content surface.\n *\n * ```tsx\n * \n * \n * Living room sofa\n * Three seats, oat linen\n * \n * \n * \n * \n * \n * ```\n *\n * All the padding lives on the slots and none of it on the root, which is why\n * a card whose media reaches its own corners needs nothing but\n * `overflow-hidden`.\n *\n * Every part is a plain view: the card draws a surface and gets out of the way,\n * and nothing here reaches for an animation driver or a native module. A card\n * that wants a decorative backing layer composes one as its first child.\n */\nimport { forwardRef } from 'react';\nimport { View, type ViewProps } from 'react-native';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport { cn } from '@/lib/cn';\nimport { Text, type TextProps } from '@/components/ui/text';\nimport type { TextRef, ViewRef } from '@/components/ui/refs';\n\nconst footerVariants = tv({\n base: 'flex-row items-center gap-2',\n variants: {\n /**\n * Whether the footer draws a surface of its own.\n *\n * `plain` is part of the card: same background, padding continuing from the\n * content above it. `panel` is a band set into it — a rule across the top,\n * a step darker, and the card's own bottom corners — so the buttons read as\n * the card's actions rather than as the last thing in its body. Use it when\n * the footer is what somebody does with the card, not more of what it says.\n */\n variant: {\n plain: 'p-6 pt-0',\n panel: 'rounded-b-2xl border-t border-border bg-inset p-6',\n },\n },\n defaultVariants: {\n variant: 'plain',\n },\n});\n\nexport interface CardProps extends ViewProps {\n className?: string;\n}\n\nexport interface CardFooterProps\n extends CardProps,\n VariantProps {}\n\nconst CardRoot = forwardRef(({ className, ...props }, ref) => (\n \n));\nCardRoot.displayName = 'Card';\n\nconst CardHeader = forwardRef(({ className, ...props }, ref) => (\n \n));\nCardHeader.displayName = 'Card.Header';\n\nconst CardTitle = forwardRef(\n ({ className, ...props }, ref) => (\n \n )\n);\nCardTitle.displayName = 'Card.Title';\n\nconst CardDescription = forwardRef(\n ({ className, ...props }, ref) => (\n \n )\n);\nCardDescription.displayName = 'Card.Description';\n\nconst CardContent = forwardRef(({ className, ...props }, ref) => (\n \n));\nCardContent.displayName = 'Card.Content';\n\nconst CardFooter = forwardRef(\n ({ className, variant, ...props }, ref) => (\n \n )\n);\nCardFooter.displayName = 'Card.Footer';\n\nexport const Card = Object.assign(CardRoot, {\n Header: CardHeader,\n Title: CardTitle,\n Description: CardDescription,\n Content: CardContent,\n Footer: CardFooter,\n});\n" } ] } diff --git a/apps/docs/public/r/checkbox.json b/apps/docs/public/r/checkbox.json index 358d2abaa..1fea18e2a 100644 --- a/apps/docs/public/r/checkbox.json +++ b/apps/docs/public/r/checkbox.json @@ -4,6 +4,7 @@ "description": "Animated checkbox, as a row or a selectable card.", "registryDependencies": [ "icons", + "refs", "text" ], "dependencies": [ @@ -16,7 +17,7 @@ { "path": "ui/checkbox.tsx", "type": "registry:ui", - "content": "import { forwardRef, useEffect, type ReactNode } from 'react';\nimport { Pressable, View, type PressableProps } from 'react-native';\nimport Animated, {\n useAnimatedStyle,\n useSharedValue,\n withSpring,\n withTiming,\n} from 'react-native-reanimated';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport { useCSSVariable } from 'uniwind';\nimport { CheckIcon, MinusIcon } from '@/components/ui/icons';\nimport { Text } from '@/components/ui/text';\n\nconst checkboxVariants = tv({\n slots: {\n row: 'min-h-12 min-w-12 flex-row items-center gap-2.5',\n box: 'h-5 w-5 items-center justify-center rounded-md border border-input bg-background',\n fill: 'absolute inset-0 items-center justify-center rounded-md bg-primary',\n content: 'flex-1 gap-0.5',\n label: 'text-sm text-foreground',\n description: 'text-sm text-muted-foreground',\n },\n variants: {\n /*\n * The box, its tick and the text beside it move together. The row does\n * not: it stays 48 points tall at every size, because that is the touch\n * target and a smaller box is not a reason for a smaller one.\n *\n * Listed before `variant` so the card keeps the label weight it sets.\n */\n size: {\n xs: {\n row: 'gap-2',\n box: 'h-3.5 w-3.5 rounded-sm',\n fill: 'rounded-sm',\n label: 'text-xs',\n description: 'text-xs',\n },\n sm: {\n row: 'gap-2',\n box: 'h-4 w-4 rounded-sm',\n fill: 'rounded-sm',\n label: 'text-[13px]',\n description: 'text-xs',\n },\n md: {},\n lg: {\n row: 'gap-3',\n box: 'h-6 w-6',\n label: 'text-base',\n },\n xl: {\n row: 'gap-3',\n box: 'h-7 w-7 rounded-lg',\n fill: 'rounded-lg',\n label: 'text-lg',\n description: 'text-base',\n },\n },\n variant: {\n default: {},\n /**\n * The whole surface is the target — for pickable options where the\n * checkbox is a confirmation rather than the affordance itself.\n */\n card: {\n row: 'w-full items-start gap-3 rounded-xl border border-border bg-card p-4',\n label: 'text-base font-medium',\n },\n },\n checked: {\n true: {},\n },\n disabled: {\n true: { row: 'opacity-50' },\n },\n },\n compoundVariants: [\n {\n variant: 'card',\n checked: true,\n class: { row: 'border-primary bg-accent' },\n },\n ],\n defaultVariants: {\n variant: 'default',\n size: 'md',\n },\n});\n\n/** How big a checkbox is. */\nexport type CheckboxSize = NonNullable['size']>;\n\n/** The tick and the dash, sized to leave the same share of the box around them. */\nconst GLYPH_SIZE: Record = { xs: 8, sm: 10, md: 12, lg: 14, xl: 16 };\n\nexport interface CheckboxProps\n extends VariantProps,\n Omit<\n PressableProps,\n | 'children'\n | 'disabled'\n | 'onPress'\n | 'style'\n | 'accessibilityRole'\n | 'accessibilityState'\n > {\n /**\n * Styles the pressable row — layout, border, fill. It does not reach the\n * label: text colour is not inherited from a view in React Native, so use\n * `labelClassName` for that.\n */\n className?: string;\n checked: boolean;\n onCheckedChange?: (checked: boolean) => void;\n /**\n * A third, in-between state for a box that governs a group of others — some\n * on, some off. It fills like a checked box but shows a dash rather than a\n * tick, and announces itself as `mixed` to a screen reader. Pressing it\n * resolves the ambiguity by turning the whole group on, so the press reports\n * `true`. `indeterminate` overrides `checked` for what is drawn.\n */\n indeterminate?: boolean;\n disabled?: boolean;\n /**\n * Optional label rendered next to the box; pressing it also toggles.\n *\n * A string is the usual case. Pass text nodes instead to put a link inside\n * the sentence — `<>I agree to the Terms` —\n * and the link's own press wins over the toggle. A screen reader cannot\n * read a node as the checkbox's name, so pass `accessibilityLabel` with it.\n */\n label?: ReactNode;\n /** Classes for the label text, merged over the defaults — e.g. `text-black`. */\n labelClassName?: string;\n /**\n * Classes for the view holding the label and description, merged over\n * `flex-1 gap-0.5`. The default makes the text fill the row; pass\n * `flex-none` to size it to the text when the checkbox shares a line with\n * other content, such as a \"Forgot password\" link.\n */\n contentClassName?: string;\n /** Secondary line under the label, for extra context. */\n description?: string;\n /** Classes for the description text, merged over the defaults. */\n descriptionClassName?: string;\n}\n\nexport const Checkbox = forwardRef(\n (\n {\n className,\n checked,\n onCheckedChange,\n indeterminate,\n disabled,\n label,\n labelClassName,\n contentClassName,\n description,\n descriptionClassName,\n variant,\n size = 'md',\n accessibilityLabel,\n ...rest\n },\n ref\n ) => {\n // The dash and the tick share one fill, so the box is \"on\" for either —\n // what changes is the glyph, not whether the fill is shown.\n const on = checked || !!indeterminate;\n const progress = useSharedValue(on ? 1 : 0);\n const slots = checkboxVariants({ variant, size, checked: on, disabled: !!disabled });\n const checkColor = useCSSVariable('--color-primary-foreground');\n\n useEffect(() => {\n progress.value = on\n ? withSpring(1, { damping: 15, stiffness: 300, mass: 0.5 })\n : withTiming(0, { duration: 120 });\n }, [on, progress]);\n\n const fillStyle = useAnimatedStyle(() => ({\n opacity: progress.value,\n transform: [{ scale: 0.6 + progress.value * 0.4 }],\n }));\n\n const glyphColor = typeof checkColor === 'string' ? checkColor : '#fff';\n const box = (\n \n \n {indeterminate ? (\n \n ) : (\n \n )}\n \n \n );\n\n return (\n onCheckedChange?.(indeterminate ? true : !checked)}\n // A description makes the row two lines tall — align the box to the\n // label rather than centring it against the whole block.\n className={slots.row({\n className:\n description && variant !== 'card'\n ? `items-start ${className ?? ''}`\n : className,\n })}\n >\n {/* The card lays out content first so the box sits top-right. */}\n {variant === 'card' ? null : box}\n {label || description ? (\n \n {label ? (\n {label}\n ) : null}\n {description ? (\n \n {description}\n \n ) : null}\n \n ) : null}\n {variant === 'card' ? box : null}\n \n );\n }\n);\n\nCheckbox.displayName = 'Checkbox';\n" + "content": "import { forwardRef, useEffect, type ReactNode } from 'react';\nimport { Pressable, View, type PressableProps } from 'react-native';\nimport Animated, {\n useAnimatedStyle,\n useSharedValue,\n withSpring,\n withTiming,\n} from 'react-native-reanimated';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport { useCSSVariable } from 'uniwind';\nimport { CheckIcon, MinusIcon } from '@/components/ui/icons';\nimport { Text } from '@/components/ui/text';\nimport type { ViewRef } from '@/components/ui/refs';\n\nconst checkboxVariants = tv({\n slots: {\n row: 'min-h-12 min-w-12 flex-row items-center gap-2.5',\n box: 'h-5 w-5 items-center justify-center rounded-md border border-input bg-background',\n fill: 'absolute inset-0 items-center justify-center rounded-md bg-primary',\n content: 'flex-1 gap-0.5',\n label: 'text-sm text-foreground',\n description: 'text-sm text-muted-foreground',\n },\n variants: {\n /*\n * The box, its tick and the text beside it move together. The row does\n * not: it stays 48 points tall at every size, because that is the touch\n * target and a smaller box is not a reason for a smaller one.\n *\n * Listed before `variant` so the card keeps the label weight it sets.\n */\n size: {\n xs: {\n row: 'gap-2',\n box: 'h-3.5 w-3.5 rounded-sm',\n fill: 'rounded-sm',\n label: 'text-xs',\n description: 'text-xs',\n },\n sm: {\n row: 'gap-2',\n box: 'h-4 w-4 rounded-sm',\n fill: 'rounded-sm',\n label: 'text-[13px]',\n description: 'text-xs',\n },\n md: {},\n lg: {\n row: 'gap-3',\n box: 'h-6 w-6',\n label: 'text-base',\n },\n xl: {\n row: 'gap-3',\n box: 'h-7 w-7 rounded-lg',\n fill: 'rounded-lg',\n label: 'text-lg',\n description: 'text-base',\n },\n },\n variant: {\n default: {},\n /**\n * The whole surface is the target — for pickable options where the\n * checkbox is a confirmation rather than the affordance itself.\n */\n card: {\n row: 'w-full items-start gap-3 rounded-xl border border-border bg-card p-4',\n label: 'text-base font-medium',\n },\n },\n checked: {\n true: {},\n },\n disabled: {\n true: { row: 'opacity-50' },\n },\n },\n compoundVariants: [\n {\n variant: 'card',\n checked: true,\n class: { row: 'border-primary bg-accent' },\n },\n ],\n defaultVariants: {\n variant: 'default',\n size: 'md',\n },\n});\n\n/** How big a checkbox is. */\nexport type CheckboxSize = NonNullable['size']>;\n\n/** The tick and the dash, sized to leave the same share of the box around them. */\nconst GLYPH_SIZE: Record = { xs: 8, sm: 10, md: 12, lg: 14, xl: 16 };\n\nexport interface CheckboxProps\n extends VariantProps,\n Omit<\n PressableProps,\n | 'children'\n | 'disabled'\n | 'onPress'\n | 'style'\n | 'accessibilityRole'\n | 'accessibilityState'\n > {\n /**\n * Styles the pressable row — layout, border, fill. It does not reach the\n * label: text colour is not inherited from a view in React Native, so use\n * `labelClassName` for that.\n */\n className?: string;\n checked: boolean;\n onCheckedChange?: (checked: boolean) => void;\n /**\n * A third, in-between state for a box that governs a group of others — some\n * on, some off. It fills like a checked box but shows a dash rather than a\n * tick, and announces itself as `mixed` to a screen reader. Pressing it\n * resolves the ambiguity by turning the whole group on, so the press reports\n * `true`. `indeterminate` overrides `checked` for what is drawn.\n */\n indeterminate?: boolean;\n disabled?: boolean;\n /**\n * Optional label rendered next to the box; pressing it also toggles.\n *\n * A string is the usual case. Pass text nodes instead to put a link inside\n * the sentence — `<>I agree to the Terms` —\n * and the link's own press wins over the toggle. A screen reader cannot\n * read a node as the checkbox's name, so pass `accessibilityLabel` with it.\n */\n label?: ReactNode;\n /** Classes for the label text, merged over the defaults — e.g. `text-black`. */\n labelClassName?: string;\n /**\n * Classes for the view holding the label and description, merged over\n * `flex-1 gap-0.5`. The default makes the text fill the row; pass\n * `flex-none` to size it to the text when the checkbox shares a line with\n * other content, such as a \"Forgot password\" link.\n */\n contentClassName?: string;\n /** Secondary line under the label, for extra context. */\n description?: string;\n /** Classes for the description text, merged over the defaults. */\n descriptionClassName?: string;\n}\n\nexport const Checkbox = forwardRef(\n (\n {\n className,\n checked,\n onCheckedChange,\n indeterminate,\n disabled,\n label,\n labelClassName,\n contentClassName,\n description,\n descriptionClassName,\n variant,\n size = 'md',\n accessibilityLabel,\n ...rest\n },\n ref\n ) => {\n // The dash and the tick share one fill, so the box is \"on\" for either —\n // what changes is the glyph, not whether the fill is shown.\n const on = checked || !!indeterminate;\n const progress = useSharedValue(on ? 1 : 0);\n const slots = checkboxVariants({ variant, size, checked: on, disabled: !!disabled });\n const checkColor = useCSSVariable('--color-primary-foreground');\n\n useEffect(() => {\n progress.value = on\n ? withSpring(1, { damping: 15, stiffness: 300, mass: 0.5 })\n : withTiming(0, { duration: 120 });\n }, [on, progress]);\n\n const fillStyle = useAnimatedStyle(() => ({\n opacity: progress.value,\n transform: [{ scale: 0.6 + progress.value * 0.4 }],\n }));\n\n const glyphColor = typeof checkColor === 'string' ? checkColor : '#fff';\n const box = (\n \n \n {indeterminate ? (\n \n ) : (\n \n )}\n \n \n );\n\n return (\n onCheckedChange?.(indeterminate ? true : !checked)}\n // A description makes the row two lines tall — align the box to the\n // label rather than centring it against the whole block.\n className={slots.row({\n className:\n description && variant !== 'card'\n ? `items-start ${className ?? ''}`\n : className,\n })}\n >\n {/* The card lays out content first so the box sits top-right. */}\n {variant === 'card' ? null : box}\n {label || description ? (\n \n {label ? (\n {label}\n ) : null}\n {description ? (\n \n {description}\n \n ) : null}\n \n ) : null}\n {variant === 'card' ? box : null}\n \n );\n }\n);\n\nCheckbox.displayName = 'Checkbox';\n" } ] } diff --git a/apps/docs/public/r/chip.json b/apps/docs/public/r/chip.json index 7f31af9ca..85a3a3387 100644 --- a/apps/docs/public/r/chip.json +++ b/apps/docs/public/r/chip.json @@ -7,6 +7,7 @@ "cn", "haptics", "icons", + "refs", "text" ], "dependencies": [ @@ -18,7 +19,7 @@ { "path": "ui/chip.tsx", "type": "registry:ui", - "content": "/**\n * Chip — a compact, interactive token.\n *\n * Where Badge only shows and ToggleButton owns a stateful toolbar, Chip is the\n * thing in between: a small pill you can *press*, mark as *selected*, or\n * *remove*. It is the filter in a filter bar, the tag on a photo, the removable\n * recipient in a \"To:\" field — small, dense, and touchable.\n *\n * The close button is its own hit target nested inside the pill, so tapping the\n * ✕ removes the chip without also firing the chip's own `onPress`. That split\n * is the whole reason a removable chip needs a component and not just a styled\n * Badge.\n *\n * ```tsx\n * Design\n *\n * remove('design')}>Design\n *\n * setOn(!on)}>\n * \n * Available\n * \n * ```\n */\nimport {\n createContext,\n forwardRef,\n useCallback,\n useContext,\n useMemo,\n type ReactNode,\n} from 'react';\nimport { Pressable, View, type ViewProps } from 'react-native';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport { useCSSVariable } from 'uniwind';\nimport { IconColorProvider, XIcon } from '@/components/ui/icons';\nimport {\n AnimatedPressable,\n type AnimatedPressableProps,\n} from '@/components/ui/animated-pressable';\nimport { Text, textChildren } from '@/components/ui/text';\nimport { cn } from '@/lib/cn';\nimport { selectionTick } from '@/lib/haptics';\n\nconst chipVariants = tv({\n slots: {\n root: 'flex-row items-center self-start rounded-full border border-transparent',\n label: 'font-medium',\n close:\n 'items-center justify-center rounded-full -me-1 aspect-square opacity-70',\n },\n variants: {\n variant: {\n /** The resting tag — a quiet neutral fill. */\n default: { root: 'bg-secondary', label: 'text-secondary-foreground' },\n /** The accent fill, for the one chip that leads. */\n primary: { root: 'bg-primary', label: 'text-primary-foreground' },\n /** Just an outline — the lightest a chip gets while still being a shape. */\n outline: { root: 'border-border bg-transparent', label: 'text-foreground' },\n success: {\n root: 'bg-success-subtle',\n label: 'text-success-foreground',\n },\n warning: {\n root: 'bg-warning-subtle',\n label: 'text-warning-foreground',\n },\n info: { root: 'bg-info-subtle', label: 'text-info-foreground' },\n destructive: {\n root: 'bg-destructive-subtle',\n label: 'text-destructive-foreground',\n },\n },\n size: {\n xs: { root: 'h-5 gap-0.5 px-1.5', label: 'text-[11px]' },\n sm: { root: 'h-6 gap-1 px-2', label: 'text-xs' },\n md: { root: 'h-7 gap-1.5 px-2.5', label: 'text-sm' },\n lg: { root: 'h-9 gap-2 px-3.5', label: 'text-base' },\n xl: { root: 'h-11 gap-2 px-4', label: 'text-base' },\n },\n /**\n * The filter \"on\" state — a loud accent surface that wins over the resting\n * variant, so any chip can be a filter without picking a second colour.\n */\n selected: {\n true: {\n root: 'border-primary/24 bg-accent',\n label: 'text-accent-foreground',\n },\n },\n /** Trims the trailing padding so the close target hugs the right edge. */\n closable: {\n true: { root: 'pe-1' },\n },\n disabled: {\n true: { root: 'opacity-[0.56]' },\n },\n },\n compoundVariants: [\n { size: 'xs', closable: true, class: { close: 'h-3.5' } },\n { size: 'sm', closable: true, class: { close: 'h-4' } },\n { size: 'md', closable: true, class: { close: 'h-5' } },\n { size: 'lg', closable: true, class: { close: 'h-6' } },\n { size: 'xl', closable: true, class: { close: 'h-7' } },\n ],\n defaultVariants: {\n variant: 'default',\n size: 'md',\n },\n});\n\ntype ChipVariantProps = VariantProps;\nexport type ChipVariant = NonNullable;\nexport type ChipSize = NonNullable;\n\n/** Which foreground token each variant paints its icons and ✕ with. */\nconst TINT_VAR: Record = {\n default: '--color-secondary-foreground',\n primary: '--color-primary-foreground',\n outline: '--color-foreground',\n success: '--color-success-foreground',\n warning: '--color-warning-foreground',\n info: '--color-info-foreground',\n destructive: '--color-destructive-foreground',\n};\n\n/** The ✕ scales with the pill so it never looks stranded in a large chip. */\nconst CLOSE_ICON: Record = { xs: 9, sm: 11, md: 13, lg: 15, xl: 17 };\n\n/* -------------------------------------------------------------------------- */\n/* State context */\n/* -------------------------------------------------------------------------- */\n\ninterface ChipStateValue {\n selected: boolean;\n disabled: boolean;\n}\n\ninterface ChipContextValue extends ChipStateValue {\n /** The label classes for this chip's variant/size, so `Chip.Label` matches. */\n labelClasses: string;\n}\n\nconst ChipContext = createContext(null);\n\n/**\n * The chip's own state, for anything rendered inside it — an icon that swaps\n * when the chip is selected, a count that only shows when it is on.\n */\nexport function useChip(): ChipStateValue {\n const ctx = useContext(ChipContext);\n if (!ctx) {\n throw new Error('useChip must be used within a ');\n }\n return { selected: ctx.selected, disabled: ctx.disabled };\n}\n\n/* -------------------------------------------------------------------------- */\n/* Chip */\n/* -------------------------------------------------------------------------- */\n\nexport interface ChipProps\n extends Omit,\n Omit {\n children?: ReactNode;\n className?: string;\n /** Extra classes for the label when `children` is a string. */\n labelClassName?: string;\n /**\n * The filter \"on\" state. Setting it (even to `false`) makes the chip a\n * toggle, announced as such — pair it with `onPress` to flip it.\n */\n selected?: boolean;\n disabled?: boolean;\n /** A leading icon or avatar, before the label. */\n start?: ReactNode;\n /**\n * Shows a trailing ✕ and calls this when it is pressed. The ✕ is its own hit\n * target, so removing a chip never also fires its `onPress`.\n */\n onClose?: () => void;\n /** Accessibility label for the ✕. Defaults to \"Remove\". */\n closeLabel?: string;\n /**\n * A tick under the finger when the chip is pressed or removed. Off by\n * default — needs the optional `expo-haptics`, and is silent without it.\n */\n haptics?: boolean;\n}\n\nconst ChipRoot = forwardRef(\n (\n {\n children,\n className,\n labelClassName,\n variant = 'default',\n size = 'md',\n selected,\n disabled = false,\n start,\n onClose,\n closeLabel = 'Remove',\n haptics = false,\n onPress,\n ...props\n },\n ref\n ) => {\n const isToggle = selected !== undefined;\n const isSelected = selected ?? false;\n const closable = onClose !== undefined;\n // Only the whole chip becoming pressable warrants the press animation; a\n // static attribute tag stays a plain View so it does not invite a tap.\n const pressable = onPress !== undefined || isToggle;\n\n const slots = chipVariants({\n variant,\n size,\n selected: isSelected,\n closable,\n disabled,\n });\n\n // Icons and the ✕ inherit this, so they track the surface they sit on\n // instead of every caller hardcoding a hex that is wrong in one theme.\n const tint = useCSSVariable(\n isSelected ? '--color-accent-foreground' : TINT_VAR[variant]\n );\n\n const handlePress = useCallback>(\n (event) => {\n if (haptics) selectionTick();\n onPress?.(event);\n },\n [haptics, onPress]\n );\n\n const handleClose = useCallback(() => {\n if (haptics) selectionTick();\n onClose?.();\n }, [haptics, onClose]);\n\n const labelClasses = slots.label();\n const state = useMemo(\n () => ({ selected: isSelected, disabled, labelClasses }),\n [isSelected, disabled, labelClasses]\n );\n\n const body = (\n <>\n {start}\n {textChildren(children, (text) => (\n \n {text}\n \n ))}\n {closable ? (\n \n \n \n ) : null}\n \n );\n\n const sharedProps = {\n accessibilityRole: 'button' as const,\n // A toggle chip is read out as checked/unchecked; a plain pressable one\n // as a button. A static tag gets neither, so it is not announced as\n // something to press.\n accessibilityState: { disabled, ...(isToggle ? { selected: isSelected } : {}) },\n 'aria-pressed': isToggle ? isSelected : undefined,\n disabled,\n className: slots.root({ className }),\n };\n\n return (\n \n \n {pressable ? (\n \n {body}\n \n ) : (\n \n {body}\n \n )}\n \n \n );\n }\n);\nChipRoot.displayName = 'Chip';\n\n/* -------------------------------------------------------------------------- */\n/* Chip.Label */\n/* -------------------------------------------------------------------------- */\n\nexport interface ChipLabelProps {\n className?: string;\n children: ReactNode;\n}\n\n/**\n * The label, when the chip holds more than a string — an icon beside text. It\n * reads the selected state itself, so composing the two does not mean threading\n * the colour through by hand.\n */\nfunction ChipLabel({ className, children }: ChipLabelProps) {\n const ctx = useContext(ChipContext);\n if (!ctx) {\n throw new Error('Chip.Label must be used within a ');\n }\n return {children};\n}\nChipLabel.displayName = 'Chip.Label';\n\nexport const Chip = Object.assign(ChipRoot, {\n Label: ChipLabel,\n});\n" + "content": "/**\n * Chip — a compact, interactive token.\n *\n * Where Badge only shows and ToggleButton owns a stateful toolbar, Chip is the\n * thing in between: a small pill you can *press*, mark as *selected*, or\n * *remove*. It is the filter in a filter bar, the tag on a photo, the removable\n * recipient in a \"To:\" field — small, dense, and touchable.\n *\n * The close button is its own hit target nested inside the pill, so tapping the\n * ✕ removes the chip without also firing the chip's own `onPress`. That split\n * is the whole reason a removable chip needs a component and not just a styled\n * Badge.\n *\n * ```tsx\n * Design\n *\n * remove('design')}>Design\n *\n * setOn(!on)}>\n * \n * Available\n * \n * ```\n */\nimport {\n createContext,\n forwardRef,\n useCallback,\n useContext,\n useMemo,\n type ReactNode,\n} from 'react';\nimport { Pressable, View, type ViewProps } from 'react-native';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport { useCSSVariable } from 'uniwind';\nimport { IconColorProvider, XIcon } from '@/components/ui/icons';\nimport {\n AnimatedPressable,\n type AnimatedPressableProps,\n} from '@/components/ui/animated-pressable';\nimport { Text, textChildren } from '@/components/ui/text';\nimport { cn } from '@/lib/cn';\nimport { selectionTick } from '@/lib/haptics';\nimport type { ViewRef } from '@/components/ui/refs';\n\nconst chipVariants = tv({\n slots: {\n root: 'flex-row items-center self-start rounded-full border border-transparent',\n label: 'font-medium',\n close:\n 'items-center justify-center rounded-full -me-1 aspect-square opacity-70',\n },\n variants: {\n variant: {\n /** The resting tag — a quiet neutral fill. */\n default: { root: 'bg-secondary', label: 'text-secondary-foreground' },\n /** The accent fill, for the one chip that leads. */\n primary: { root: 'bg-primary', label: 'text-primary-foreground' },\n /** Just an outline — the lightest a chip gets while still being a shape. */\n outline: { root: 'border-border bg-transparent', label: 'text-foreground' },\n success: {\n root: 'bg-success-subtle',\n label: 'text-success-foreground',\n },\n warning: {\n root: 'bg-warning-subtle',\n label: 'text-warning-foreground',\n },\n info: { root: 'bg-info-subtle', label: 'text-info-foreground' },\n destructive: {\n root: 'bg-destructive-subtle',\n label: 'text-destructive-foreground',\n },\n },\n size: {\n xs: { root: 'h-5 gap-0.5 px-1.5', label: 'text-[11px]' },\n sm: { root: 'h-6 gap-1 px-2', label: 'text-xs' },\n md: { root: 'h-7 gap-1.5 px-2.5', label: 'text-sm' },\n lg: { root: 'h-9 gap-2 px-3.5', label: 'text-base' },\n xl: { root: 'h-11 gap-2 px-4', label: 'text-base' },\n },\n /**\n * The filter \"on\" state — a loud accent surface that wins over the resting\n * variant, so any chip can be a filter without picking a second colour.\n */\n selected: {\n true: {\n root: 'border-primary/24 bg-accent',\n label: 'text-accent-foreground',\n },\n },\n /** Trims the trailing padding so the close target hugs the right edge. */\n closable: {\n true: { root: 'pe-1' },\n },\n disabled: {\n true: { root: 'opacity-[0.56]' },\n },\n },\n compoundVariants: [\n { size: 'xs', closable: true, class: { close: 'h-3.5' } },\n { size: 'sm', closable: true, class: { close: 'h-4' } },\n { size: 'md', closable: true, class: { close: 'h-5' } },\n { size: 'lg', closable: true, class: { close: 'h-6' } },\n { size: 'xl', closable: true, class: { close: 'h-7' } },\n ],\n defaultVariants: {\n variant: 'default',\n size: 'md',\n },\n});\n\ntype ChipVariantProps = VariantProps;\nexport type ChipVariant = NonNullable;\nexport type ChipSize = NonNullable;\n\n/** Which foreground token each variant paints its icons and ✕ with. */\nconst TINT_VAR: Record = {\n default: '--color-secondary-foreground',\n primary: '--color-primary-foreground',\n outline: '--color-foreground',\n success: '--color-success-foreground',\n warning: '--color-warning-foreground',\n info: '--color-info-foreground',\n destructive: '--color-destructive-foreground',\n};\n\n/** The ✕ scales with the pill so it never looks stranded in a large chip. */\nconst CLOSE_ICON: Record = { xs: 9, sm: 11, md: 13, lg: 15, xl: 17 };\n\n/* -------------------------------------------------------------------------- */\n/* State context */\n/* -------------------------------------------------------------------------- */\n\ninterface ChipStateValue {\n selected: boolean;\n disabled: boolean;\n}\n\ninterface ChipContextValue extends ChipStateValue {\n /** The label classes for this chip's variant/size, so `Chip.Label` matches. */\n labelClasses: string;\n}\n\nconst ChipContext = createContext(null);\n\n/**\n * The chip's own state, for anything rendered inside it — an icon that swaps\n * when the chip is selected, a count that only shows when it is on.\n */\nexport function useChip(): ChipStateValue {\n const ctx = useContext(ChipContext);\n if (!ctx) {\n throw new Error('useChip must be used within a ');\n }\n return { selected: ctx.selected, disabled: ctx.disabled };\n}\n\n/* -------------------------------------------------------------------------- */\n/* Chip */\n/* -------------------------------------------------------------------------- */\n\nexport interface ChipProps\n extends Omit,\n Omit {\n children?: ReactNode;\n className?: string;\n /** Extra classes for the label when `children` is a string. */\n labelClassName?: string;\n /**\n * The filter \"on\" state. Setting it (even to `false`) makes the chip a\n * toggle, announced as such — pair it with `onPress` to flip it.\n */\n selected?: boolean;\n disabled?: boolean;\n /** A leading icon or avatar, before the label. */\n start?: ReactNode;\n /**\n * Shows a trailing ✕ and calls this when it is pressed. The ✕ is its own hit\n * target, so removing a chip never also fires its `onPress`.\n */\n onClose?: () => void;\n /** Accessibility label for the ✕. Defaults to \"Remove\". */\n closeLabel?: string;\n /**\n * A tick under the finger when the chip is pressed or removed. Off by\n * default — needs the optional `expo-haptics`, and is silent without it.\n */\n haptics?: boolean;\n}\n\nconst ChipRoot = forwardRef(\n (\n {\n children,\n className,\n labelClassName,\n variant = 'default',\n size = 'md',\n selected,\n disabled = false,\n start,\n onClose,\n closeLabel = 'Remove',\n haptics = false,\n onPress,\n ...props\n },\n ref\n ) => {\n const isToggle = selected !== undefined;\n const isSelected = selected ?? false;\n const closable = onClose !== undefined;\n // Only the whole chip becoming pressable warrants the press animation; a\n // static attribute tag stays a plain View so it does not invite a tap.\n const pressable = onPress !== undefined || isToggle;\n\n const slots = chipVariants({\n variant,\n size,\n selected: isSelected,\n closable,\n disabled,\n });\n\n // Icons and the ✕ inherit this, so they track the surface they sit on\n // instead of every caller hardcoding a hex that is wrong in one theme.\n const tint = useCSSVariable(\n isSelected ? '--color-accent-foreground' : TINT_VAR[variant]\n );\n\n const handlePress = useCallback>(\n (event) => {\n if (haptics) selectionTick();\n onPress?.(event);\n },\n [haptics, onPress]\n );\n\n const handleClose = useCallback(() => {\n if (haptics) selectionTick();\n onClose?.();\n }, [haptics, onClose]);\n\n const labelClasses = slots.label();\n const state = useMemo(\n () => ({ selected: isSelected, disabled, labelClasses }),\n [isSelected, disabled, labelClasses]\n );\n\n const body = (\n <>\n {start}\n {textChildren(children, (text) => (\n \n {text}\n \n ))}\n {closable ? (\n \n \n \n ) : null}\n \n );\n\n const sharedProps = {\n accessibilityRole: 'button' as const,\n // A toggle chip is read out as checked/unchecked; a plain pressable one\n // as a button. A static tag gets neither, so it is not announced as\n // something to press.\n accessibilityState: { disabled, ...(isToggle ? { selected: isSelected } : {}) },\n 'aria-pressed': isToggle ? isSelected : undefined,\n disabled,\n className: slots.root({ className }),\n };\n\n return (\n \n \n {pressable ? (\n \n {body}\n \n ) : (\n \n {body}\n \n )}\n \n \n );\n }\n);\nChipRoot.displayName = 'Chip';\n\n/* -------------------------------------------------------------------------- */\n/* Chip.Label */\n/* -------------------------------------------------------------------------- */\n\nexport interface ChipLabelProps {\n className?: string;\n children: ReactNode;\n}\n\n/**\n * The label, when the chip holds more than a string — an icon beside text. It\n * reads the selected state itself, so composing the two does not mean threading\n * the colour through by hand.\n */\nfunction ChipLabel({ className, children }: ChipLabelProps) {\n const ctx = useContext(ChipContext);\n if (!ctx) {\n throw new Error('Chip.Label must be used within a ');\n }\n return {children};\n}\nChipLabel.displayName = 'Chip.Label';\n\nexport const Chip = Object.assign(ChipRoot, {\n Label: ChipLabel,\n});\n" } ] } diff --git a/apps/docs/public/r/collapsible.json b/apps/docs/public/r/collapsible.json index a59ecdd8f..826eb9398 100644 --- a/apps/docs/public/r/collapsible.json +++ b/apps/docs/public/r/collapsible.json @@ -5,6 +5,7 @@ "registryDependencies": [ "collapse", "icons", + "refs", "text" ], "dependencies": [ @@ -16,7 +17,7 @@ { "path": "ui/collapsible.tsx", "type": "registry:ui", - "content": "/**\n * Collapsible — one section of content that a header shows and hides.\n *\n * ```tsx\n * \n * \n * Delivery options\n * \n * \n * \n * Anything, of any height.\n * \n * \n * ```\n *\n * ## Why this and not a one-item Accordion\n *\n * The two look the same and differ in what happens to the body when it closes.\n * An accordion unmounts it, which is right when a closed section should cost\n * nothing and there is nothing inside worth keeping.\n *\n * This one keeps the body mounted and animates its height, so what is inside\n * survives being closed — a part-filled form still has what was typed into it,\n * a list is still scrolled to where it was, a video is still part-way through.\n * That is the case this component exists for; reach for `Accordion` when\n * sections are mutually exclusive or the body is expensive and rarely opened.\n *\n * ## The body is mounted whether or not it is open\n *\n * A height cannot be animated from `auto`, so the content has to be measured\n * before it can be travelled to, and measuring it means rendering it. There is\n * no lazy version of that: a body that has never been opened has still been\n * rendered once. For a body heavy enough that this matters, an `Accordion` with\n * a single item unmounts it instead.\n *\n * ## Reduced motion\n *\n * With the operating system set to reduce motion the panel snaps between its\n * two states and the chevron turns without travelling. The disclosure still\n * happens — it is the movement that is dropped, not the change.\n */\nimport {\n createContext,\n forwardRef,\n useCallback,\n useContext,\n useEffect,\n useMemo,\n useState,\n type ReactNode,\n} from 'react';\nimport {\n Pressable,\n View,\n type PressableProps,\n type Text as RNText,\n type ViewProps,\n} from 'react-native';\nimport Animated, {\n useAnimatedStyle,\n useReducedMotion,\n useSharedValue,\n withTiming,\n} from 'react-native-reanimated';\nimport { tv } from 'tailwind-variants';\nimport { ChevronDownIcon } from '@/components/ui/icons';\nimport { Collapse } from '@/components/ui/collapse';\nimport { Text, type TextProps, textChildren } from '@/components/ui/text';\n\nexport type CollapsibleVariant = 'default' | 'surface' | 'ghost';\n\n/** Matches the chevron in `Accordion`, so the two read as the same control. */\nconst INDICATOR_DURATION = 200;\n\nconst collapsibleVariants = tv({\n slots: {\n root: 'w-full flex-col',\n trigger: 'flex-row items-center justify-between gap-4 py-4',\n title: 'flex-1 text-base font-medium text-foreground',\n indicator: 'items-center justify-center',\n content: 'pb-4',\n contentText: 'text-sm text-muted-foreground',\n },\n variants: {\n variant: {\n default: { trigger: 'px-1', content: 'px-1' },\n surface: {\n root: 'overflow-hidden rounded-2xl border border-border bg-surface',\n trigger: 'px-4',\n content: 'px-4',\n },\n ghost: { trigger: 'px-0', content: 'px-0' },\n },\n },\n defaultVariants: {\n variant: 'default',\n },\n});\n\ninterface CollapsibleContextValue {\n open: boolean;\n toggle: () => void;\n isDisabled: boolean;\n variant: CollapsibleVariant;\n}\n\nconst CollapsibleContext = createContext(null);\n\nfunction useCollapsible(component: string): CollapsibleContextValue {\n const context = useContext(CollapsibleContext);\n if (!context) throw new Error(`${component} must be used within a `);\n return context;\n}\n\nexport interface CollapsibleProps extends ViewProps {\n className?: string;\n variant?: CollapsibleVariant;\n /** Whether the body is showing, controlled. */\n open?: boolean;\n defaultOpen?: boolean;\n onOpenChange?: (open: boolean) => void;\n /** Stops the trigger opening or closing it, and marks it disabled to a screen reader. */\n isDisabled?: boolean;\n children?: ReactNode;\n}\n\nconst CollapsibleRoot = forwardRef(\n (\n {\n className,\n variant = 'default',\n open,\n defaultOpen = false,\n onOpenChange,\n isDisabled = false,\n children,\n ...props\n },\n ref\n ) => {\n const [internal, setInternal] = useState(defaultOpen);\n const isControlled = open !== undefined;\n const isOpen = isControlled ? open : internal;\n\n const toggle = useCallback(() => {\n const next = !isOpen;\n if (!isControlled) setInternal(next);\n onOpenChange?.(next);\n }, [isOpen, isControlled, onOpenChange]);\n\n const context = useMemo(\n () => ({ open: isOpen, toggle, isDisabled, variant }),\n [isOpen, toggle, isDisabled, variant]\n );\n\n const { root } = collapsibleVariants({ variant });\n\n return (\n \n \n {children}\n \n \n );\n }\n);\nCollapsibleRoot.displayName = 'Collapsible';\n\nexport interface CollapsibleTriggerProps extends Omit {\n className?: string;\n children?: ReactNode;\n}\n\n/** The pressable header row. Bare strings are wrapped in the title style. */\nconst CollapsibleTrigger = forwardRef(\n ({ className, children, onPress, ...props }, ref) => {\n const { open, toggle, isDisabled, variant } = useCollapsible('Collapsible.Trigger');\n const { trigger, title } = collapsibleVariants({ variant });\n\n return (\n {\n onPress?.(event);\n toggle();\n }}\n >\n {textChildren(children, (text) => (\n {text}\n ))}\n \n );\n }\n);\nCollapsibleTrigger.displayName = 'Collapsible.Trigger';\n\nconst CollapsibleTitle = forwardRef(({ className, ...props }, ref) => {\n const { variant } = useCollapsible('Collapsible.Title');\n const { title } = collapsibleVariants({ variant });\n return ;\n});\nCollapsibleTitle.displayName = 'Collapsible.Title';\n\nexport interface CollapsibleIndicatorProps extends ViewProps {\n className?: string;\n /** Replaces the default chevron. */\n children?: ReactNode;\n}\n\n/** Chevron that rotates 180° while the body is open. */\nconst CollapsibleIndicator = forwardRef(\n ({ className, children, ...props }, ref) => {\n const { open, variant } = useCollapsible('Collapsible.Indicator');\n const { indicator } = collapsibleVariants({ variant });\n const reducedMotion = useReducedMotion();\n const progress = useSharedValue(open ? 1 : 0);\n\n useEffect(() => {\n progress.value = reducedMotion\n ? open\n ? 1\n : 0\n : withTiming(open ? 1 : 0, { duration: INDICATOR_DURATION });\n }, [open, reducedMotion, progress]);\n\n const style = useAnimatedStyle(() => ({\n transform: [{ rotate: `${progress.value * 180}deg` }],\n }));\n\n return (\n \n \n {children ?? }\n \n \n );\n }\n);\nCollapsibleIndicator.displayName = 'Collapsible.Indicator';\n\nexport interface CollapsibleContentProps extends ViewProps {\n className?: string;\n children?: ReactNode;\n}\n\n/**\n * The body, opened and closed by animating its own height.\n *\n * It stays mounted while closed, which is the point of the component — see the\n * note at the top of the file. A closed body is hidden from the accessibility\n * tree as well as from the eye, because a screen reader reading out a section\n * that is not on screen is worse than one that skips it.\n */\nconst CollapsibleContent = forwardRef(\n ({ className, children, ...props }, ref) => {\n const { open, variant } = useCollapsible('Collapsible.Content');\n const { content, contentText } = collapsibleVariants({ variant });\n\n return (\n \n \n {textChildren(children, (text) => (\n {text}\n ))}\n \n \n );\n }\n);\nCollapsibleContent.displayName = 'Collapsible.Content';\n\nexport const Collapsible = Object.assign(CollapsibleRoot, {\n Trigger: CollapsibleTrigger,\n Title: CollapsibleTitle,\n Indicator: CollapsibleIndicator,\n Content: CollapsibleContent,\n});\n" + "content": "/**\n * Collapsible — one section of content that a header shows and hides.\n *\n * ```tsx\n * \n * \n * Delivery options\n * \n * \n * \n * Anything, of any height.\n * \n * \n * ```\n *\n * ## Why this and not a one-item Accordion\n *\n * The two look the same and differ in what happens to the body when it closes.\n * An accordion unmounts it, which is right when a closed section should cost\n * nothing and there is nothing inside worth keeping.\n *\n * This one keeps the body mounted and animates its height, so what is inside\n * survives being closed — a part-filled form still has what was typed into it,\n * a list is still scrolled to where it was, a video is still part-way through.\n * That is the case this component exists for; reach for `Accordion` when\n * sections are mutually exclusive or the body is expensive and rarely opened.\n *\n * ## The body is mounted whether or not it is open\n *\n * A height cannot be animated from `auto`, so the content has to be measured\n * before it can be travelled to, and measuring it means rendering it. There is\n * no lazy version of that: a body that has never been opened has still been\n * rendered once. For a body heavy enough that this matters, an `Accordion` with\n * a single item unmounts it instead.\n *\n * ## Reduced motion\n *\n * With the operating system set to reduce motion the panel snaps between its\n * two states and the chevron turns without travelling. The disclosure still\n * happens — it is the movement that is dropped, not the change.\n */\nimport {\n createContext,\n forwardRef,\n useCallback,\n useContext,\n useEffect,\n useMemo,\n useState,\n type ReactNode,\n} from 'react';\nimport {\n Pressable,\n View,\n type PressableProps,\n type Text as RNText,\n type ViewProps,\n} from 'react-native';\nimport Animated, {\n useAnimatedStyle,\n useReducedMotion,\n useSharedValue,\n withTiming,\n} from 'react-native-reanimated';\nimport { tv } from 'tailwind-variants';\nimport { ChevronDownIcon } from '@/components/ui/icons';\nimport { Collapse } from '@/components/ui/collapse';\nimport { Text, type TextProps, textChildren } from '@/components/ui/text';\nimport type { TextRef, ViewRef } from '@/components/ui/refs';\n\nexport type CollapsibleVariant = 'default' | 'surface' | 'ghost';\n\n/** Matches the chevron in `Accordion`, so the two read as the same control. */\nconst INDICATOR_DURATION = 200;\n\nconst collapsibleVariants = tv({\n slots: {\n root: 'w-full flex-col',\n trigger: 'flex-row items-center justify-between gap-4 py-4',\n title: 'flex-1 text-base font-medium text-foreground',\n indicator: 'items-center justify-center',\n content: 'pb-4',\n contentText: 'text-sm text-muted-foreground',\n },\n variants: {\n variant: {\n default: { trigger: 'px-1', content: 'px-1' },\n surface: {\n root: 'overflow-hidden rounded-2xl border border-border bg-surface',\n trigger: 'px-4',\n content: 'px-4',\n },\n ghost: { trigger: 'px-0', content: 'px-0' },\n },\n },\n defaultVariants: {\n variant: 'default',\n },\n});\n\ninterface CollapsibleContextValue {\n open: boolean;\n toggle: () => void;\n isDisabled: boolean;\n variant: CollapsibleVariant;\n}\n\nconst CollapsibleContext = createContext(null);\n\nfunction useCollapsible(component: string): CollapsibleContextValue {\n const context = useContext(CollapsibleContext);\n if (!context) throw new Error(`${component} must be used within a `);\n return context;\n}\n\nexport interface CollapsibleProps extends ViewProps {\n className?: string;\n variant?: CollapsibleVariant;\n /** Whether the body is showing, controlled. */\n open?: boolean;\n defaultOpen?: boolean;\n onOpenChange?: (open: boolean) => void;\n /** Stops the trigger opening or closing it, and marks it disabled to a screen reader. */\n isDisabled?: boolean;\n children?: ReactNode;\n}\n\nconst CollapsibleRoot = forwardRef(\n (\n {\n className,\n variant = 'default',\n open,\n defaultOpen = false,\n onOpenChange,\n isDisabled = false,\n children,\n ...props\n },\n ref\n ) => {\n const [internal, setInternal] = useState(defaultOpen);\n const isControlled = open !== undefined;\n const isOpen = isControlled ? open : internal;\n\n const toggle = useCallback(() => {\n const next = !isOpen;\n if (!isControlled) setInternal(next);\n onOpenChange?.(next);\n }, [isOpen, isControlled, onOpenChange]);\n\n const context = useMemo(\n () => ({ open: isOpen, toggle, isDisabled, variant }),\n [isOpen, toggle, isDisabled, variant]\n );\n\n const { root } = collapsibleVariants({ variant });\n\n return (\n \n \n {children}\n \n \n );\n }\n);\nCollapsibleRoot.displayName = 'Collapsible';\n\nexport interface CollapsibleTriggerProps extends Omit {\n className?: string;\n children?: ReactNode;\n}\n\n/** The pressable header row. Bare strings are wrapped in the title style. */\nconst CollapsibleTrigger = forwardRef(\n ({ className, children, onPress, ...props }, ref) => {\n const { open, toggle, isDisabled, variant } = useCollapsible('Collapsible.Trigger');\n const { trigger, title } = collapsibleVariants({ variant });\n\n return (\n {\n onPress?.(event);\n toggle();\n }}\n >\n {textChildren(children, (text) => (\n {text}\n ))}\n \n );\n }\n);\nCollapsibleTrigger.displayName = 'Collapsible.Trigger';\n\nconst CollapsibleTitle = forwardRef(({ className, ...props }, ref) => {\n const { variant } = useCollapsible('Collapsible.Title');\n const { title } = collapsibleVariants({ variant });\n return ;\n});\nCollapsibleTitle.displayName = 'Collapsible.Title';\n\nexport interface CollapsibleIndicatorProps extends ViewProps {\n className?: string;\n /** Replaces the default chevron. */\n children?: ReactNode;\n}\n\n/** Chevron that rotates 180° while the body is open. */\nconst CollapsibleIndicator = forwardRef(\n ({ className, children, ...props }, ref) => {\n const { open, variant } = useCollapsible('Collapsible.Indicator');\n const { indicator } = collapsibleVariants({ variant });\n const reducedMotion = useReducedMotion();\n const progress = useSharedValue(open ? 1 : 0);\n\n useEffect(() => {\n progress.value = reducedMotion\n ? open\n ? 1\n : 0\n : withTiming(open ? 1 : 0, { duration: INDICATOR_DURATION });\n }, [open, reducedMotion, progress]);\n\n const style = useAnimatedStyle(() => ({\n transform: [{ rotate: `${progress.value * 180}deg` }],\n }));\n\n return (\n \n \n {children ?? }\n \n \n );\n }\n);\nCollapsibleIndicator.displayName = 'Collapsible.Indicator';\n\nexport interface CollapsibleContentProps extends ViewProps {\n className?: string;\n children?: ReactNode;\n}\n\n/**\n * The body, opened and closed by animating its own height.\n *\n * It stays mounted while closed, which is the point of the component — see the\n * note at the top of the file. A closed body is hidden from the accessibility\n * tree as well as from the eye, because a screen reader reading out a section\n * that is not on screen is worse than one that skips it.\n */\nconst CollapsibleContent = forwardRef(\n ({ className, children, ...props }, ref) => {\n const { open, variant } = useCollapsible('Collapsible.Content');\n const { content, contentText } = collapsibleVariants({ variant });\n\n return (\n \n \n {textChildren(children, (text) => (\n {text}\n ))}\n \n \n );\n }\n);\nCollapsibleContent.displayName = 'Collapsible.Content';\n\nexport const Collapsible = Object.assign(CollapsibleRoot, {\n Trigger: CollapsibleTrigger,\n Title: CollapsibleTitle,\n Indicator: CollapsibleIndicator,\n Content: CollapsibleContent,\n});\n" } ] } diff --git a/apps/docs/public/r/color-picker.json b/apps/docs/public/r/color-picker.json index 5d3c51184..e7e38d968 100644 --- a/apps/docs/public/r/color-picker.json +++ b/apps/docs/public/r/color-picker.json @@ -7,6 +7,7 @@ "color", "haptics", "popover", + "refs", "text", "use-direction" ], @@ -29,7 +30,7 @@ { "path": "ui/color-picker.tsx", "type": "registry:ui", - "content": "/**\n * ColorPicker — a colour chosen by dragging, not by typing.\n *\n * The controls are the colour model made visible: a square where saturation\n * runs across and brightness runs up, under a hue picked on its own scale.\n * That is why the component stores hue, saturation, value and alpha rather\n * than the string it hands back — a fully black colour is `#000` whatever hue\n * and saturation produced it, so a picker that stored the output would lose\n * the thumb the moment you dragged into a corner and could not put it back.\n *\n * Nothing about a drag crosses to JavaScript. The four channels are shared\n * values; the hue behind the square, both thumb fills and the preview swatch\n * are `backgroundColor`s computed from them on the UI thread, and the\n * translucency ramp on the alpha track is a gradient used as a mask over a\n * solid fill rather than a gradient whose colours have to be re-declared from\n * React on every frame. The picked colour bridges back on change and again on\n * release, the same as any other input here.\n *\n * ```tsx\n * \n * \n * \n * \n * \n * ```\n *\n * Composition is the API: a picker is whichever of the parts you put in it, so\n * a control with no alpha is one that has no `ColorPicker.Alpha`, not one with\n * a prop turned off.\n *\n * ## Folding it away\n *\n * A picker is a page's worth of controls in service of one value, and that\n * value is read far more often than it is changed — so `presentation` puts the\n * controls behind the row that reads it out:\n *\n * ```tsx\n * \n * \n * \n * \n * \n * \n * \n * \n * \n * ```\n *\n * `Content` re-provides the picker's context around what it holds, because the\n * panel is portalled above the rest of the screen and is therefore no longer\n * below the picker in the tree by the time the parts inside it go looking.\n *\n * Works controlled (`value` + `onValueChange`) or uncontrolled (`defaultValue`).\n */\nimport {\n createContext,\n forwardRef,\n useCallback,\n useContext,\n useEffect,\n useId,\n useMemo,\n useRef,\n useState,\n type ReactElement,\n type ReactNode,\n} from 'react';\nimport {\n Pressable,\n StyleSheet,\n View,\n type AccessibilityActionEvent,\n type LayoutChangeEvent,\n type ViewStyle,\n} from 'react-native';\nimport MaskedView from '@react-native-masked-view/masked-view';\nimport { LinearGradient } from 'expo-linear-gradient';\nimport { Gesture, GestureDetector } from 'react-native-gesture-handler';\nimport Animated, {\n runOnJS,\n useAnimatedReaction,\n useAnimatedStyle,\n useSharedValue,\n withTiming,\n type SharedValue,\n} from 'react-native-reanimated';\nimport Svg, {\n Circle,\n Defs,\n Path,\n Pattern,\n RadialGradient,\n Rect,\n Stop,\n} from 'react-native-svg';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport { useCSSVariable } from 'uniwind';\nimport { useDirectionSign } from '@/hooks/use-direction';\nimport { Text } from '@/components/ui/text';\nimport {\n formatColor,\n hsvToCss,\n hsvToHex,\n parseColor,\n type ColorFormat,\n type HsvaColor,\n} from '@/lib/color';\nimport { cn } from '@/lib/cn';\nimport { selectionTick } from '@/lib/haptics';\nimport { Popover, type PopoverContentProps } from '@/components/ui/popover';\nimport { enabledColorPickerAction } from '@/components/ui/color-picker-accessibility';\n\n/** Settles a thumb that was moved by something other than a finger. */\nconst TIMING = { duration: 140 } as const;\n\n/**\n * The hue scale, drawn once as a gradient rather than sampled per pixel. Six\n * stops plus the wrap back to red — fewer would bend the ramp, more would not\n * change it, because hue is piecewise linear in RGB and these are the corners.\n */\nconst HUE_STOPS: readonly [string, string, ...string[]] = [\n '#ff0000',\n '#ffff00',\n '#00ff00',\n '#00ffff',\n '#0000ff',\n '#ff00ff',\n '#ff0000',\n];\n\n/** The same ramp for a right-to-left picker, where hue runs the other way. */\nconst HUE_STOPS_FLIPPED = [...HUE_STOPS].reverse() as unknown as readonly [\n string,\n string,\n ...string[],\n];\n\n/** How far one screen-reader increment moves a channel. */\nconst NUDGE = 0.05;\n/** How far one screen-reader increment moves the hue, in degrees. */\nconst HUE_NUDGE = 10;\n\nconst colorPickerVariants = tv({\n slots: {\n root: 'w-full gap-3',\n area: 'overflow-hidden rounded-2xl border border-border',\n wheel: 'overflow-hidden rounded-full border border-border',\n // Both thumbs are a white ring around a fill of the colour under them, so\n // the ring reads against the dark end of the square as well as the light.\n // Two points, not three: the ring is there to separate the fill from what\n // is behind it, and any more of it starts hiding the colour it is showing.\n thumb: 'absolute start-0 top-0 rounded-full border-2 border-white shadow-md',\n thumbFill: 'flex-1 rounded-full',\n track: 'w-full overflow-hidden rounded-full border border-border',\n swatches: 'flex-row flex-wrap items-center gap-2',\n swatch: 'items-center justify-center rounded-full',\n swatchRing: 'absolute rounded-full border-2 border-foreground',\n preview: 'flex-row items-center gap-3',\n previewSwatch: 'overflow-hidden rounded-full border border-border',\n previewValue: 'text-sm font-medium text-foreground',\n // The header strip: a name on the leading edge, the value and a swatch on\n // the trailing one.\n field: 'w-full flex-row items-center gap-3 rounded-2xl bg-surface px-4 py-3',\n fieldLabel: 'flex-1 text-base font-medium text-foreground',\n fieldValue: 'text-base font-normal text-muted-foreground',\n fieldSwatch: 'overflow-hidden rounded-full border border-border',\n // The readout above a track — the number on one side, what it names on the\n // other.\n channel: 'w-full flex-row items-center justify-between gap-3',\n channelValue: 'text-base font-normal text-muted-foreground',\n channelLabel: 'text-base font-medium text-foreground',\n },\n variants: {\n size: {\n sm: {},\n md: {},\n lg: {},\n },\n disabled: {\n true: { root: 'opacity-50' },\n },\n },\n defaultVariants: {\n size: 'md',\n },\n});\n\ntype ColorPickerVariantProps = VariantProps;\nexport type ColorPickerSize = NonNullable;\n\n/** Height of the saturation/brightness square, per size. */\nconst AREA_HEIGHT: Record = { sm: 140, md: 180, lg: 220 };\n/**\n * Height of a channel track, per size. Tall enough that the knob sits *in* the\n * track rather than on top of a hairline — a slider whose thumb overhangs a\n * thin rule reads as a control laid over a decoration.\n */\nconst TRACK_HEIGHT: Record = { sm: 16, md: 20, lg: 24 };\n/** Thumb diameter on a channel track — wider than the track, so it reads as a knob. */\nconst TRACK_THUMB: Record = { sm: 22, md: 26, lg: 30 };\n/** Thumb diameter on the square. */\nconst AREA_THUMB: Record = { sm: 20, md: 24, lg: 28 };\n/** Preset swatch diameter. */\nconst SWATCH: Record = { sm: 26, md: 30, lg: 34 };\n/** The swatch beside the value in `Preview`. */\nconst PREVIEW_SWATCH: Record = { sm: 28, md: 36, lg: 44 };\n/** The smaller swatch on the end of the `Field` strip. */\nconst FIELD_SWATCH: Record = { sm: 22, md: 28, lg: 34 };\n/** Diameter of the wheel, per size. */\nconst WHEEL_SIZE: Record = { sm: 180, md: 240, lg: 300 };\n\ninterface ColorPickerContextValue {\n hue: SharedValue;\n saturation: SharedValue;\n brightness: SharedValue;\n opacity: SharedValue;\n /** Called from a worklet with the channels that produced the change. */\n emit: (h: number, s: number, v: number, a: number, commit: boolean) => void;\n format: ColorFormat;\n disabled: boolean;\n haptics: boolean;\n size: ColorPickerSize;\n}\n\nconst ColorPickerContext = createContext(null);\n\nfunction useColorPicker(part: string) {\n const ctx = useContext(ColorPickerContext);\n if (!ctx) throw new Error(`${part} must be used inside .`);\n return ctx;\n}\n\n/** Where a picker starts when it is handed a value it cannot read. */\nconst FALLBACK: HsvaColor = { h: 0, s: 1, v: 1, a: 1 };\n\n/** How the controls get onto the screen. */\nexport type ColorPickerPresentation = 'inline' | 'popover' | 'bottom-sheet';\n\n/**\n * Floor for the panel's width when it takes the trigger's. A swatch row is a\n * narrow trigger, and a square you drag on is not a control that survives being\n * squeezed to match one.\n */\nconst CONTENT_MIN_WIDTH = 268;\n\nexport interface ColorPickerProps extends Omit {\n className?: string;\n /** Controlled colour. Leave unset and pass `defaultValue` to run uncontrolled. */\n value?: string;\n /** Starting colour when uncontrolled. */\n defaultValue?: string;\n /**\n * Fires on every frame of a drag — cheap updates only. The string is written\n * in `format`.\n */\n onValueChange?: (color: string) => void;\n /** Fires once when a drag ends. The place for expensive side effects. */\n onValueCommit?: (color: string) => void;\n /**\n * How the colour is written on the way out. `hex` gains an `#rrggbbaa` alpha\n * pair, and the other two switch to their `a` forms, only when the colour is\n * actually translucent.\n */\n format?: ColorFormat;\n disabled?: boolean;\n /**\n * A tick when a drag ends and when a preset is picked. Off by default — needs\n * the optional `expo-haptics`, and is silent without it. There is no tick\n * during a drag: a colour has no steps to cross, so a tick could only be a\n * buzz proportional to speed.\n */\n haptics?: boolean;\n /**\n * How the controls get onto the screen.\n *\n * `inline` stacks them where they are written, and is the default. The other\n * two put them behind a `ColorPicker.Trigger` and draw them in a\n * `ColorPicker.Content` — which is the arrangement a colour usually wants,\n * since a picker is a page's worth of controls in service of one value that\n * is looked at far more often than it is changed.\n */\n presentation?: ColorPickerPresentation;\n /** Controlled open state of the panel. Ignored by `inline`. */\n open?: boolean;\n onOpenChange?: (open: boolean) => void;\n /** The parts, in the order they should stack. */\n children: ReactNode;\n}\n\nconst ColorPickerRoot = forwardRef(\n (\n {\n className,\n value: valueProp,\n defaultValue = '#ff0000',\n onValueChange,\n onValueCommit,\n format = 'hex',\n disabled = false,\n haptics = false,\n size = 'md',\n presentation = 'inline',\n open,\n onOpenChange,\n children,\n },\n ref\n ) => {\n const slots = colorPickerVariants({ size, disabled });\n\n // Parsed once. After this the channels are the truth and the prop is only\n // consulted when it changes to something we did not ourselves emit.\n const initial = useMemo(\n () => parseColor(valueProp ?? defaultValue) ?? FALLBACK,\n // eslint-disable-next-line react-hooks/exhaustive-deps\n []\n );\n\n const hue = useSharedValue(initial.h);\n const saturation = useSharedValue(initial.s);\n const brightness = useSharedValue(initial.v);\n const opacity = useSharedValue(initial.a);\n\n const changeRef = useRef(onValueChange);\n changeRef.current = onValueChange;\n const commitRef = useRef(onValueCommit);\n commitRef.current = onValueCommit;\n const formatRef = useRef(format);\n formatRef.current = format;\n\n /*\n * The last string this picker produced. A controlled parent hands it\n * straight back, and re-parsing it into the channels mid-drag would fight\n * the finger: the round trip is lossy at the edges of the square, so the\n * thumb would be pulled a pixel back on every frame.\n */\n const lastEmitted = useRef(null);\n\n const emit = useCallback((h: number, s: number, v: number, a: number, commit: boolean) => {\n const next = formatColor({ h, s, v, a }, formatRef.current);\n lastEmitted.current = next;\n changeRef.current?.(next);\n if (commit) commitRef.current?.(next);\n }, []);\n\n // Keep the channels in step with a controlled value changed elsewhere. Set\n // rather than animated: an external change is not a gesture, and springing\n // the hue from 350° to 10° would run the long way round the wheel.\n useEffect(() => {\n if (valueProp === undefined) return;\n if (valueProp === lastEmitted.current) return;\n const parsed = parseColor(valueProp);\n if (!parsed) return;\n hue.value = parsed.h;\n saturation.value = parsed.s;\n brightness.value = parsed.v;\n opacity.value = parsed.a;\n }, [valueProp, hue, saturation, brightness, opacity]);\n\n const context = useMemo(\n () => ({\n hue,\n saturation,\n brightness,\n opacity,\n emit,\n format,\n disabled,\n haptics,\n size: size ?? 'md',\n }),\n [hue, saturation, brightness, opacity, emit, format, disabled, haptics, size]\n );\n\n if (presentation !== 'inline') {\n return (\n \n \n {children}\n \n \n );\n }\n\n return (\n \n \n {children}\n \n \n );\n }\n);\n\nColorPickerRoot.displayName = 'ColorPicker';\n\n/* ------------------------------------------------------------------ *\n * Trigger and Content — the picker folded away behind the value.\n * ------------------------------------------------------------------ */\n\nexport interface ColorPickerTriggerProps {\n /** One element, cloned with an `onPress` that opens the panel. */\n children: ReactElement<{ onPress?: (...args: unknown[]) => void }>;\n}\n\n/**\n * What you press to open the picker. `ColorPicker.Field` is the obvious child —\n * it already reads out the colour it would let you change — but anything that\n * takes an `onPress` will do.\n */\nfunction ColorPickerTrigger({ children }: ColorPickerTriggerProps) {\n useColorPicker('ColorPicker.Trigger');\n return {children};\n}\n\nColorPickerTrigger.displayName = 'ColorPicker.Trigger';\n\nexport interface ColorPickerContentProps extends PopoverContentProps {}\n\n/**\n * The panel the controls are drawn in.\n *\n * It re-provides the picker's context around its children, and has to: the\n * panel is rendered through a portal, above everything else on the screen, so\n * by the time the parts inside it look for the picker they are no longer\n * anywhere below it in the tree. Without this the first `ColorPicker.Area`\n * inside a popover would throw.\n *\n * Defaults to the trigger's width, floored, so a panel opened from a labelled\n * strip lines up under it rather than announcing itself as a different object.\n */\nfunction ColorPickerContent({\n className,\n width = 'trigger',\n minWidth = CONTENT_MIN_WIDTH,\n children,\n ...props\n}: ColorPickerContentProps) {\n const ctx = useColorPicker('ColorPicker.Content');\n\n return (\n \n {children}\n \n );\n}\n\nColorPickerContent.displayName = 'ColorPicker.Content';\n\n/* ------------------------------------------------------------------ *\n * The checkerboard behind anything translucent.\n * ------------------------------------------------------------------ */\n\n/**\n * The \"nothing here\" backing. A tiled SVG pattern rather than a grid of views:\n * a 340-point track at eight-point squares is 170 squares, and every one of\n * them would be a view laid out by Yoga on the frame the picker mounts.\n */\nfunction Checkerboard({ square = 7 }: { square?: number }) {\n const id = useId().replace(/[^a-zA-Z0-9]/g, '');\n // The pattern is themed rather than the usual grey-on-white: a checkerboard\n // is a statement that there is nothing there, and \"nothing\" is the page.\n const light = String(useCSSVariable('--color-background') ?? '#ffffff');\n const dark = String(useCSSVariable('--color-muted') ?? '#e5e5e5');\n\n return (\n \n \n \n \n \n \n \n \n \n \n );\n}\n\n/* ------------------------------------------------------------------ *\n * Area — saturation across, brightness up.\n * ------------------------------------------------------------------ */\n\nexport interface ColorPickerAreaProps {\n className?: string;\n /** Height of the square in points. Defaults to the picker's size. */\n height?: number;\n /** Extra classes for the draggable thumb. */\n thumbClassName?: string;\n}\n\nconst ColorPickerArea = forwardRef(\n ({ className, height, thumbClassName }, ref) => {\n const ctx = useColorPicker('ColorPicker.Area');\n const slots = colorPickerVariants({ size: ctx.size });\n const sign = useDirectionSign();\n\n const boxHeight = height ?? AREA_HEIGHT[ctx.size];\n const thumbSize = AREA_THUMB[ctx.size];\n const width = useSharedValue(0);\n\n /*\n * The screen reader's copy of the value. Updated when a drag ends rather\n * than while it runs: a `now` that re-rendered on every frame would be a\n * hundred announcements for one gesture, and nobody is listening to the\n * middle of a drag.\n */\n const [announced, setAnnounced] = useState(() => ({\n s: ctx.saturation.value,\n v: ctx.brightness.value,\n }));\n\n const settle = useCallback((s: number, v: number) => {\n setAnnounced({ s, v });\n }, []);\n\n const apply = (x: number, y: number, commit: boolean) => {\n 'worklet';\n const w = Math.max(width.value, 1);\n // `x` is measured from the physical left edge either way, so a\n // right-to-left picker counts saturation back from the far side.\n const along = sign === 1 ? x : w - x;\n const s = Math.min(Math.max(along / w, 0), 1);\n const v = 1 - Math.min(Math.max(y / boxHeight, 0), 1);\n ctx.saturation.value = s;\n ctx.brightness.value = v;\n runOnJS(ctx.emit)(ctx.hue.value, s, v, ctx.opacity.value, commit);\n if (commit) {\n runOnJS(settle)(s, v);\n if (ctx.haptics) runOnJS(selectionTick)();\n }\n };\n\n // Both axes belong to the square, so it takes the gesture on touch-down\n // rather than after a threshold — a pad that needed ten points of travel\n // before it responded would feel broken.\n const pan = Gesture.Pan()\n .enabled(!ctx.disabled)\n .minDistance(0)\n .shouldCancelWhenOutside(false)\n .onBegin((event) => apply(event.x, event.y, false))\n .onUpdate((event) => apply(event.x, event.y, false))\n .onFinalize((event) => apply(event.x, event.y, true));\n\n const hueStyle = useAnimatedStyle(() => ({\n backgroundColor: hsvToCss(ctx.hue.value, 1, 1, 1),\n }));\n\n const thumbStyle = useAnimatedStyle(() => {\n // A transform is not laid out, so Yoga does not mirror it — the thumb\n // has to travel the other way itself.\n const along = ctx.saturation.value * width.value - thumbSize / 2;\n return {\n transform: [\n { translateX: along * sign },\n { translateY: (1 - ctx.brightness.value) * boxHeight - thumbSize / 2 },\n ],\n };\n });\n\n const thumbFillStyle = useAnimatedStyle(() => ({\n backgroundColor: hsvToCss(\n ctx.hue.value,\n ctx.saturation.value,\n ctx.brightness.value,\n 1\n ),\n }));\n\n const nudge = (channel: 'saturation' | 'brightness', dir: 1 | -1) => {\n const next = Math.min(Math.max(ctx[channel].value + dir * NUDGE, 0), 1);\n ctx[channel].value = withTiming(next, TIMING);\n const s = channel === 'saturation' ? next : ctx.saturation.value;\n const v = channel === 'brightness' ? next : ctx.brightness.value;\n settle(s, v);\n ctx.emit(ctx.hue.value, s, v, ctx.opacity.value, true);\n };\n\n const onAccessibilityAction = (event: AccessibilityActionEvent) => {\n const action = enabledColorPickerAction(\n event.nativeEvent.actionName,\n ctx.disabled\n );\n if (action === 'increment') nudge('saturation', 1);\n else if (action === 'decrement') nudge('saturation', -1);\n else if (action === 'brighter') nudge('brightness', 1);\n else if (action === 'darker') nudge('brightness', -1);\n };\n\n return (\n \n {/* The outer box is not clipped, so the thumb can sit centred on a\n colour at the very edge of the square instead of being pushed\n inside it. Only the gradients below are clipped to the radius. */}\n {\n width.value = event.nativeEvent.layout.width;\n }}\n accessible\n accessibilityRole=\"adjustable\"\n accessibilityLabel=\"Saturation and brightness\"\n accessibilityState={{ disabled: ctx.disabled }}\n accessibilityValue={{\n min: 0,\n max: 100,\n now: Math.round(announced.s * 100),\n text: `${Math.round(announced.s * 100)}% saturation, ${Math.round(\n announced.v * 100\n )}% brightness`,\n }}\n accessibilityActions={[\n { name: 'increment' },\n { name: 'decrement' },\n { name: 'brighter', label: 'Brighter' },\n { name: 'darker', label: 'Darker' },\n ]}\n onAccessibilityAction={onAccessibilityAction}\n >\n \n \n \n \n \n\n \n \n \n \n \n );\n }\n);\n\nColorPickerArea.displayName = 'ColorPicker.Area';\n\n/* ------------------------------------------------------------------ *\n * The two channel tracks share everything but what is drawn in them.\n * ------------------------------------------------------------------ */\n\ninterface ChannelTrackProps {\n /** What fills the track — a hue ramp, or a masked translucency ramp. */\n children: ReactNode;\n gesture: ReturnType;\n thumbStyle: ReturnType>;\n thumbFillStyle: ReturnType>;\n onLayout: (event: LayoutChangeEvent) => void;\n size: ColorPickerSize;\n className?: string;\n thumbClassName?: string;\n label: string;\n disabled: boolean;\n accessibilityValue: { min: number; max: number; now: number; text: string };\n onAccessibilityAction: (event: AccessibilityActionEvent) => void;\n innerRef: React.ForwardedRef;\n}\n\nfunction ChannelTrack({\n children,\n gesture,\n thumbStyle,\n thumbFillStyle,\n onLayout,\n size,\n className,\n thumbClassName,\n label,\n disabled,\n accessibilityValue,\n onAccessibilityAction,\n innerRef,\n}: ChannelTrackProps) {\n const slots = colorPickerVariants({ size });\n const trackHeight = TRACK_HEIGHT[size];\n const thumbSize = TRACK_THUMB[size];\n\n return (\n \n {/* The row is as tall as the thumb so an overhanging knob has somewhere\n to be drawn whole; the track inside it is the shorter, clipped part. */}\n \n \n {children}\n \n \n \n \n \n \n );\n}\n\n/**\n * Maps a touch anywhere on a track to a fraction of it, accounting for the\n * thumb's own width: the thumb travels between the two edges rather than off\n * them, so the usable run is the track minus one thumb.\n */\nfunction trackFraction(x: number, width: number, thumbSize: number, sign: number) {\n 'worklet';\n const along = sign === 1 ? x : width - x;\n const travel = Math.max(width - thumbSize, 1);\n return Math.min(Math.max((along - thumbSize / 2) / travel, 0), 1);\n}\n\n/* ------------------------------------------------------------------ *\n * Hue.\n * ------------------------------------------------------------------ */\n\nexport interface ColorPickerHueProps {\n className?: string;\n /** Extra classes for the draggable thumb. */\n thumbClassName?: string;\n}\n\nconst ColorPickerHue = forwardRef(\n ({ className, thumbClassName }, ref) => {\n const ctx = useColorPicker('ColorPicker.Hue');\n const sign = useDirectionSign();\n const thumbSize = TRACK_THUMB[ctx.size];\n const width = useSharedValue(0);\n const [announced, setAnnounced] = useState(() => Math.round(ctx.hue.value));\n\n const settle = useCallback((h: number) => setAnnounced(Math.round(h)), []);\n\n const apply = (x: number, commit: boolean) => {\n 'worklet';\n const h = trackFraction(x, width.value, thumbSize, sign) * 360;\n ctx.hue.value = h;\n runOnJS(ctx.emit)(\n h,\n ctx.saturation.value,\n ctx.brightness.value,\n ctx.opacity.value,\n commit\n );\n if (commit) {\n runOnJS(settle)(h);\n if (ctx.haptics) runOnJS(selectionTick)();\n }\n };\n\n /*\n * A thin horizontal control inside a scroller: the pan waits for six points\n * of sideways travel so a vertical flick starting on the track still\n * scrolls the page, and the tap handles the case where there was no travel\n * at all.\n */\n const pan = Gesture.Pan()\n .enabled(!ctx.disabled)\n .activeOffsetX([-6, 6])\n .shouldCancelWhenOutside(false)\n .onUpdate((event) => apply(event.x, false))\n .onFinalize((event) => apply(event.x, true));\n\n const tap = Gesture.Tap()\n .enabled(!ctx.disabled)\n .maxDuration(250)\n .onEnd((event) => apply(event.x, true));\n\n const gesture = Gesture.Race(pan, tap);\n\n const thumbStyle = useAnimatedStyle(() => {\n const travel = Math.max(width.value - thumbSize, 0);\n return { transform: [{ translateX: (ctx.hue.value / 360) * travel * sign }] };\n });\n\n const thumbFillStyle = useAnimatedStyle(() => ({\n backgroundColor: hsvToCss(ctx.hue.value, 1, 1, 1),\n }));\n\n const nudge = (dir: 1 | -1) => {\n const next = Math.min(Math.max(ctx.hue.value + dir * HUE_NUDGE, 0), 360);\n ctx.hue.value = withTiming(next, TIMING);\n settle(next);\n ctx.emit(next, ctx.saturation.value, ctx.brightness.value, ctx.opacity.value, true);\n };\n\n return (\n {\n width.value = event.nativeEvent.layout.width;\n }}\n size={ctx.size}\n className={className}\n thumbClassName={thumbClassName}\n label=\"Hue\"\n disabled={ctx.disabled}\n accessibilityValue={{ min: 0, max: 360, now: announced, text: `${announced}°` }}\n onAccessibilityAction={(event) => {\n const action = enabledColorPickerAction(\n event.nativeEvent.actionName,\n ctx.disabled\n );\n if (action === 'increment') nudge(1);\n else if (action === 'decrement') nudge(-1);\n }}\n >\n \n \n );\n }\n);\n\nColorPickerHue.displayName = 'ColorPicker.Hue';\n\n/* ------------------------------------------------------------------ *\n * Alpha.\n * ------------------------------------------------------------------ */\n\nexport interface ColorPickerAlphaProps {\n className?: string;\n /** Extra classes for the draggable thumb. */\n thumbClassName?: string;\n}\n\nconst ColorPickerAlpha = forwardRef(\n ({ className, thumbClassName }, ref) => {\n const ctx = useColorPicker('ColorPicker.Alpha');\n const sign = useDirectionSign();\n const thumbSize = TRACK_THUMB[ctx.size];\n const width = useSharedValue(0);\n const [announced, setAnnounced] = useState(() => Math.round(ctx.opacity.value * 100));\n\n const settle = useCallback((a: number) => setAnnounced(Math.round(a * 100)), []);\n\n const apply = (x: number, commit: boolean) => {\n 'worklet';\n const a = trackFraction(x, width.value, thumbSize, sign);\n ctx.opacity.value = a;\n runOnJS(ctx.emit)(\n ctx.hue.value,\n ctx.saturation.value,\n ctx.brightness.value,\n a,\n commit\n );\n if (commit) {\n runOnJS(settle)(a);\n if (ctx.haptics) runOnJS(selectionTick)();\n }\n };\n\n const pan = Gesture.Pan()\n .enabled(!ctx.disabled)\n .activeOffsetX([-6, 6])\n .shouldCancelWhenOutside(false)\n .onUpdate((event) => apply(event.x, false))\n .onFinalize((event) => apply(event.x, true));\n\n const tap = Gesture.Tap()\n .enabled(!ctx.disabled)\n .maxDuration(250)\n .onEnd((event) => apply(event.x, true));\n\n const gesture = Gesture.Race(pan, tap);\n\n const thumbStyle = useAnimatedStyle(() => {\n const travel = Math.max(width.value - thumbSize, 0);\n return { transform: [{ translateX: ctx.opacity.value * travel * sign }] };\n });\n\n const solidStyle = useAnimatedStyle(() => ({\n backgroundColor: hsvToCss(\n ctx.hue.value,\n ctx.saturation.value,\n ctx.brightness.value,\n 1\n ),\n }));\n\n const thumbFillStyle = useAnimatedStyle(() => ({\n backgroundColor: hsvToCss(\n ctx.hue.value,\n ctx.saturation.value,\n ctx.brightness.value,\n ctx.opacity.value\n ),\n }));\n\n const nudge = (dir: 1 | -1) => {\n const next = Math.min(Math.max(ctx.opacity.value + dir * NUDGE, 0), 1);\n ctx.opacity.value = withTiming(next, TIMING);\n settle(next);\n ctx.emit(ctx.hue.value, ctx.saturation.value, ctx.brightness.value, next, true);\n };\n\n return (\n {\n width.value = event.nativeEvent.layout.width;\n }}\n size={ctx.size}\n className={className}\n thumbClassName={thumbClassName}\n label=\"Opacity\"\n disabled={ctx.disabled}\n accessibilityValue={{\n min: 0,\n max: 100,\n now: announced,\n text: `${announced}%`,\n }}\n onAccessibilityAction={(event) => {\n const action = enabledColorPickerAction(\n event.nativeEvent.actionName,\n ctx.disabled\n );\n if (action === 'increment') nudge(1);\n else if (action === 'decrement') nudge(-1);\n }}\n >\n \n {/*\n * The ramp is a gradient used as a *mask* over a solid fill of the\n * current colour, not a gradient between a transparent and an opaque\n * copy of it. A gradient's colours are props, so the second form would\n * have to be re-declared from React on every frame of a drag on the\n * square above; the mask never changes, and only the fill under it does\n * — which is a `backgroundColor` the UI thread can write itself.\n */}\n \n }\n >\n \n \n \n );\n }\n);\n\nColorPickerAlpha.displayName = 'ColorPicker.Alpha';\n\n/* ------------------------------------------------------------------ *\n * Preview.\n * ------------------------------------------------------------------ */\n\nexport interface ColorPickerPreviewProps {\n className?: string;\n /**\n * Print the colour beside the swatch, in the picker's `format`. The string\n * is built on the UI thread and only crosses to JavaScript when it differs\n * from the last one, so a drag that is not changing the rounded value costs\n * nothing.\n */\n showValue?: boolean;\n /** Extra classes for the swatch. */\n swatchClassName?: string;\n /** Extra classes for the printed value. */\n valueClassName?: string;\n /** Anything to put after the value — a copy button, a label. */\n children?: ReactNode;\n}\n\nconst ColorPickerPreview = forwardRef(\n ({ className, showValue = false, swatchClassName, valueClassName, children }, ref) => {\n const ctx = useColorPicker('ColorPicker.Preview');\n const slots = colorPickerVariants({ size: ctx.size });\n const diameter = PREVIEW_SWATCH[ctx.size];\n\n const [printed, setPrinted] = useState(() =>\n formatColor(\n {\n h: ctx.hue.value,\n s: ctx.saturation.value,\n v: ctx.brightness.value,\n a: ctx.opacity.value,\n },\n ctx.format\n )\n );\n\n const format = ctx.format;\n useAnimatedReaction(\n () =>\n formatColor(\n {\n h: ctx.hue.value,\n s: ctx.saturation.value,\n v: ctx.brightness.value,\n a: ctx.opacity.value,\n },\n format\n ),\n (current, previous) => {\n if (current !== previous) runOnJS(setPrinted)(current);\n },\n [format]\n );\n\n const fillStyle = useAnimatedStyle(() => ({\n backgroundColor: hsvToCss(\n ctx.hue.value,\n ctx.saturation.value,\n ctx.brightness.value,\n ctx.opacity.value\n ),\n }));\n\n return (\n \n \n \n \n \n {showValue ? (\n \n {printed}\n \n ) : null}\n {children}\n \n );\n }\n);\n\nColorPickerPreview.displayName = 'ColorPicker.Preview';\n\n/* ------------------------------------------------------------------ *\n * Swatches.\n * ------------------------------------------------------------------ */\n\nexport interface ColorPickerSwatchesProps {\n className?: string;\n /** The presets, in any format `ColorPicker` can read. */\n colors: string[];\n /** Diameter of one swatch in points. Defaults to the picker's size. */\n swatchSize?: number;\n /** Extra classes for one swatch. */\n swatchClassName?: string;\n}\n\nconst ColorPickerSwatches = forwardRef(\n ({ className, colors, swatchSize, swatchClassName }, ref) => {\n const ctx = useColorPicker('ColorPicker.Swatches');\n const slots = colorPickerVariants({ size: ctx.size });\n const diameter = swatchSize ?? SWATCH[ctx.size];\n\n // Unreadable strings are dropped here rather than rendered as black: a row\n // of presets with a mystery black one in it is worse than a shorter row.\n const parsed = useMemo(\n () =>\n colors\n .map((raw) => ({ raw, color: parseColor(raw) }))\n .filter((entry): entry is { raw: string; color: HsvaColor } => entry.color !== null),\n [colors]\n );\n\n return (\n \n {parsed.map((entry, index) => (\n \n ))}\n \n );\n }\n);\n\nColorPickerSwatches.displayName = 'ColorPicker.Swatches';\n\nfunction Swatch({\n color,\n diameter,\n className,\n ctx,\n slots,\n}: {\n color: HsvaColor;\n diameter: number;\n className?: string;\n ctx: ColorPickerContextValue;\n slots: ReturnType;\n}) {\n const hex = hsvToHex(color.h, color.s, color.v, color.a);\n\n /*\n * Selected is an exact match on the written colour rather than a distance\n * between two sets of channels. A near-black has an arbitrary hue and a\n * meaningless saturation, so a per-channel comparison marks two visually\n * identical blacks as different and two different hues of black as the same.\n */\n const ringStyle = useAnimatedStyle(() => {\n const current = hsvToHex(\n ctx.hue.value,\n ctx.saturation.value,\n ctx.brightness.value,\n ctx.opacity.value\n );\n return { opacity: withTiming(current === hex ? 1 : 0, { duration: 120 }) };\n });\n\n const onPress = () => {\n ctx.hue.value = color.h;\n ctx.saturation.value = color.s;\n ctx.brightness.value = color.v;\n ctx.opacity.value = color.a;\n ctx.emit(color.h, color.s, color.v, color.a, true);\n if (ctx.haptics) selectionTick();\n };\n\n return (\n \n \n {color.a < 1 ? : null}\n \n \n {/* The ring sits outside the fill, so a swatch the same colour as the\n page still reads as selected. */}\n \n \n );\n}\n\n/* ------------------------------------------------------------------ *\n * Field — the strip above the controls: what is being picked, and what\n * it currently is.\n * ------------------------------------------------------------------ */\n\nexport interface ColorPickerFieldProps {\n className?: string;\n /** What the colour is for — \"Accent\", \"Background\", a layer name. */\n label?: string;\n /**\n * Print the current colour beside the swatch, in the picker's `format`.\n * On by default: the strip exists to say what the colour *is*, and a swatch\n * alone cannot be read out, copied down or typed into a design tool.\n */\n showValue?: boolean;\n /** Extra classes for the swatch. */\n swatchClassName?: string;\n /**\n * Makes the strip pressable, and a button to a screen reader.\n *\n * Mostly you do not pass this yourself: `ColorPicker.Trigger` clones the\n * strip with one, which is what turns the row into the thing that opens the\n * picker.\n */\n onPress?: (...args: unknown[]) => void;\n /** Anything to put after the swatch — a copy button, a reset. */\n children?: ReactNode;\n}\n\n/**\n * A header for the picker below it.\n *\n * The value it prints is built on the UI thread and only crosses to\n * JavaScript when the rounded string actually changes, the same as `Preview`.\n * A drag through a hundred frames of the same hex costs one render.\n */\nconst ColorPickerField = forwardRef(\n ({ className, label, showValue = true, swatchClassName, onPress, children }, ref) => {\n const ctx = useColorPicker('ColorPicker.Field');\n const slots = colorPickerVariants({ size: ctx.size });\n const diameter = FIELD_SWATCH[ctx.size];\n\n const read = useCallback(\n () =>\n formatColor(\n {\n h: ctx.hue.value,\n s: ctx.saturation.value,\n v: ctx.brightness.value,\n a: ctx.opacity.value,\n },\n ctx.format\n ),\n // Shared values are stable objects; `format` is the only real dependency.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [ctx.format]\n );\n\n const [printed, setPrinted] = useState(read);\n\n const format = ctx.format;\n useAnimatedReaction(\n () =>\n formatColor(\n {\n h: ctx.hue.value,\n s: ctx.saturation.value,\n v: ctx.brightness.value,\n a: ctx.opacity.value,\n },\n format\n ),\n (current, previous) => {\n if (current !== previous) runOnJS(setPrinted)(current);\n },\n [format]\n );\n\n const fillStyle = useAnimatedStyle(() => ({\n backgroundColor: hsvToCss(\n ctx.hue.value,\n ctx.saturation.value,\n ctx.brightness.value,\n ctx.opacity.value\n ),\n }));\n\n // Pressable only when it has somewhere to go. A row that highlights under\n // a finger and then does nothing is a worse lie than a row that does not\n // react at all.\n const Row = onPress ? Pressable : View;\n\n return (\n \n {label ? {label} : null}\n {showValue ? (\n {printed.toUpperCase()}\n ) : null}\n \n {/* Only behind a translucent colour — a checkerboard under an opaque\n swatch is a pattern nobody can see, drawn every frame. */}\n \n \n \n {children}\n \n );\n }\n);\n\nColorPickerField.displayName = 'ColorPicker.Field';\n\n/* ------------------------------------------------------------------ *\n * Channel — the readout that names the track under it.\n * ------------------------------------------------------------------ */\n\n/** Which channel a readout is reading. */\nexport type ColorPickerChannel = 'hue' | 'saturation' | 'brightness' | 'alpha';\n\n/** What each channel is called, and how its number is written. */\nconst CHANNEL_LABEL: Record = {\n hue: 'Hue',\n saturation: 'Saturation',\n brightness: 'Brightness',\n alpha: 'Opacity',\n};\n\nexport interface ColorPickerChannelProps {\n className?: string;\n /** Which channel to read. */\n channel: ColorPickerChannel;\n /** Overrides the channel's own name. */\n label?: string;\n /**\n * Writes the number yourself. Receives degrees for `hue` and a percentage\n * for the other three, both already rounded.\n */\n format?: (value: number) => string;\n}\n\n/**\n * The line above a track: the number on the leading edge, what it names on the\n * trailing one.\n *\n * The number is the one thing in this component that has to reach JavaScript\n * during a drag, because text cannot be written from a worklet. It is rounded\n * first and only sent when the rounded value changes, so a slow drag across a\n * hue track sends 360 updates rather than one per frame.\n */\nconst ColorPickerChannelReadout = forwardRef(\n ({ className, channel, label, format }, ref) => {\n const ctx = useColorPicker('ColorPicker.Channel');\n const slots = colorPickerVariants({ size: ctx.size });\n\n const source =\n channel === 'hue'\n ? ctx.hue\n : channel === 'saturation'\n ? ctx.saturation\n : channel === 'brightness'\n ? ctx.brightness\n : ctx.opacity;\n\n const [value, setValue] = useState(() =>\n Math.round(channel === 'hue' ? source.value : source.value * 100)\n );\n\n useAnimatedReaction(\n () => Math.round(channel === 'hue' ? source.value : source.value * 100),\n (current, previous) => {\n if (current !== previous) runOnJS(setValue)(current);\n },\n [channel]\n );\n\n const text = format\n ? format(value)\n : channel === 'hue'\n ? `${value}°`\n : `${value}%`;\n\n return (\n \n {text}\n {label ?? CHANNEL_LABEL[channel]}\n \n );\n }\n);\n\nColorPickerChannelReadout.displayName = 'ColorPicker.Channel';\n\n/* ------------------------------------------------------------------ *\n * Brightness — the third channel, as a track.\n * ------------------------------------------------------------------ */\n\nexport interface ColorPickerBrightnessProps {\n className?: string;\n /** Extra classes for the draggable thumb. */\n thumbClassName?: string;\n}\n\n/**\n * Black to the colour at full brightness.\n *\n * The square carries saturation and brightness together, so a picker built\n * around it never needs this. A wheel carries hue and saturation instead, and\n * leaves brightness with nowhere to live — this is where it goes.\n */\nconst ColorPickerBrightness = forwardRef(\n ({ className, thumbClassName }, ref) => {\n const ctx = useColorPicker('ColorPicker.Brightness');\n const sign = useDirectionSign();\n const thumbSize = TRACK_THUMB[ctx.size];\n const width = useSharedValue(0);\n const [announced, setAnnounced] = useState(() =>\n Math.round(ctx.brightness.value * 100)\n );\n\n const settle = useCallback((v: number) => setAnnounced(Math.round(v * 100)), []);\n\n /*\n * The ramp's far end is the current hue at full brightness, so it has to be\n * re-declared from React when the hue moves. Unlike the hue ramp — which is\n * every hue and therefore never changes — this one is only two colours, so\n * rebuilding it is a two-element array rather than a gradient recompile.\n */\n const [hueEnd, setHueEnd] = useState(() =>\n hsvToCss(ctx.hue.value, ctx.saturation.value, 1, 1)\n );\n\n useAnimatedReaction(\n () => hsvToCss(ctx.hue.value, ctx.saturation.value, 1, 1),\n (current, previous) => {\n if (current !== previous) runOnJS(setHueEnd)(current);\n }\n );\n\n const apply = (x: number, commit: boolean) => {\n 'worklet';\n const v = trackFraction(x, width.value, thumbSize, sign);\n ctx.brightness.value = v;\n runOnJS(ctx.emit)(ctx.hue.value, ctx.saturation.value, v, ctx.opacity.value, commit);\n if (commit) {\n runOnJS(settle)(v);\n if (ctx.haptics) runOnJS(selectionTick)();\n }\n };\n\n const pan = Gesture.Pan()\n .enabled(!ctx.disabled)\n .activeOffsetX([-6, 6])\n .shouldCancelWhenOutside(false)\n .onUpdate((event) => apply(event.x, false))\n .onFinalize((event) => apply(event.x, true));\n\n const tap = Gesture.Tap()\n .enabled(!ctx.disabled)\n .maxDuration(250)\n .onEnd((event) => apply(event.x, true));\n\n const gesture = Gesture.Race(pan, tap);\n\n const thumbStyle = useAnimatedStyle(() => {\n const travel = Math.max(width.value - thumbSize, 0);\n return { transform: [{ translateX: ctx.brightness.value * travel * sign }] };\n });\n\n const thumbFillStyle = useAnimatedStyle(() => ({\n backgroundColor: hsvToCss(\n ctx.hue.value,\n ctx.saturation.value,\n ctx.brightness.value,\n 1\n ),\n }));\n\n const nudge = (dir: 1 | -1) => {\n const next = Math.min(Math.max(ctx.brightness.value + dir * NUDGE, 0), 1);\n ctx.brightness.value = withTiming(next, TIMING);\n settle(next);\n ctx.emit(ctx.hue.value, ctx.saturation.value, next, ctx.opacity.value, true);\n };\n\n return (\n {\n width.value = event.nativeEvent.layout.width;\n }}\n size={ctx.size}\n className={className}\n thumbClassName={thumbClassName}\n label=\"Brightness\"\n disabled={ctx.disabled}\n accessibilityValue={{\n min: 0,\n max: 100,\n now: announced,\n text: `${announced}%`,\n }}\n onAccessibilityAction={(event) => {\n const action = enabledColorPickerAction(\n event.nativeEvent.actionName,\n ctx.disabled\n );\n if (action === 'increment') nudge(1);\n else if (action === 'decrement') nudge(-1);\n }}\n >\n \n \n );\n }\n);\n\nColorPickerBrightness.displayName = 'ColorPicker.Brightness';\n\n/* ------------------------------------------------------------------ *\n * Wheel — hue around, saturation out.\n * ------------------------------------------------------------------ */\n\n/**\n * How many wedges the hue ring is cut into.\n *\n * There is no conic gradient here to draw a hue ring with, so it is\n * approximated by solid wedges. Three degrees each is under a pixel of colour\n * step at any size this is drawn at, and the wedges are given a hair of\n * overlap so the seams between them cannot show as lighter lines.\n */\nconst WHEEL_WEDGES = 120;\nconst WHEEL_OVERLAP = 0.6;\n\n/** Radius of the drawing, in the wheel's own viewBox units. */\nconst WHEEL_R = 50;\n\n/** The hue ring, built once — it is the same wheel in every picker. */\nconst WHEEL_PATHS = Array.from({ length: WHEEL_WEDGES }, (_, index) => {\n const step = 360 / WHEEL_WEDGES;\n const from = index * step;\n const to = from + step + WHEEL_OVERLAP;\n const rad = (deg: number) => (deg * Math.PI) / 180;\n const x1 = WHEEL_R + WHEEL_R * Math.cos(rad(from));\n const y1 = WHEEL_R + WHEEL_R * Math.sin(rad(from));\n const x2 = WHEEL_R + WHEEL_R * Math.cos(rad(to));\n const y2 = WHEEL_R + WHEEL_R * Math.sin(rad(to));\n return {\n d: `M ${WHEEL_R} ${WHEEL_R} L ${x1} ${y1} A ${WHEEL_R} ${WHEEL_R} 0 0 1 ${x2} ${y2} Z`,\n fill: hsvToCss(from, 1, 1, 1),\n };\n});\n\nexport interface ColorPickerWheelProps {\n className?: string;\n /** Diameter in points. Defaults to the picker's size. */\n size?: number;\n /** Extra classes for the draggable thumb. */\n thumbClassName?: string;\n}\n\n/**\n * The square, bent into a circle: hue is the angle and saturation is the\n * distance from the middle.\n *\n * It reads the same three shared values as `ColorPicker.Area`, so the two are\n * interchangeable — a picker is a wheel instead of a square, not a wheel as\n * well as one. What it does not carry is brightness, which has no third\n * dimension to occupy here; pair it with `ColorPicker.Brightness`.\n *\n * The wheel is deliberately not mirrored under RTL. A track has a start and an\n * end and so has a direction to read it in; a wheel has neither, and flipping\n * which way round the spectrum runs would only make the same colour sit\n * somewhere else.\n */\nconst ColorPickerWheel = forwardRef(\n ({ className, size, thumbClassName }, ref) => {\n const ctx = useColorPicker('ColorPicker.Wheel');\n const slots = colorPickerVariants({ size: ctx.size });\n const gradientId = useId().replace(/[^a-zA-Z0-9]/g, '');\n\n const diameter = size ?? WHEEL_SIZE[ctx.size];\n const thumbSize = AREA_THUMB[ctx.size];\n const radius = diameter / 2;\n\n const [announced, setAnnounced] = useState(() => ({\n h: Math.round(ctx.hue.value),\n s: Math.round(ctx.saturation.value * 100),\n }));\n\n const settle = useCallback((h: number, s: number) => {\n setAnnounced({ h: Math.round(h), s: Math.round(s * 100) });\n }, []);\n\n const apply = (x: number, y: number, commit: boolean) => {\n 'worklet';\n const dx = x - radius;\n const dy = y - radius;\n // Clockwise from three o'clock, which is what `atan2` already gives in a\n // coordinate space whose y runs downwards — so the wedges above and the\n // touch here agree without either of them correcting for the other.\n const degrees = (Math.atan2(dy, dx) * 180) / Math.PI;\n const h = (degrees + 360) % 360;\n // Past the rim the colour is the one on the rim, rather than nothing:\n // a finger that overshoots the edge of a disc has still chosen a hue.\n const s = Math.min(Math.sqrt(dx * dx + dy * dy) / radius, 1);\n\n ctx.hue.value = h;\n ctx.saturation.value = s;\n runOnJS(ctx.emit)(h, s, ctx.brightness.value, ctx.opacity.value, commit);\n if (commit) {\n runOnJS(settle)(h, s);\n if (ctx.haptics) runOnJS(selectionTick)();\n }\n };\n\n const pan = Gesture.Pan()\n .enabled(!ctx.disabled)\n .minDistance(0)\n .shouldCancelWhenOutside(false)\n .onBegin((event) => apply(event.x, event.y, false))\n .onUpdate((event) => apply(event.x, event.y, false))\n .onFinalize((event) => apply(event.x, event.y, true));\n\n const thumbStyle = useAnimatedStyle(() => {\n const angle = (ctx.hue.value * Math.PI) / 180;\n const r = ctx.saturation.value * radius;\n return {\n transform: [\n { translateX: radius + r * Math.cos(angle) - thumbSize / 2 },\n { translateY: radius + r * Math.sin(angle) - thumbSize / 2 },\n ],\n };\n });\n\n const thumbFillStyle = useAnimatedStyle(() => ({\n backgroundColor: hsvToCss(\n ctx.hue.value,\n ctx.saturation.value,\n ctx.brightness.value,\n 1\n ),\n }));\n\n // Brightness is not drawn into the wheel — it darkens the whole of it,\n // which is what brightness does.\n const dimStyle = useAnimatedStyle(() => ({\n opacity: 1 - ctx.brightness.value,\n }));\n\n const nudge = (channel: 'hue' | 'saturation', dir: 1 | -1) => {\n if (channel === 'hue') {\n const next = (ctx.hue.value + dir * HUE_NUDGE + 360) % 360;\n ctx.hue.value = next;\n settle(next, ctx.saturation.value);\n ctx.emit(next, ctx.saturation.value, ctx.brightness.value, ctx.opacity.value, true);\n return;\n }\n const next = Math.min(Math.max(ctx.saturation.value + dir * NUDGE, 0), 1);\n ctx.saturation.value = withTiming(next, TIMING);\n settle(ctx.hue.value, next);\n ctx.emit(ctx.hue.value, next, ctx.brightness.value, ctx.opacity.value, true);\n };\n\n const onAccessibilityAction = (event: AccessibilityActionEvent) => {\n const action = enabledColorPickerAction(\n event.nativeEvent.actionName,\n ctx.disabled\n );\n if (action === 'increment') nudge('hue', 1);\n else if (action === 'decrement') nudge('hue', -1);\n else if (action === 'saturate') nudge('saturation', 1);\n else if (action === 'desaturate') nudge('saturation', -1);\n };\n\n return (\n \n {/* Square box, round drawing: the thumb travels to the rim and has to\n be drawn whole when it gets there, so the box is not clipped and\n only the wheel inside it is round. */}\n \n \n \n \n \n \n \n \n \n {WHEEL_PATHS.map((wedge, index) => (\n \n ))}\n {/* White at the middle falling off to nothing at the rim — the\n same ramp the square runs across, wrapped around a centre. */}\n \n \n \n \n\n \n \n \n \n \n );\n }\n);\n\nColorPickerWheel.displayName = 'ColorPicker.Wheel';\n\nexport const ColorPicker = Object.assign(ColorPickerRoot, {\n Trigger: ColorPickerTrigger,\n Content: ColorPickerContent,\n Field: ColorPickerField,\n Area: ColorPickerArea,\n Wheel: ColorPickerWheel,\n Channel: ColorPickerChannelReadout,\n Hue: ColorPickerHue,\n Brightness: ColorPickerBrightness,\n Alpha: ColorPickerAlpha,\n Preview: ColorPickerPreview,\n Swatches: ColorPickerSwatches,\n});\n" + "content": "/**\n * ColorPicker — a colour chosen by dragging, not by typing.\n *\n * The controls are the colour model made visible: a square where saturation\n * runs across and brightness runs up, under a hue picked on its own scale.\n * That is why the component stores hue, saturation, value and alpha rather\n * than the string it hands back — a fully black colour is `#000` whatever hue\n * and saturation produced it, so a picker that stored the output would lose\n * the thumb the moment you dragged into a corner and could not put it back.\n *\n * Nothing about a drag crosses to JavaScript. The four channels are shared\n * values; the hue behind the square, both thumb fills and the preview swatch\n * are `backgroundColor`s computed from them on the UI thread, and the\n * translucency ramp on the alpha track is a gradient used as a mask over a\n * solid fill rather than a gradient whose colours have to be re-declared from\n * React on every frame. The picked colour bridges back on change and again on\n * release, the same as any other input here.\n *\n * ```tsx\n * \n * \n * \n * \n * \n * ```\n *\n * Composition is the API: a picker is whichever of the parts you put in it, so\n * a control with no alpha is one that has no `ColorPicker.Alpha`, not one with\n * a prop turned off.\n *\n * ## Folding it away\n *\n * A picker is a page's worth of controls in service of one value, and that\n * value is read far more often than it is changed — so `presentation` puts the\n * controls behind the row that reads it out:\n *\n * ```tsx\n * \n * \n * \n * \n * \n * \n * \n * \n * \n * ```\n *\n * `Content` re-provides the picker's context around what it holds, because the\n * panel is portalled above the rest of the screen and is therefore no longer\n * below the picker in the tree by the time the parts inside it go looking.\n *\n * Works controlled (`value` + `onValueChange`) or uncontrolled (`defaultValue`).\n */\nimport {\n createContext,\n forwardRef,\n useCallback,\n useContext,\n useEffect,\n useId,\n useMemo,\n useRef,\n useState,\n type ReactElement,\n type ReactNode,\n} from 'react';\nimport {\n Pressable,\n StyleSheet,\n View,\n type AccessibilityActionEvent,\n type LayoutChangeEvent,\n type ViewStyle,\n} from 'react-native';\nimport MaskedView from '@react-native-masked-view/masked-view';\nimport { LinearGradient } from 'expo-linear-gradient';\nimport { Gesture, GestureDetector } from 'react-native-gesture-handler';\nimport Animated, {\n runOnJS,\n useAnimatedReaction,\n useAnimatedStyle,\n useSharedValue,\n withTiming,\n type SharedValue,\n} from 'react-native-reanimated';\nimport Svg, {\n Circle,\n Defs,\n Path,\n Pattern,\n RadialGradient,\n Rect,\n Stop,\n} from 'react-native-svg';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport { useCSSVariable } from 'uniwind';\nimport { useDirectionSign } from '@/hooks/use-direction';\nimport { Text } from '@/components/ui/text';\nimport {\n formatColor,\n hsvToCss,\n hsvToHex,\n parseColor,\n type ColorFormat,\n type HsvaColor,\n} from '@/lib/color';\nimport { cn } from '@/lib/cn';\nimport { selectionTick } from '@/lib/haptics';\nimport { Popover, type PopoverContentProps } from '@/components/ui/popover';\nimport { enabledColorPickerAction } from '@/components/ui/color-picker-accessibility';\nimport type { ViewRef } from '@/components/ui/refs';\n\n/** Settles a thumb that was moved by something other than a finger. */\nconst TIMING = { duration: 140 } as const;\n\n/**\n * The hue scale, drawn once as a gradient rather than sampled per pixel. Six\n * stops plus the wrap back to red — fewer would bend the ramp, more would not\n * change it, because hue is piecewise linear in RGB and these are the corners.\n */\nconst HUE_STOPS: readonly [string, string, ...string[]] = [\n '#ff0000',\n '#ffff00',\n '#00ff00',\n '#00ffff',\n '#0000ff',\n '#ff00ff',\n '#ff0000',\n];\n\n/** The same ramp for a right-to-left picker, where hue runs the other way. */\nconst HUE_STOPS_FLIPPED = [...HUE_STOPS].reverse() as unknown as readonly [\n string,\n string,\n ...string[],\n];\n\n/** How far one screen-reader increment moves a channel. */\nconst NUDGE = 0.05;\n/** How far one screen-reader increment moves the hue, in degrees. */\nconst HUE_NUDGE = 10;\n\nconst colorPickerVariants = tv({\n slots: {\n root: 'w-full gap-3',\n area: 'overflow-hidden rounded-2xl border border-border',\n wheel: 'overflow-hidden rounded-full border border-border',\n // Both thumbs are a white ring around a fill of the colour under them, so\n // the ring reads against the dark end of the square as well as the light.\n // Two points, not three: the ring is there to separate the fill from what\n // is behind it, and any more of it starts hiding the colour it is showing.\n thumb: 'absolute start-0 top-0 rounded-full border-2 border-white shadow-md',\n thumbFill: 'flex-1 rounded-full',\n track: 'w-full overflow-hidden rounded-full border border-border',\n swatches: 'flex-row flex-wrap items-center gap-2',\n swatch: 'items-center justify-center rounded-full',\n swatchRing: 'absolute rounded-full border-2 border-foreground',\n preview: 'flex-row items-center gap-3',\n previewSwatch: 'overflow-hidden rounded-full border border-border',\n previewValue: 'text-sm font-medium text-foreground',\n // The header strip: a name on the leading edge, the value and a swatch on\n // the trailing one.\n field: 'w-full flex-row items-center gap-3 rounded-2xl bg-surface px-4 py-3',\n fieldLabel: 'flex-1 text-base font-medium text-foreground',\n fieldValue: 'text-base font-normal text-muted-foreground',\n fieldSwatch: 'overflow-hidden rounded-full border border-border',\n // The readout above a track — the number on one side, what it names on the\n // other.\n channel: 'w-full flex-row items-center justify-between gap-3',\n channelValue: 'text-base font-normal text-muted-foreground',\n channelLabel: 'text-base font-medium text-foreground',\n },\n variants: {\n size: {\n sm: {},\n md: {},\n lg: {},\n },\n disabled: {\n true: { root: 'opacity-50' },\n },\n },\n defaultVariants: {\n size: 'md',\n },\n});\n\ntype ColorPickerVariantProps = VariantProps;\nexport type ColorPickerSize = NonNullable;\n\n/** Height of the saturation/brightness square, per size. */\nconst AREA_HEIGHT: Record = { sm: 140, md: 180, lg: 220 };\n/**\n * Height of a channel track, per size. Tall enough that the knob sits *in* the\n * track rather than on top of a hairline — a slider whose thumb overhangs a\n * thin rule reads as a control laid over a decoration.\n */\nconst TRACK_HEIGHT: Record = { sm: 16, md: 20, lg: 24 };\n/** Thumb diameter on a channel track — wider than the track, so it reads as a knob. */\nconst TRACK_THUMB: Record = { sm: 22, md: 26, lg: 30 };\n/** Thumb diameter on the square. */\nconst AREA_THUMB: Record = { sm: 20, md: 24, lg: 28 };\n/** Preset swatch diameter. */\nconst SWATCH: Record = { sm: 26, md: 30, lg: 34 };\n/** The swatch beside the value in `Preview`. */\nconst PREVIEW_SWATCH: Record = { sm: 28, md: 36, lg: 44 };\n/** The smaller swatch on the end of the `Field` strip. */\nconst FIELD_SWATCH: Record = { sm: 22, md: 28, lg: 34 };\n/** Diameter of the wheel, per size. */\nconst WHEEL_SIZE: Record = { sm: 180, md: 240, lg: 300 };\n\ninterface ColorPickerContextValue {\n hue: SharedValue;\n saturation: SharedValue;\n brightness: SharedValue;\n opacity: SharedValue;\n /** Called from a worklet with the channels that produced the change. */\n emit: (h: number, s: number, v: number, a: number, commit: boolean) => void;\n format: ColorFormat;\n disabled: boolean;\n haptics: boolean;\n size: ColorPickerSize;\n}\n\nconst ColorPickerContext = createContext(null);\n\nfunction useColorPicker(part: string) {\n const ctx = useContext(ColorPickerContext);\n if (!ctx) throw new Error(`${part} must be used inside .`);\n return ctx;\n}\n\n/** Where a picker starts when it is handed a value it cannot read. */\nconst FALLBACK: HsvaColor = { h: 0, s: 1, v: 1, a: 1 };\n\n/** How the controls get onto the screen. */\nexport type ColorPickerPresentation = 'inline' | 'popover' | 'bottom-sheet';\n\n/**\n * Floor for the panel's width when it takes the trigger's. A swatch row is a\n * narrow trigger, and a square you drag on is not a control that survives being\n * squeezed to match one.\n */\nconst CONTENT_MIN_WIDTH = 268;\n\nexport interface ColorPickerProps extends Omit {\n className?: string;\n /** Controlled colour. Leave unset and pass `defaultValue` to run uncontrolled. */\n value?: string;\n /** Starting colour when uncontrolled. */\n defaultValue?: string;\n /**\n * Fires on every frame of a drag — cheap updates only. The string is written\n * in `format`.\n */\n onValueChange?: (color: string) => void;\n /** Fires once when a drag ends. The place for expensive side effects. */\n onValueCommit?: (color: string) => void;\n /**\n * How the colour is written on the way out. `hex` gains an `#rrggbbaa` alpha\n * pair, and the other two switch to their `a` forms, only when the colour is\n * actually translucent.\n */\n format?: ColorFormat;\n disabled?: boolean;\n /**\n * A tick when a drag ends and when a preset is picked. Off by default — needs\n * the optional `expo-haptics`, and is silent without it. There is no tick\n * during a drag: a colour has no steps to cross, so a tick could only be a\n * buzz proportional to speed.\n */\n haptics?: boolean;\n /**\n * How the controls get onto the screen.\n *\n * `inline` stacks them where they are written, and is the default. The other\n * two put them behind a `ColorPicker.Trigger` and draw them in a\n * `ColorPicker.Content` — which is the arrangement a colour usually wants,\n * since a picker is a page's worth of controls in service of one value that\n * is looked at far more often than it is changed.\n */\n presentation?: ColorPickerPresentation;\n /** Controlled open state of the panel. Ignored by `inline`. */\n open?: boolean;\n onOpenChange?: (open: boolean) => void;\n /** The parts, in the order they should stack. */\n children: ReactNode;\n}\n\nconst ColorPickerRoot = forwardRef(\n (\n {\n className,\n value: valueProp,\n defaultValue = '#ff0000',\n onValueChange,\n onValueCommit,\n format = 'hex',\n disabled = false,\n haptics = false,\n size = 'md',\n presentation = 'inline',\n open,\n onOpenChange,\n children,\n },\n ref\n ) => {\n const slots = colorPickerVariants({ size, disabled });\n\n // Parsed once. After this the channels are the truth and the prop is only\n // consulted when it changes to something we did not ourselves emit.\n const initial = useMemo(\n () => parseColor(valueProp ?? defaultValue) ?? FALLBACK,\n // eslint-disable-next-line react-hooks/exhaustive-deps\n []\n );\n\n const hue = useSharedValue(initial.h);\n const saturation = useSharedValue(initial.s);\n const brightness = useSharedValue(initial.v);\n const opacity = useSharedValue(initial.a);\n\n const changeRef = useRef(onValueChange);\n changeRef.current = onValueChange;\n const commitRef = useRef(onValueCommit);\n commitRef.current = onValueCommit;\n const formatRef = useRef(format);\n formatRef.current = format;\n\n /*\n * The last string this picker produced. A controlled parent hands it\n * straight back, and re-parsing it into the channels mid-drag would fight\n * the finger: the round trip is lossy at the edges of the square, so the\n * thumb would be pulled a pixel back on every frame.\n */\n const lastEmitted = useRef(null);\n\n const emit = useCallback((h: number, s: number, v: number, a: number, commit: boolean) => {\n const next = formatColor({ h, s, v, a }, formatRef.current);\n lastEmitted.current = next;\n changeRef.current?.(next);\n if (commit) commitRef.current?.(next);\n }, []);\n\n // Keep the channels in step with a controlled value changed elsewhere. Set\n // rather than animated: an external change is not a gesture, and springing\n // the hue from 350° to 10° would run the long way round the wheel.\n useEffect(() => {\n if (valueProp === undefined) return;\n if (valueProp === lastEmitted.current) return;\n const parsed = parseColor(valueProp);\n if (!parsed) return;\n hue.value = parsed.h;\n saturation.value = parsed.s;\n brightness.value = parsed.v;\n opacity.value = parsed.a;\n }, [valueProp, hue, saturation, brightness, opacity]);\n\n const context = useMemo(\n () => ({\n hue,\n saturation,\n brightness,\n opacity,\n emit,\n format,\n disabled,\n haptics,\n size: size ?? 'md',\n }),\n [hue, saturation, brightness, opacity, emit, format, disabled, haptics, size]\n );\n\n if (presentation !== 'inline') {\n return (\n \n \n {children}\n \n \n );\n }\n\n return (\n \n \n {children}\n \n \n );\n }\n);\n\nColorPickerRoot.displayName = 'ColorPicker';\n\n/* ------------------------------------------------------------------ *\n * Trigger and Content — the picker folded away behind the value.\n * ------------------------------------------------------------------ */\n\nexport interface ColorPickerTriggerProps {\n /** One element, cloned with an `onPress` that opens the panel. */\n children: ReactElement<{ onPress?: (...args: unknown[]) => void }>;\n}\n\n/**\n * What you press to open the picker. `ColorPicker.Field` is the obvious child —\n * it already reads out the colour it would let you change — but anything that\n * takes an `onPress` will do.\n */\nfunction ColorPickerTrigger({ children }: ColorPickerTriggerProps) {\n useColorPicker('ColorPicker.Trigger');\n return {children};\n}\n\nColorPickerTrigger.displayName = 'ColorPicker.Trigger';\n\nexport interface ColorPickerContentProps extends PopoverContentProps {}\n\n/**\n * The panel the controls are drawn in.\n *\n * It re-provides the picker's context around its children, and has to: the\n * panel is rendered through a portal, above everything else on the screen, so\n * by the time the parts inside it look for the picker they are no longer\n * anywhere below it in the tree. Without this the first `ColorPicker.Area`\n * inside a popover would throw.\n *\n * Defaults to the trigger's width, floored, so a panel opened from a labelled\n * strip lines up under it rather than announcing itself as a different object.\n */\nfunction ColorPickerContent({\n className,\n width = 'trigger',\n minWidth = CONTENT_MIN_WIDTH,\n children,\n ...props\n}: ColorPickerContentProps) {\n const ctx = useColorPicker('ColorPicker.Content');\n\n return (\n \n {children}\n \n );\n}\n\nColorPickerContent.displayName = 'ColorPicker.Content';\n\n/* ------------------------------------------------------------------ *\n * The checkerboard behind anything translucent.\n * ------------------------------------------------------------------ */\n\n/**\n * The \"nothing here\" backing. A tiled SVG pattern rather than a grid of views:\n * a 340-point track at eight-point squares is 170 squares, and every one of\n * them would be a view laid out by Yoga on the frame the picker mounts.\n */\nfunction Checkerboard({ square = 7 }: { square?: number }) {\n const id = useId().replace(/[^a-zA-Z0-9]/g, '');\n // The pattern is themed rather than the usual grey-on-white: a checkerboard\n // is a statement that there is nothing there, and \"nothing\" is the page.\n const light = String(useCSSVariable('--color-background') ?? '#ffffff');\n const dark = String(useCSSVariable('--color-muted') ?? '#e5e5e5');\n\n return (\n \n \n \n \n \n \n \n \n \n \n );\n}\n\n/* ------------------------------------------------------------------ *\n * Area — saturation across, brightness up.\n * ------------------------------------------------------------------ */\n\nexport interface ColorPickerAreaProps {\n className?: string;\n /** Height of the square in points. Defaults to the picker's size. */\n height?: number;\n /** Extra classes for the draggable thumb. */\n thumbClassName?: string;\n}\n\nconst ColorPickerArea = forwardRef(\n ({ className, height, thumbClassName }, ref) => {\n const ctx = useColorPicker('ColorPicker.Area');\n const slots = colorPickerVariants({ size: ctx.size });\n const sign = useDirectionSign();\n\n const boxHeight = height ?? AREA_HEIGHT[ctx.size];\n const thumbSize = AREA_THUMB[ctx.size];\n const width = useSharedValue(0);\n\n /*\n * The screen reader's copy of the value. Updated when a drag ends rather\n * than while it runs: a `now` that re-rendered on every frame would be a\n * hundred announcements for one gesture, and nobody is listening to the\n * middle of a drag.\n */\n const [announced, setAnnounced] = useState(() => ({\n s: ctx.saturation.value,\n v: ctx.brightness.value,\n }));\n\n const settle = useCallback((s: number, v: number) => {\n setAnnounced({ s, v });\n }, []);\n\n const apply = (x: number, y: number, commit: boolean) => {\n 'worklet';\n const w = Math.max(width.value, 1);\n // `x` is measured from the physical left edge either way, so a\n // right-to-left picker counts saturation back from the far side.\n const along = sign === 1 ? x : w - x;\n const s = Math.min(Math.max(along / w, 0), 1);\n const v = 1 - Math.min(Math.max(y / boxHeight, 0), 1);\n ctx.saturation.value = s;\n ctx.brightness.value = v;\n runOnJS(ctx.emit)(ctx.hue.value, s, v, ctx.opacity.value, commit);\n if (commit) {\n runOnJS(settle)(s, v);\n if (ctx.haptics) runOnJS(selectionTick)();\n }\n };\n\n // Both axes belong to the square, so it takes the gesture on touch-down\n // rather than after a threshold — a pad that needed ten points of travel\n // before it responded would feel broken.\n const pan = Gesture.Pan()\n .enabled(!ctx.disabled)\n .minDistance(0)\n .shouldCancelWhenOutside(false)\n .onBegin((event) => apply(event.x, event.y, false))\n .onUpdate((event) => apply(event.x, event.y, false))\n .onFinalize((event) => apply(event.x, event.y, true));\n\n const hueStyle = useAnimatedStyle(() => ({\n backgroundColor: hsvToCss(ctx.hue.value, 1, 1, 1),\n }));\n\n const thumbStyle = useAnimatedStyle(() => {\n // A transform is not laid out, so Yoga does not mirror it — the thumb\n // has to travel the other way itself.\n const along = ctx.saturation.value * width.value - thumbSize / 2;\n return {\n transform: [\n { translateX: along * sign },\n { translateY: (1 - ctx.brightness.value) * boxHeight - thumbSize / 2 },\n ],\n };\n });\n\n const thumbFillStyle = useAnimatedStyle(() => ({\n backgroundColor: hsvToCss(\n ctx.hue.value,\n ctx.saturation.value,\n ctx.brightness.value,\n 1\n ),\n }));\n\n const nudge = (channel: 'saturation' | 'brightness', dir: 1 | -1) => {\n const next = Math.min(Math.max(ctx[channel].value + dir * NUDGE, 0), 1);\n ctx[channel].value = withTiming(next, TIMING);\n const s = channel === 'saturation' ? next : ctx.saturation.value;\n const v = channel === 'brightness' ? next : ctx.brightness.value;\n settle(s, v);\n ctx.emit(ctx.hue.value, s, v, ctx.opacity.value, true);\n };\n\n const onAccessibilityAction = (event: AccessibilityActionEvent) => {\n const action = enabledColorPickerAction(\n event.nativeEvent.actionName,\n ctx.disabled\n );\n if (action === 'increment') nudge('saturation', 1);\n else if (action === 'decrement') nudge('saturation', -1);\n else if (action === 'brighter') nudge('brightness', 1);\n else if (action === 'darker') nudge('brightness', -1);\n };\n\n return (\n \n {/* The outer box is not clipped, so the thumb can sit centred on a\n colour at the very edge of the square instead of being pushed\n inside it. Only the gradients below are clipped to the radius. */}\n {\n width.value = event.nativeEvent.layout.width;\n }}\n accessible\n accessibilityRole=\"adjustable\"\n accessibilityLabel=\"Saturation and brightness\"\n accessibilityState={{ disabled: ctx.disabled }}\n accessibilityValue={{\n min: 0,\n max: 100,\n now: Math.round(announced.s * 100),\n text: `${Math.round(announced.s * 100)}% saturation, ${Math.round(\n announced.v * 100\n )}% brightness`,\n }}\n accessibilityActions={[\n { name: 'increment' },\n { name: 'decrement' },\n { name: 'brighter', label: 'Brighter' },\n { name: 'darker', label: 'Darker' },\n ]}\n onAccessibilityAction={onAccessibilityAction}\n >\n \n \n \n \n \n\n \n \n \n \n \n );\n }\n);\n\nColorPickerArea.displayName = 'ColorPicker.Area';\n\n/* ------------------------------------------------------------------ *\n * The two channel tracks share everything but what is drawn in them.\n * ------------------------------------------------------------------ */\n\ninterface ChannelTrackProps {\n /** What fills the track — a hue ramp, or a masked translucency ramp. */\n children: ReactNode;\n gesture: ReturnType;\n thumbStyle: ReturnType>;\n thumbFillStyle: ReturnType>;\n onLayout: (event: LayoutChangeEvent) => void;\n size: ColorPickerSize;\n className?: string;\n thumbClassName?: string;\n label: string;\n disabled: boolean;\n accessibilityValue: { min: number; max: number; now: number; text: string };\n onAccessibilityAction: (event: AccessibilityActionEvent) => void;\n innerRef: React.ForwardedRef;\n}\n\nfunction ChannelTrack({\n children,\n gesture,\n thumbStyle,\n thumbFillStyle,\n onLayout,\n size,\n className,\n thumbClassName,\n label,\n disabled,\n accessibilityValue,\n onAccessibilityAction,\n innerRef,\n}: ChannelTrackProps) {\n const slots = colorPickerVariants({ size });\n const trackHeight = TRACK_HEIGHT[size];\n const thumbSize = TRACK_THUMB[size];\n\n return (\n \n {/* The row is as tall as the thumb so an overhanging knob has somewhere\n to be drawn whole; the track inside it is the shorter, clipped part. */}\n \n \n {children}\n \n \n \n \n \n \n );\n}\n\n/**\n * Maps a touch anywhere on a track to a fraction of it, accounting for the\n * thumb's own width: the thumb travels between the two edges rather than off\n * them, so the usable run is the track minus one thumb.\n */\nfunction trackFraction(x: number, width: number, thumbSize: number, sign: number) {\n 'worklet';\n const along = sign === 1 ? x : width - x;\n const travel = Math.max(width - thumbSize, 1);\n return Math.min(Math.max((along - thumbSize / 2) / travel, 0), 1);\n}\n\n/* ------------------------------------------------------------------ *\n * Hue.\n * ------------------------------------------------------------------ */\n\nexport interface ColorPickerHueProps {\n className?: string;\n /** Extra classes for the draggable thumb. */\n thumbClassName?: string;\n}\n\nconst ColorPickerHue = forwardRef(\n ({ className, thumbClassName }, ref) => {\n const ctx = useColorPicker('ColorPicker.Hue');\n const sign = useDirectionSign();\n const thumbSize = TRACK_THUMB[ctx.size];\n const width = useSharedValue(0);\n const [announced, setAnnounced] = useState(() => Math.round(ctx.hue.value));\n\n const settle = useCallback((h: number) => setAnnounced(Math.round(h)), []);\n\n const apply = (x: number, commit: boolean) => {\n 'worklet';\n const h = trackFraction(x, width.value, thumbSize, sign) * 360;\n ctx.hue.value = h;\n runOnJS(ctx.emit)(\n h,\n ctx.saturation.value,\n ctx.brightness.value,\n ctx.opacity.value,\n commit\n );\n if (commit) {\n runOnJS(settle)(h);\n if (ctx.haptics) runOnJS(selectionTick)();\n }\n };\n\n /*\n * A thin horizontal control inside a scroller: the pan waits for six points\n * of sideways travel so a vertical flick starting on the track still\n * scrolls the page, and the tap handles the case where there was no travel\n * at all.\n */\n const pan = Gesture.Pan()\n .enabled(!ctx.disabled)\n .activeOffsetX([-6, 6])\n .shouldCancelWhenOutside(false)\n .onUpdate((event) => apply(event.x, false))\n .onFinalize((event) => apply(event.x, true));\n\n const tap = Gesture.Tap()\n .enabled(!ctx.disabled)\n .maxDuration(250)\n .onEnd((event) => apply(event.x, true));\n\n const gesture = Gesture.Race(pan, tap);\n\n const thumbStyle = useAnimatedStyle(() => {\n const travel = Math.max(width.value - thumbSize, 0);\n return { transform: [{ translateX: (ctx.hue.value / 360) * travel * sign }] };\n });\n\n const thumbFillStyle = useAnimatedStyle(() => ({\n backgroundColor: hsvToCss(ctx.hue.value, 1, 1, 1),\n }));\n\n const nudge = (dir: 1 | -1) => {\n const next = Math.min(Math.max(ctx.hue.value + dir * HUE_NUDGE, 0), 360);\n ctx.hue.value = withTiming(next, TIMING);\n settle(next);\n ctx.emit(next, ctx.saturation.value, ctx.brightness.value, ctx.opacity.value, true);\n };\n\n return (\n {\n width.value = event.nativeEvent.layout.width;\n }}\n size={ctx.size}\n className={className}\n thumbClassName={thumbClassName}\n label=\"Hue\"\n disabled={ctx.disabled}\n accessibilityValue={{ min: 0, max: 360, now: announced, text: `${announced}°` }}\n onAccessibilityAction={(event) => {\n const action = enabledColorPickerAction(\n event.nativeEvent.actionName,\n ctx.disabled\n );\n if (action === 'increment') nudge(1);\n else if (action === 'decrement') nudge(-1);\n }}\n >\n \n \n );\n }\n);\n\nColorPickerHue.displayName = 'ColorPicker.Hue';\n\n/* ------------------------------------------------------------------ *\n * Alpha.\n * ------------------------------------------------------------------ */\n\nexport interface ColorPickerAlphaProps {\n className?: string;\n /** Extra classes for the draggable thumb. */\n thumbClassName?: string;\n}\n\nconst ColorPickerAlpha = forwardRef(\n ({ className, thumbClassName }, ref) => {\n const ctx = useColorPicker('ColorPicker.Alpha');\n const sign = useDirectionSign();\n const thumbSize = TRACK_THUMB[ctx.size];\n const width = useSharedValue(0);\n const [announced, setAnnounced] = useState(() => Math.round(ctx.opacity.value * 100));\n\n const settle = useCallback((a: number) => setAnnounced(Math.round(a * 100)), []);\n\n const apply = (x: number, commit: boolean) => {\n 'worklet';\n const a = trackFraction(x, width.value, thumbSize, sign);\n ctx.opacity.value = a;\n runOnJS(ctx.emit)(\n ctx.hue.value,\n ctx.saturation.value,\n ctx.brightness.value,\n a,\n commit\n );\n if (commit) {\n runOnJS(settle)(a);\n if (ctx.haptics) runOnJS(selectionTick)();\n }\n };\n\n const pan = Gesture.Pan()\n .enabled(!ctx.disabled)\n .activeOffsetX([-6, 6])\n .shouldCancelWhenOutside(false)\n .onUpdate((event) => apply(event.x, false))\n .onFinalize((event) => apply(event.x, true));\n\n const tap = Gesture.Tap()\n .enabled(!ctx.disabled)\n .maxDuration(250)\n .onEnd((event) => apply(event.x, true));\n\n const gesture = Gesture.Race(pan, tap);\n\n const thumbStyle = useAnimatedStyle(() => {\n const travel = Math.max(width.value - thumbSize, 0);\n return { transform: [{ translateX: ctx.opacity.value * travel * sign }] };\n });\n\n const solidStyle = useAnimatedStyle(() => ({\n backgroundColor: hsvToCss(\n ctx.hue.value,\n ctx.saturation.value,\n ctx.brightness.value,\n 1\n ),\n }));\n\n const thumbFillStyle = useAnimatedStyle(() => ({\n backgroundColor: hsvToCss(\n ctx.hue.value,\n ctx.saturation.value,\n ctx.brightness.value,\n ctx.opacity.value\n ),\n }));\n\n const nudge = (dir: 1 | -1) => {\n const next = Math.min(Math.max(ctx.opacity.value + dir * NUDGE, 0), 1);\n ctx.opacity.value = withTiming(next, TIMING);\n settle(next);\n ctx.emit(ctx.hue.value, ctx.saturation.value, ctx.brightness.value, next, true);\n };\n\n return (\n {\n width.value = event.nativeEvent.layout.width;\n }}\n size={ctx.size}\n className={className}\n thumbClassName={thumbClassName}\n label=\"Opacity\"\n disabled={ctx.disabled}\n accessibilityValue={{\n min: 0,\n max: 100,\n now: announced,\n text: `${announced}%`,\n }}\n onAccessibilityAction={(event) => {\n const action = enabledColorPickerAction(\n event.nativeEvent.actionName,\n ctx.disabled\n );\n if (action === 'increment') nudge(1);\n else if (action === 'decrement') nudge(-1);\n }}\n >\n \n {/*\n * The ramp is a gradient used as a *mask* over a solid fill of the\n * current colour, not a gradient between a transparent and an opaque\n * copy of it. A gradient's colours are props, so the second form would\n * have to be re-declared from React on every frame of a drag on the\n * square above; the mask never changes, and only the fill under it does\n * — which is a `backgroundColor` the UI thread can write itself.\n */}\n \n }\n >\n \n \n \n );\n }\n);\n\nColorPickerAlpha.displayName = 'ColorPicker.Alpha';\n\n/* ------------------------------------------------------------------ *\n * Preview.\n * ------------------------------------------------------------------ */\n\nexport interface ColorPickerPreviewProps {\n className?: string;\n /**\n * Print the colour beside the swatch, in the picker's `format`. The string\n * is built on the UI thread and only crosses to JavaScript when it differs\n * from the last one, so a drag that is not changing the rounded value costs\n * nothing.\n */\n showValue?: boolean;\n /** Extra classes for the swatch. */\n swatchClassName?: string;\n /** Extra classes for the printed value. */\n valueClassName?: string;\n /** Anything to put after the value — a copy button, a label. */\n children?: ReactNode;\n}\n\nconst ColorPickerPreview = forwardRef(\n ({ className, showValue = false, swatchClassName, valueClassName, children }, ref) => {\n const ctx = useColorPicker('ColorPicker.Preview');\n const slots = colorPickerVariants({ size: ctx.size });\n const diameter = PREVIEW_SWATCH[ctx.size];\n\n const [printed, setPrinted] = useState(() =>\n formatColor(\n {\n h: ctx.hue.value,\n s: ctx.saturation.value,\n v: ctx.brightness.value,\n a: ctx.opacity.value,\n },\n ctx.format\n )\n );\n\n const format = ctx.format;\n useAnimatedReaction(\n () =>\n formatColor(\n {\n h: ctx.hue.value,\n s: ctx.saturation.value,\n v: ctx.brightness.value,\n a: ctx.opacity.value,\n },\n format\n ),\n (current, previous) => {\n if (current !== previous) runOnJS(setPrinted)(current);\n },\n [format]\n );\n\n const fillStyle = useAnimatedStyle(() => ({\n backgroundColor: hsvToCss(\n ctx.hue.value,\n ctx.saturation.value,\n ctx.brightness.value,\n ctx.opacity.value\n ),\n }));\n\n return (\n \n \n \n \n \n {showValue ? (\n \n {printed}\n \n ) : null}\n {children}\n \n );\n }\n);\n\nColorPickerPreview.displayName = 'ColorPicker.Preview';\n\n/* ------------------------------------------------------------------ *\n * Swatches.\n * ------------------------------------------------------------------ */\n\nexport interface ColorPickerSwatchesProps {\n className?: string;\n /** The presets, in any format `ColorPicker` can read. */\n colors: string[];\n /** Diameter of one swatch in points. Defaults to the picker's size. */\n swatchSize?: number;\n /** Extra classes for one swatch. */\n swatchClassName?: string;\n}\n\nconst ColorPickerSwatches = forwardRef(\n ({ className, colors, swatchSize, swatchClassName }, ref) => {\n const ctx = useColorPicker('ColorPicker.Swatches');\n const slots = colorPickerVariants({ size: ctx.size });\n const diameter = swatchSize ?? SWATCH[ctx.size];\n\n // Unreadable strings are dropped here rather than rendered as black: a row\n // of presets with a mystery black one in it is worse than a shorter row.\n const parsed = useMemo(\n () =>\n colors\n .map((raw) => ({ raw, color: parseColor(raw) }))\n .filter((entry): entry is { raw: string; color: HsvaColor } => entry.color !== null),\n [colors]\n );\n\n return (\n \n {parsed.map((entry, index) => (\n \n ))}\n \n );\n }\n);\n\nColorPickerSwatches.displayName = 'ColorPicker.Swatches';\n\nfunction Swatch({\n color,\n diameter,\n className,\n ctx,\n slots,\n}: {\n color: HsvaColor;\n diameter: number;\n className?: string;\n ctx: ColorPickerContextValue;\n slots: ReturnType;\n}) {\n const hex = hsvToHex(color.h, color.s, color.v, color.a);\n\n /*\n * Selected is an exact match on the written colour rather than a distance\n * between two sets of channels. A near-black has an arbitrary hue and a\n * meaningless saturation, so a per-channel comparison marks two visually\n * identical blacks as different and two different hues of black as the same.\n */\n const ringStyle = useAnimatedStyle(() => {\n const current = hsvToHex(\n ctx.hue.value,\n ctx.saturation.value,\n ctx.brightness.value,\n ctx.opacity.value\n );\n return { opacity: withTiming(current === hex ? 1 : 0, { duration: 120 }) };\n });\n\n const onPress = () => {\n ctx.hue.value = color.h;\n ctx.saturation.value = color.s;\n ctx.brightness.value = color.v;\n ctx.opacity.value = color.a;\n ctx.emit(color.h, color.s, color.v, color.a, true);\n if (ctx.haptics) selectionTick();\n };\n\n return (\n \n \n {color.a < 1 ? : null}\n \n \n {/* The ring sits outside the fill, so a swatch the same colour as the\n page still reads as selected. */}\n \n \n );\n}\n\n/* ------------------------------------------------------------------ *\n * Field — the strip above the controls: what is being picked, and what\n * it currently is.\n * ------------------------------------------------------------------ */\n\nexport interface ColorPickerFieldProps {\n className?: string;\n /** What the colour is for — \"Accent\", \"Background\", a layer name. */\n label?: string;\n /**\n * Print the current colour beside the swatch, in the picker's `format`.\n * On by default: the strip exists to say what the colour *is*, and a swatch\n * alone cannot be read out, copied down or typed into a design tool.\n */\n showValue?: boolean;\n /** Extra classes for the swatch. */\n swatchClassName?: string;\n /**\n * Makes the strip pressable, and a button to a screen reader.\n *\n * Mostly you do not pass this yourself: `ColorPicker.Trigger` clones the\n * strip with one, which is what turns the row into the thing that opens the\n * picker.\n */\n onPress?: (...args: unknown[]) => void;\n /** Anything to put after the swatch — a copy button, a reset. */\n children?: ReactNode;\n}\n\n/**\n * A header for the picker below it.\n *\n * The value it prints is built on the UI thread and only crosses to\n * JavaScript when the rounded string actually changes, the same as `Preview`.\n * A drag through a hundred frames of the same hex costs one render.\n */\nconst ColorPickerField = forwardRef(\n ({ className, label, showValue = true, swatchClassName, onPress, children }, ref) => {\n const ctx = useColorPicker('ColorPicker.Field');\n const slots = colorPickerVariants({ size: ctx.size });\n const diameter = FIELD_SWATCH[ctx.size];\n\n const read = useCallback(\n () =>\n formatColor(\n {\n h: ctx.hue.value,\n s: ctx.saturation.value,\n v: ctx.brightness.value,\n a: ctx.opacity.value,\n },\n ctx.format\n ),\n // Shared values are stable objects; `format` is the only real dependency.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [ctx.format]\n );\n\n const [printed, setPrinted] = useState(read);\n\n const format = ctx.format;\n useAnimatedReaction(\n () =>\n formatColor(\n {\n h: ctx.hue.value,\n s: ctx.saturation.value,\n v: ctx.brightness.value,\n a: ctx.opacity.value,\n },\n format\n ),\n (current, previous) => {\n if (current !== previous) runOnJS(setPrinted)(current);\n },\n [format]\n );\n\n const fillStyle = useAnimatedStyle(() => ({\n backgroundColor: hsvToCss(\n ctx.hue.value,\n ctx.saturation.value,\n ctx.brightness.value,\n ctx.opacity.value\n ),\n }));\n\n // Pressable only when it has somewhere to go. A row that highlights under\n // a finger and then does nothing is a worse lie than a row that does not\n // react at all.\n const Row = onPress ? Pressable : View;\n\n return (\n \n {label ? {label} : null}\n {showValue ? (\n {printed.toUpperCase()}\n ) : null}\n \n {/* Only behind a translucent colour — a checkerboard under an opaque\n swatch is a pattern nobody can see, drawn every frame. */}\n \n \n \n {children}\n \n );\n }\n);\n\nColorPickerField.displayName = 'ColorPicker.Field';\n\n/* ------------------------------------------------------------------ *\n * Channel — the readout that names the track under it.\n * ------------------------------------------------------------------ */\n\n/** Which channel a readout is reading. */\nexport type ColorPickerChannel = 'hue' | 'saturation' | 'brightness' | 'alpha';\n\n/** What each channel is called, and how its number is written. */\nconst CHANNEL_LABEL: Record = {\n hue: 'Hue',\n saturation: 'Saturation',\n brightness: 'Brightness',\n alpha: 'Opacity',\n};\n\nexport interface ColorPickerChannelProps {\n className?: string;\n /** Which channel to read. */\n channel: ColorPickerChannel;\n /** Overrides the channel's own name. */\n label?: string;\n /**\n * Writes the number yourself. Receives degrees for `hue` and a percentage\n * for the other three, both already rounded.\n */\n format?: (value: number) => string;\n}\n\n/**\n * The line above a track: the number on the leading edge, what it names on the\n * trailing one.\n *\n * The number is the one thing in this component that has to reach JavaScript\n * during a drag, because text cannot be written from a worklet. It is rounded\n * first and only sent when the rounded value changes, so a slow drag across a\n * hue track sends 360 updates rather than one per frame.\n */\nconst ColorPickerChannelReadout = forwardRef(\n ({ className, channel, label, format }, ref) => {\n const ctx = useColorPicker('ColorPicker.Channel');\n const slots = colorPickerVariants({ size: ctx.size });\n\n const source =\n channel === 'hue'\n ? ctx.hue\n : channel === 'saturation'\n ? ctx.saturation\n : channel === 'brightness'\n ? ctx.brightness\n : ctx.opacity;\n\n const [value, setValue] = useState(() =>\n Math.round(channel === 'hue' ? source.value : source.value * 100)\n );\n\n useAnimatedReaction(\n () => Math.round(channel === 'hue' ? source.value : source.value * 100),\n (current, previous) => {\n if (current !== previous) runOnJS(setValue)(current);\n },\n [channel]\n );\n\n const text = format\n ? format(value)\n : channel === 'hue'\n ? `${value}°`\n : `${value}%`;\n\n return (\n \n {text}\n {label ?? CHANNEL_LABEL[channel]}\n \n );\n }\n);\n\nColorPickerChannelReadout.displayName = 'ColorPicker.Channel';\n\n/* ------------------------------------------------------------------ *\n * Brightness — the third channel, as a track.\n * ------------------------------------------------------------------ */\n\nexport interface ColorPickerBrightnessProps {\n className?: string;\n /** Extra classes for the draggable thumb. */\n thumbClassName?: string;\n}\n\n/**\n * Black to the colour at full brightness.\n *\n * The square carries saturation and brightness together, so a picker built\n * around it never needs this. A wheel carries hue and saturation instead, and\n * leaves brightness with nowhere to live — this is where it goes.\n */\nconst ColorPickerBrightness = forwardRef(\n ({ className, thumbClassName }, ref) => {\n const ctx = useColorPicker('ColorPicker.Brightness');\n const sign = useDirectionSign();\n const thumbSize = TRACK_THUMB[ctx.size];\n const width = useSharedValue(0);\n const [announced, setAnnounced] = useState(() =>\n Math.round(ctx.brightness.value * 100)\n );\n\n const settle = useCallback((v: number) => setAnnounced(Math.round(v * 100)), []);\n\n /*\n * The ramp's far end is the current hue at full brightness, so it has to be\n * re-declared from React when the hue moves. Unlike the hue ramp — which is\n * every hue and therefore never changes — this one is only two colours, so\n * rebuilding it is a two-element array rather than a gradient recompile.\n */\n const [hueEnd, setHueEnd] = useState(() =>\n hsvToCss(ctx.hue.value, ctx.saturation.value, 1, 1)\n );\n\n useAnimatedReaction(\n () => hsvToCss(ctx.hue.value, ctx.saturation.value, 1, 1),\n (current, previous) => {\n if (current !== previous) runOnJS(setHueEnd)(current);\n }\n );\n\n const apply = (x: number, commit: boolean) => {\n 'worklet';\n const v = trackFraction(x, width.value, thumbSize, sign);\n ctx.brightness.value = v;\n runOnJS(ctx.emit)(ctx.hue.value, ctx.saturation.value, v, ctx.opacity.value, commit);\n if (commit) {\n runOnJS(settle)(v);\n if (ctx.haptics) runOnJS(selectionTick)();\n }\n };\n\n const pan = Gesture.Pan()\n .enabled(!ctx.disabled)\n .activeOffsetX([-6, 6])\n .shouldCancelWhenOutside(false)\n .onUpdate((event) => apply(event.x, false))\n .onFinalize((event) => apply(event.x, true));\n\n const tap = Gesture.Tap()\n .enabled(!ctx.disabled)\n .maxDuration(250)\n .onEnd((event) => apply(event.x, true));\n\n const gesture = Gesture.Race(pan, tap);\n\n const thumbStyle = useAnimatedStyle(() => {\n const travel = Math.max(width.value - thumbSize, 0);\n return { transform: [{ translateX: ctx.brightness.value * travel * sign }] };\n });\n\n const thumbFillStyle = useAnimatedStyle(() => ({\n backgroundColor: hsvToCss(\n ctx.hue.value,\n ctx.saturation.value,\n ctx.brightness.value,\n 1\n ),\n }));\n\n const nudge = (dir: 1 | -1) => {\n const next = Math.min(Math.max(ctx.brightness.value + dir * NUDGE, 0), 1);\n ctx.brightness.value = withTiming(next, TIMING);\n settle(next);\n ctx.emit(ctx.hue.value, ctx.saturation.value, next, ctx.opacity.value, true);\n };\n\n return (\n {\n width.value = event.nativeEvent.layout.width;\n }}\n size={ctx.size}\n className={className}\n thumbClassName={thumbClassName}\n label=\"Brightness\"\n disabled={ctx.disabled}\n accessibilityValue={{\n min: 0,\n max: 100,\n now: announced,\n text: `${announced}%`,\n }}\n onAccessibilityAction={(event) => {\n const action = enabledColorPickerAction(\n event.nativeEvent.actionName,\n ctx.disabled\n );\n if (action === 'increment') nudge(1);\n else if (action === 'decrement') nudge(-1);\n }}\n >\n \n \n );\n }\n);\n\nColorPickerBrightness.displayName = 'ColorPicker.Brightness';\n\n/* ------------------------------------------------------------------ *\n * Wheel — hue around, saturation out.\n * ------------------------------------------------------------------ */\n\n/**\n * How many wedges the hue ring is cut into.\n *\n * There is no conic gradient here to draw a hue ring with, so it is\n * approximated by solid wedges. Three degrees each is under a pixel of colour\n * step at any size this is drawn at, and the wedges are given a hair of\n * overlap so the seams between them cannot show as lighter lines.\n */\nconst WHEEL_WEDGES = 120;\nconst WHEEL_OVERLAP = 0.6;\n\n/** Radius of the drawing, in the wheel's own viewBox units. */\nconst WHEEL_R = 50;\n\n/** The hue ring, built once — it is the same wheel in every picker. */\nconst WHEEL_PATHS = Array.from({ length: WHEEL_WEDGES }, (_, index) => {\n const step = 360 / WHEEL_WEDGES;\n const from = index * step;\n const to = from + step + WHEEL_OVERLAP;\n const rad = (deg: number) => (deg * Math.PI) / 180;\n const x1 = WHEEL_R + WHEEL_R * Math.cos(rad(from));\n const y1 = WHEEL_R + WHEEL_R * Math.sin(rad(from));\n const x2 = WHEEL_R + WHEEL_R * Math.cos(rad(to));\n const y2 = WHEEL_R + WHEEL_R * Math.sin(rad(to));\n return {\n d: `M ${WHEEL_R} ${WHEEL_R} L ${x1} ${y1} A ${WHEEL_R} ${WHEEL_R} 0 0 1 ${x2} ${y2} Z`,\n fill: hsvToCss(from, 1, 1, 1),\n };\n});\n\nexport interface ColorPickerWheelProps {\n className?: string;\n /** Diameter in points. Defaults to the picker's size. */\n size?: number;\n /** Extra classes for the draggable thumb. */\n thumbClassName?: string;\n}\n\n/**\n * The square, bent into a circle: hue is the angle and saturation is the\n * distance from the middle.\n *\n * It reads the same three shared values as `ColorPicker.Area`, so the two are\n * interchangeable — a picker is a wheel instead of a square, not a wheel as\n * well as one. What it does not carry is brightness, which has no third\n * dimension to occupy here; pair it with `ColorPicker.Brightness`.\n *\n * The wheel is deliberately not mirrored under RTL. A track has a start and an\n * end and so has a direction to read it in; a wheel has neither, and flipping\n * which way round the spectrum runs would only make the same colour sit\n * somewhere else.\n */\nconst ColorPickerWheel = forwardRef(\n ({ className, size, thumbClassName }, ref) => {\n const ctx = useColorPicker('ColorPicker.Wheel');\n const slots = colorPickerVariants({ size: ctx.size });\n const gradientId = useId().replace(/[^a-zA-Z0-9]/g, '');\n\n const diameter = size ?? WHEEL_SIZE[ctx.size];\n const thumbSize = AREA_THUMB[ctx.size];\n const radius = diameter / 2;\n\n const [announced, setAnnounced] = useState(() => ({\n h: Math.round(ctx.hue.value),\n s: Math.round(ctx.saturation.value * 100),\n }));\n\n const settle = useCallback((h: number, s: number) => {\n setAnnounced({ h: Math.round(h), s: Math.round(s * 100) });\n }, []);\n\n const apply = (x: number, y: number, commit: boolean) => {\n 'worklet';\n const dx = x - radius;\n const dy = y - radius;\n // Clockwise from three o'clock, which is what `atan2` already gives in a\n // coordinate space whose y runs downwards — so the wedges above and the\n // touch here agree without either of them correcting for the other.\n const degrees = (Math.atan2(dy, dx) * 180) / Math.PI;\n const h = (degrees + 360) % 360;\n // Past the rim the colour is the one on the rim, rather than nothing:\n // a finger that overshoots the edge of a disc has still chosen a hue.\n const s = Math.min(Math.sqrt(dx * dx + dy * dy) / radius, 1);\n\n ctx.hue.value = h;\n ctx.saturation.value = s;\n runOnJS(ctx.emit)(h, s, ctx.brightness.value, ctx.opacity.value, commit);\n if (commit) {\n runOnJS(settle)(h, s);\n if (ctx.haptics) runOnJS(selectionTick)();\n }\n };\n\n const pan = Gesture.Pan()\n .enabled(!ctx.disabled)\n .minDistance(0)\n .shouldCancelWhenOutside(false)\n .onBegin((event) => apply(event.x, event.y, false))\n .onUpdate((event) => apply(event.x, event.y, false))\n .onFinalize((event) => apply(event.x, event.y, true));\n\n const thumbStyle = useAnimatedStyle(() => {\n const angle = (ctx.hue.value * Math.PI) / 180;\n const r = ctx.saturation.value * radius;\n return {\n transform: [\n { translateX: radius + r * Math.cos(angle) - thumbSize / 2 },\n { translateY: radius + r * Math.sin(angle) - thumbSize / 2 },\n ],\n };\n });\n\n const thumbFillStyle = useAnimatedStyle(() => ({\n backgroundColor: hsvToCss(\n ctx.hue.value,\n ctx.saturation.value,\n ctx.brightness.value,\n 1\n ),\n }));\n\n // Brightness is not drawn into the wheel — it darkens the whole of it,\n // which is what brightness does.\n const dimStyle = useAnimatedStyle(() => ({\n opacity: 1 - ctx.brightness.value,\n }));\n\n const nudge = (channel: 'hue' | 'saturation', dir: 1 | -1) => {\n if (channel === 'hue') {\n const next = (ctx.hue.value + dir * HUE_NUDGE + 360) % 360;\n ctx.hue.value = next;\n settle(next, ctx.saturation.value);\n ctx.emit(next, ctx.saturation.value, ctx.brightness.value, ctx.opacity.value, true);\n return;\n }\n const next = Math.min(Math.max(ctx.saturation.value + dir * NUDGE, 0), 1);\n ctx.saturation.value = withTiming(next, TIMING);\n settle(ctx.hue.value, next);\n ctx.emit(ctx.hue.value, next, ctx.brightness.value, ctx.opacity.value, true);\n };\n\n const onAccessibilityAction = (event: AccessibilityActionEvent) => {\n const action = enabledColorPickerAction(\n event.nativeEvent.actionName,\n ctx.disabled\n );\n if (action === 'increment') nudge('hue', 1);\n else if (action === 'decrement') nudge('hue', -1);\n else if (action === 'saturate') nudge('saturation', 1);\n else if (action === 'desaturate') nudge('saturation', -1);\n };\n\n return (\n \n {/* Square box, round drawing: the thumb travels to the rim and has to\n be drawn whole when it gets there, so the box is not clipped and\n only the wheel inside it is round. */}\n \n \n \n \n \n \n \n \n \n {WHEEL_PATHS.map((wedge, index) => (\n \n ))}\n {/* White at the middle falling off to nothing at the rim — the\n same ramp the square runs across, wrapped around a centre. */}\n \n \n \n \n\n \n \n \n \n \n );\n }\n);\n\nColorPickerWheel.displayName = 'ColorPicker.Wheel';\n\nexport const ColorPicker = Object.assign(ColorPickerRoot, {\n Trigger: ColorPickerTrigger,\n Content: ColorPickerContent,\n Field: ColorPickerField,\n Area: ColorPickerArea,\n Wheel: ColorPickerWheel,\n Channel: ColorPickerChannelReadout,\n Hue: ColorPickerHue,\n Brightness: ColorPickerBrightness,\n Alpha: ColorPickerAlpha,\n Preview: ColorPickerPreview,\n Swatches: ColorPickerSwatches,\n});\n" } ] } diff --git a/apps/docs/public/r/countdown.json b/apps/docs/public/r/countdown.json index 8d7ac058b..082d207db 100644 --- a/apps/docs/public/r/countdown.json +++ b/apps/docs/public/r/countdown.json @@ -4,6 +4,7 @@ "description": "The time left until a moment, ticking down to it.", "registryDependencies": [ "cn", + "refs", "text" ], "dependencies": [ @@ -20,7 +21,7 @@ { "path": "ui/countdown.tsx", "type": "registry:ui", - "content": "/**\n * Countdown — the time left until a moment, ticking.\n *\n * ```tsx\n * \n * \n * ```\n *\n * The clock is read, not counted. Every tick works out what is left from\n * `Date.now()` and the target, and the next tick is scheduled for the moment\n * the displayed second turns over. A countdown that subtracted one per\n * interval would drift by however late each timer fired, and would be wrong\n * by the whole time the app spent in the background.\n *\n * Each digit is its own keyed view: when it changes, the old one slides out\n * downward and the new one drops in from above, on the UI thread. Only the\n * digits that changed move, so a ticking clock is one column moving, not the\n * whole number re-rendering. Nothing moves on the first render, and nothing\n * moves at all under reduce motion.\n *\n * The screen-reader label is coarser than the display — minutes until the\n * last minute, then seconds — because a label that changed every second\n * would be re-read every second. See `countdown-time.ts`.\n */\nimport { forwardRef, useEffect, useRef, useState } from 'react';\nimport { View, type ViewProps } from 'react-native';\nimport Animated, {\n Easing,\n useReducedMotion,\n withTiming,\n type EntryAnimationsValues,\n type ExitAnimationsValues,\n} from 'react-native-reanimated';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport { Text } from '@/components/ui/text';\nimport { cn } from '@/lib/cn';\nimport {\n describeSeconds,\n msUntilNextTick,\n secondsLeft,\n splitSeconds,\n trimLeading,\n type CountdownPart,\n type CountdownUnit,\n} from '@/components/ui/countdown-time';\n\nexport type { CountdownUnit } from '@/components/ui/countdown-time';\n\n/** How long a digit takes to change. Well inside the second it is shown for. */\nconst DIGIT_MS = 260;\nconst EASE = Easing.out(Easing.cubic);\n\n/*\n * The new digit drops into its slot from one digit-height above while the old\n * one drops out by one digit-height below, so the two travel together like an\n * odometer wheel and are never on top of each other. A fixed distance would\n * overlap them at large sizes and overshoot the window at small ones, so the\n * distance is the measured height of the digit itself.\n */\nfunction enter(values: EntryAnimationsValues) {\n 'worklet';\n return {\n initialValues: { originY: values.targetOriginY - values.targetHeight, opacity: 0 },\n animations: {\n originY: withTiming(values.targetOriginY, { duration: DIGIT_MS, easing: EASE }),\n opacity: withTiming(1, { duration: DIGIT_MS, easing: EASE }),\n },\n };\n}\n\nfunction exit(values: ExitAnimationsValues) {\n 'worklet';\n return {\n initialValues: { originY: values.currentOriginY, opacity: 1 },\n animations: {\n originY: withTiming(values.currentOriginY + values.currentHeight, {\n duration: DIGIT_MS,\n easing: EASE,\n }),\n opacity: withTiming(0, { duration: DIGIT_MS, easing: EASE }),\n },\n };\n}\n\nconst countdownVariants = tv({\n slots: {\n root: 'flex-row items-start',\n segment: 'items-center',\n digits: 'flex-row',\n digit: 'font-semibold tabular-nums text-foreground',\n label: 'font-medium uppercase tracking-wider text-muted-foreground',\n separator: 'font-semibold tabular-nums text-muted-foreground',\n suffix: 'font-semibold text-muted-foreground',\n },\n variants: {\n /**\n * `segmented` puts each unit in its own box with its name under it — a\n * launch page, a hero, anything the countdown is the point of.\n * `inline` is a line of text, `2d 14:03:22`, for a banner, a button\n * caption or a row.\n */\n variant: {\n segmented: {\n root: 'gap-2',\n segment: 'gap-1.5 rounded-xl border border-border bg-card shadow-sm',\n },\n inline: {\n root: 'items-baseline',\n segment: 'flex-row items-baseline',\n },\n },\n size: {\n sm: { digit: 'text-lg', label: 'text-[10px]', separator: 'text-lg', suffix: 'text-sm' },\n md: { digit: 'text-3xl', label: 'text-[10px]', separator: 'text-3xl', suffix: 'text-xl' },\n lg: { digit: 'text-4xl', label: 'text-[11px]', separator: 'text-4xl', suffix: 'text-2xl' },\n },\n },\n compoundVariants: [\n { variant: 'segmented', size: 'sm', class: { segment: 'min-w-12 px-2 py-1.5' } },\n { variant: 'segmented', size: 'md', class: { segment: 'min-w-16 px-3 py-2.5' } },\n { variant: 'segmented', size: 'lg', class: { segment: 'min-w-16 px-3 py-3' } },\n ],\n defaultVariants: { variant: 'segmented', size: 'md' },\n});\n\ntype CountdownVariantProps = VariantProps;\n\n/**\n * The urgent colour. A class added on top rather than a `tv()` variant: it is\n * derived from `urgentBelow` and the clock, not passed, and a variant here\n * would be documented as a prop.\n */\nconst URGENT = { digit: 'text-destructive', separator: 'text-destructive/60' };\n\nconst DEFAULT_LABELS: Record = {\n days: 'Days',\n hours: 'Hours',\n minutes: 'Minutes',\n seconds: 'Seconds',\n};\n\nconst SUFFIX: Record = { days: 'd', hours: 'h', minutes: 'm', seconds: 's' };\n\nexport interface CountdownProps extends Omit, CountdownVariantProps {\n className?: string;\n /** The moment to count down to, as a `Date` or epoch milliseconds. */\n to: Date | number;\n /**\n * Which units to show, largest to smallest. The largest one absorbs\n * everything above it — `['hours', 'minutes', 'seconds']` shows two days as\n * 48 hours. Defaults to all four.\n */\n units?: CountdownUnit[];\n /**\n * Drop leading units while they are zero, so a launch three hours away has\n * no \"00 days\" box. The smallest two always stay.\n */\n trim?: boolean;\n /** Names under each box in `segmented`. Pass any subset to translate them. */\n labels?: Partial>;\n /**\n * Turn the digits to the destructive colour once this many seconds or fewer\n * are left — the last five minutes of a sale, the last ten seconds of a bid.\n */\n urgentBelow?: number;\n /** Called once when the time is up, including on mount if it already is. */\n onComplete?: () => void;\n /** Called with the whole seconds left each time the display changes. */\n onTick?: (secondsLeft: number) => void;\n}\n\n/** One digit in a window its own height, swapped with a slide when it changes. */\nfunction Digit({\n value,\n animate,\n className,\n}: {\n value: string;\n animate: boolean;\n className: string;\n}) {\n return (\n \n {/* In flow and invisible: it gives the window a digit's width and height. */}\n 0\n \n {value}\n \n \n );\n}\n\nfunction Digits({\n part,\n animate,\n className,\n}: {\n part: CountdownPart;\n animate: boolean;\n className: string;\n}) {\n const slots = countdownVariants();\n const text = String(part.value).padStart(2, '0');\n return (\n \n {text.split('').map((char, index) => (\n // Keyed by place from the right, so a days count going from 10 to 9\n // keeps the ones column it already had.\n \n ))}\n \n );\n}\n\nconst CountdownRoot = forwardRef(function Countdown(\n {\n className,\n to,\n variant = 'segmented',\n size = 'md',\n units,\n trim = true,\n labels,\n urgentBelow,\n onComplete,\n onTick,\n ...props\n },\n ref\n) {\n const target = typeof to === 'number' ? to : to.getTime();\n const [left, setLeft] = useState(() => secondsLeft(target, Date.now()));\n\n // Callbacks through refs, so a new arrow function from the parent on every\n // render does not restart the clock.\n const completeRef = useRef(onComplete);\n const tickRef = useRef(onTick);\n completeRef.current = onComplete;\n tickRef.current = onTick;\n\n // No animation on the first render: the numbers are arriving, not changing.\n const mounted = useRef(false);\n useEffect(() => {\n mounted.current = true;\n }, []);\n\n useEffect(() => {\n let timer: ReturnType | undefined;\n let done = false;\n const tick = () => {\n const now = Date.now();\n const next = secondsLeft(target, now);\n setLeft(next);\n tickRef.current?.(next);\n if (next === 0) {\n if (!done) {\n done = true;\n completeRef.current?.();\n }\n return;\n }\n // A few milliseconds past the turn, so the read lands in the new second.\n timer = setTimeout(tick, msUntilNextTick(target, now) + 8);\n };\n tick();\n return () => clearTimeout(timer);\n }, [target]);\n\n const urgent = urgentBelow != null && left > 0 && left <= urgentBelow;\n const variants = countdownVariants({ variant, size });\n const slots = {\n ...variants,\n digit: () => cn(variants.digit(), urgent && URGENT.digit),\n separator: () => cn(variants.separator(), urgent && URGENT.separator),\n };\n const split = splitSeconds(left, units);\n const parts = trim ? trimLeading(split) : split;\n // Custom layout animations do not read the system setting on their own.\n const reduced = useReducedMotion();\n const animate = mounted.current && !reduced;\n const names = { ...DEFAULT_LABELS, ...labels };\n\n return (\n \n {parts.map((part, index) => {\n if (variant === 'inline') {\n // Days read as \"2d\" ahead of a clock; the rest as hh:mm:ss.\n const isDays = part.unit === 'days';\n const nextIsClock = parts[index + 1] && parts[index + 1]!.unit !== 'days';\n return (\n \n {isDays ? (\n <>\n {part.value}\n {SUFFIX.days} \n \n ) : (\n <>\n \n {nextIsClock ? : : null}\n \n )}\n \n );\n }\n\n return (\n \n \n {names[part.unit]}\n \n );\n })}\n \n );\n});\n\nexport const Countdown = CountdownRoot;\n" + "content": "/**\n * Countdown — the time left until a moment, ticking.\n *\n * ```tsx\n * \n * \n * ```\n *\n * The clock is read, not counted. Every tick works out what is left from\n * `Date.now()` and the target, and the next tick is scheduled for the moment\n * the displayed second turns over. A countdown that subtracted one per\n * interval would drift by however late each timer fired, and would be wrong\n * by the whole time the app spent in the background.\n *\n * Each digit is its own keyed view: when it changes, the old one slides out\n * downward and the new one drops in from above, on the UI thread. Only the\n * digits that changed move, so a ticking clock is one column moving, not the\n * whole number re-rendering. Nothing moves on the first render, and nothing\n * moves at all under reduce motion.\n *\n * The screen-reader label is coarser than the display — minutes until the\n * last minute, then seconds — because a label that changed every second\n * would be re-read every second. See `countdown-time.ts`.\n */\nimport { forwardRef, useEffect, useRef, useState } from 'react';\nimport { View, type ViewProps } from 'react-native';\nimport Animated, {\n Easing,\n useReducedMotion,\n withTiming,\n type EntryAnimationsValues,\n type ExitAnimationsValues,\n} from 'react-native-reanimated';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport { Text } from '@/components/ui/text';\nimport { cn } from '@/lib/cn';\nimport {\n describeSeconds,\n msUntilNextTick,\n secondsLeft,\n splitSeconds,\n trimLeading,\n type CountdownPart,\n type CountdownUnit,\n} from '@/components/ui/countdown-time';\nimport type { ViewRef } from '@/components/ui/refs';\n\nexport type { CountdownUnit } from '@/components/ui/countdown-time';\n\n/** How long a digit takes to change. Well inside the second it is shown for. */\nconst DIGIT_MS = 260;\nconst EASE = Easing.out(Easing.cubic);\n\n/*\n * The new digit drops into its slot from one digit-height above while the old\n * one drops out by one digit-height below, so the two travel together like an\n * odometer wheel and are never on top of each other. A fixed distance would\n * overlap them at large sizes and overshoot the window at small ones, so the\n * distance is the measured height of the digit itself.\n */\nfunction enter(values: EntryAnimationsValues) {\n 'worklet';\n return {\n initialValues: { originY: values.targetOriginY - values.targetHeight, opacity: 0 },\n animations: {\n originY: withTiming(values.targetOriginY, { duration: DIGIT_MS, easing: EASE }),\n opacity: withTiming(1, { duration: DIGIT_MS, easing: EASE }),\n },\n };\n}\n\nfunction exit(values: ExitAnimationsValues) {\n 'worklet';\n return {\n initialValues: { originY: values.currentOriginY, opacity: 1 },\n animations: {\n originY: withTiming(values.currentOriginY + values.currentHeight, {\n duration: DIGIT_MS,\n easing: EASE,\n }),\n opacity: withTiming(0, { duration: DIGIT_MS, easing: EASE }),\n },\n };\n}\n\nconst countdownVariants = tv({\n slots: {\n root: 'flex-row items-start',\n segment: 'items-center',\n digits: 'flex-row',\n digit: 'font-semibold tabular-nums text-foreground',\n label: 'font-medium uppercase tracking-wider text-muted-foreground',\n separator: 'font-semibold tabular-nums text-muted-foreground',\n suffix: 'font-semibold text-muted-foreground',\n },\n variants: {\n /**\n * `segmented` puts each unit in its own box with its name under it — a\n * launch page, a hero, anything the countdown is the point of.\n * `inline` is a line of text, `2d 14:03:22`, for a banner, a button\n * caption or a row.\n */\n variant: {\n segmented: {\n root: 'gap-2',\n segment: 'gap-1.5 rounded-xl border border-border bg-card shadow-sm',\n },\n inline: {\n root: 'items-baseline',\n segment: 'flex-row items-baseline',\n },\n },\n size: {\n sm: { digit: 'text-lg', label: 'text-[10px]', separator: 'text-lg', suffix: 'text-sm' },\n md: { digit: 'text-3xl', label: 'text-[10px]', separator: 'text-3xl', suffix: 'text-xl' },\n lg: { digit: 'text-4xl', label: 'text-[11px]', separator: 'text-4xl', suffix: 'text-2xl' },\n },\n },\n compoundVariants: [\n { variant: 'segmented', size: 'sm', class: { segment: 'min-w-12 px-2 py-1.5' } },\n { variant: 'segmented', size: 'md', class: { segment: 'min-w-16 px-3 py-2.5' } },\n { variant: 'segmented', size: 'lg', class: { segment: 'min-w-16 px-3 py-3' } },\n ],\n defaultVariants: { variant: 'segmented', size: 'md' },\n});\n\ntype CountdownVariantProps = VariantProps;\n\n/**\n * The urgent colour. A class added on top rather than a `tv()` variant: it is\n * derived from `urgentBelow` and the clock, not passed, and a variant here\n * would be documented as a prop.\n */\nconst URGENT = { digit: 'text-destructive', separator: 'text-destructive/60' };\n\nconst DEFAULT_LABELS: Record = {\n days: 'Days',\n hours: 'Hours',\n minutes: 'Minutes',\n seconds: 'Seconds',\n};\n\nconst SUFFIX: Record = { days: 'd', hours: 'h', minutes: 'm', seconds: 's' };\n\nexport interface CountdownProps extends Omit, CountdownVariantProps {\n className?: string;\n /** The moment to count down to, as a `Date` or epoch milliseconds. */\n to: Date | number;\n /**\n * Which units to show, largest to smallest. The largest one absorbs\n * everything above it — `['hours', 'minutes', 'seconds']` shows two days as\n * 48 hours. Defaults to all four.\n */\n units?: CountdownUnit[];\n /**\n * Drop leading units while they are zero, so a launch three hours away has\n * no \"00 days\" box. The smallest two always stay.\n */\n trim?: boolean;\n /** Names under each box in `segmented`. Pass any subset to translate them. */\n labels?: Partial>;\n /**\n * Turn the digits to the destructive colour once this many seconds or fewer\n * are left — the last five minutes of a sale, the last ten seconds of a bid.\n */\n urgentBelow?: number;\n /** Called once when the time is up, including on mount if it already is. */\n onComplete?: () => void;\n /** Called with the whole seconds left each time the display changes. */\n onTick?: (secondsLeft: number) => void;\n}\n\n/** One digit in a window its own height, swapped with a slide when it changes. */\nfunction Digit({\n value,\n animate,\n className,\n}: {\n value: string;\n animate: boolean;\n className: string;\n}) {\n return (\n \n {/* In flow and invisible: it gives the window a digit's width and height. */}\n 0\n \n {value}\n \n \n );\n}\n\nfunction Digits({\n part,\n animate,\n className,\n}: {\n part: CountdownPart;\n animate: boolean;\n className: string;\n}) {\n const slots = countdownVariants();\n const text = String(part.value).padStart(2, '0');\n return (\n \n {text.split('').map((char, index) => (\n // Keyed by place from the right, so a days count going from 10 to 9\n // keeps the ones column it already had.\n \n ))}\n \n );\n}\n\nconst CountdownRoot = forwardRef(function Countdown(\n {\n className,\n to,\n variant = 'segmented',\n size = 'md',\n units,\n trim = true,\n labels,\n urgentBelow,\n onComplete,\n onTick,\n ...props\n },\n ref\n) {\n const target = typeof to === 'number' ? to : to.getTime();\n const [left, setLeft] = useState(() => secondsLeft(target, Date.now()));\n\n // Callbacks through refs, so a new arrow function from the parent on every\n // render does not restart the clock.\n const completeRef = useRef(onComplete);\n const tickRef = useRef(onTick);\n completeRef.current = onComplete;\n tickRef.current = onTick;\n\n // No animation on the first render: the numbers are arriving, not changing.\n const mounted = useRef(false);\n useEffect(() => {\n mounted.current = true;\n }, []);\n\n useEffect(() => {\n let timer: ReturnType | undefined;\n let done = false;\n const tick = () => {\n const now = Date.now();\n const next = secondsLeft(target, now);\n setLeft(next);\n tickRef.current?.(next);\n if (next === 0) {\n if (!done) {\n done = true;\n completeRef.current?.();\n }\n return;\n }\n // A few milliseconds past the turn, so the read lands in the new second.\n timer = setTimeout(tick, msUntilNextTick(target, now) + 8);\n };\n tick();\n return () => clearTimeout(timer);\n }, [target]);\n\n const urgent = urgentBelow != null && left > 0 && left <= urgentBelow;\n const variants = countdownVariants({ variant, size });\n const slots = {\n ...variants,\n digit: () => cn(variants.digit(), urgent && URGENT.digit),\n separator: () => cn(variants.separator(), urgent && URGENT.separator),\n };\n const split = splitSeconds(left, units);\n const parts = trim ? trimLeading(split) : split;\n // Custom layout animations do not read the system setting on their own.\n const reduced = useReducedMotion();\n const animate = mounted.current && !reduced;\n const names = { ...DEFAULT_LABELS, ...labels };\n\n return (\n \n {parts.map((part, index) => {\n if (variant === 'inline') {\n // Days read as \"2d\" ahead of a clock; the rest as hh:mm:ss.\n const isDays = part.unit === 'days';\n const nextIsClock = parts[index + 1] && parts[index + 1]!.unit !== 'days';\n return (\n \n {isDays ? (\n <>\n {part.value}\n {SUFFIX.days} \n \n ) : (\n <>\n \n {nextIsClock ? : : null}\n \n )}\n \n );\n }\n\n return (\n \n \n {names[part.unit]}\n \n );\n })}\n \n );\n});\n\nexport const Countdown = CountdownRoot;\n" } ] } diff --git a/apps/docs/public/r/dialog.json b/apps/docs/public/r/dialog.json index fc1947901..bb6647a96 100644 --- a/apps/docs/public/r/dialog.json +++ b/apps/docs/public/r/dialog.json @@ -5,6 +5,7 @@ "registryDependencies": [ "cn", "portal", + "refs", "scrim", "text", "use-back-handler" @@ -18,7 +19,7 @@ { "path": "ui/dialog.tsx", "type": "registry:ui", - "content": "import {\n cloneElement,\n createContext,\n forwardRef,\n isValidElement,\n useCallback,\n useContext,\n useMemo,\n useState,\n type ReactElement,\n type ReactNode,\n} from 'react';\nimport { Pressable, View, type ViewProps } from 'react-native';\nimport Animated, { FadeIn, FadeOut, ZoomIn } from 'react-native-reanimated';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport { ModalPortal } from '@/components/ui/portal';\nimport { Scrim } from '@/components/ui/scrim';\nimport { Text, type TextProps, textChildren } from '@/components/ui/text';\nimport { useBackHandler } from '@/hooks/use-back-handler';\nimport { cn } from '@/lib/cn';\n\ninterface DialogContextValue {\n open: boolean;\n setOpen: (open: boolean) => void;\n}\n\nconst DialogContext = createContext(null);\n\nfunction useDialog(component: string): DialogContextValue {\n const context = useContext(DialogContext);\n if (!context) {\n throw new Error(`${component} must be used within a `);\n }\n return context;\n}\n\nexport interface DialogProps {\n children: ReactNode;\n /** Controlled open state. */\n open?: boolean;\n onOpenChange?: (open: boolean) => void;\n /** Initial state when uncontrolled. */\n defaultOpen?: boolean;\n}\n\nfunction DialogRoot({ children, open, onOpenChange, defaultOpen = false }: DialogProps) {\n const [internalOpen, setInternalOpen] = useState(defaultOpen);\n const isControlled = open !== undefined;\n const resolvedOpen = isControlled ? open : internalOpen;\n\n const setOpen = useCallback(\n (next: boolean) => {\n if (!isControlled) setInternalOpen(next);\n onOpenChange?.(next);\n },\n [isControlled, onOpenChange]\n );\n\n const context = useMemo(\n () => ({ open: resolvedOpen, setOpen }),\n [resolvedOpen, setOpen]\n );\n\n return {children};\n}\n\ninterface DialogTriggerProps {\n children: ReactElement<{ onPress?: (...args: unknown[]) => void }>;\n}\n\n/** Wraps its child and opens the dialog on press. */\nfunction DialogTrigger({ children }: DialogTriggerProps) {\n const { setOpen } = useDialog('Dialog.Trigger');\n if (!isValidElement(children)) return children;\n\n return cloneElement(children, {\n onPress: (...args: unknown[]) => {\n children.props.onPress?.(...args);\n setOpen(true);\n },\n });\n}\n\nexport interface DialogContentProps extends ViewProps {\n className?: string;\n /** Tap on the backdrop closes the dialog. Default true. */\n dismissible?: boolean;\n /**\n * Frost the screen behind the dialog instead of dimming it. Uses `expo-blur`\n * when installed and falls back to the dim when it is not, so it is safe to\n * pass either way.\n *\n * Someone who has Reduce Transparency switched on gets an opaque\n * backdrop instead, which is the whole point of the setting.\n */\n blur?: boolean;\n children?: ReactNode;\n}\n\nfunction DialogContent({\n className,\n dismissible = true,\n blur = false,\n children,\n ...props\n}: DialogContentProps) {\n const context = useDialog('Dialog.Content');\n const { open, setOpen } = context;\n\n // While the dialog is up, the Android back button closes it rather than\n // popping the screen behind — but only when tapping outside would too.\n useBackHandler(open && dismissible, () => setOpen(false));\n\n if (!open) return null;\n\n return (\n \n {/* Portal content mounts under PortalHost, outside this provider's\n subtree — re-provide the context so Dialog.Close etc. keep working. */}\n \n \n \n setOpen(false) : undefined}\n />\n \n {textChildren(children)}\n \n \n \n \n );\n}\n\nconst DialogTitle = forwardRef, TextProps>(\n ({ className, ...props }, ref) => (\n \n )\n);\nDialogTitle.displayName = 'Dialog.Title';\n\nconst DialogDescription = forwardRef, TextProps>(\n ({ className, ...props }, ref) => (\n \n )\n);\nDialogDescription.displayName = 'Dialog.Description';\n\n/**\n * The dialog's padding, in points. The panel footer bleeds back out through it,\n * so the two have to agree — a mismatch is a band inset from one edge.\n */\nconst DIALOG_PADDING = 20;\n\nconst dialogFooterVariants = tv({\n base: 'flex-row items-center justify-end gap-2',\n variants: {\n /**\n * Whether the footer draws a surface of its own.\n *\n * `plain` is part of the dialog: same background, sitting under the content\n * with a gap above it. `panel` is a band set into it — a rule across the\n * top, a step darker, and the dialog's own bottom corners — which separates\n * what the dialog says from what you can do about it. Worth it on a dialog\n * with a form in it, where the buttons are otherwise one more row of it.\n */\n variant: {\n plain: 'mt-4',\n panel: 'mt-5 rounded-b-2xl border-t border-border bg-inset',\n },\n },\n defaultVariants: {\n variant: 'plain',\n },\n});\n\nexport interface DialogFooterProps extends ViewProps, VariantProps {\n className?: string;\n}\n\nfunction DialogFooter({ className, variant, style, ...props }: DialogFooterProps) {\n return (\n \n );\n}\n\ninterface DialogCloseProps {\n children: ReactElement<{ onPress?: (...args: unknown[]) => void }>;\n}\n\n/** Wraps its child and closes the dialog on press. */\nfunction DialogClose({ children }: DialogCloseProps) {\n const { setOpen } = useDialog('Dialog.Close');\n if (!isValidElement(children)) return children;\n\n return cloneElement(children, {\n onPress: (...args: unknown[]) => {\n children.props.onPress?.(...args);\n setOpen(false);\n },\n });\n}\n\nexport const Dialog = Object.assign(DialogRoot, {\n Trigger: DialogTrigger,\n Content: DialogContent,\n Title: DialogTitle,\n Description: DialogDescription,\n Footer: DialogFooter,\n Close: DialogClose,\n});\n" + "content": "import {\n cloneElement,\n createContext,\n forwardRef,\n isValidElement,\n useCallback,\n useContext,\n useMemo,\n useState,\n type ReactElement,\n type ReactNode,\n} from 'react';\nimport { Pressable, View, type ViewProps } from 'react-native';\nimport Animated, { FadeIn, FadeOut, ZoomIn } from 'react-native-reanimated';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport { ModalPortal } from '@/components/ui/portal';\nimport { Scrim } from '@/components/ui/scrim';\nimport { Text, type TextProps, textChildren } from '@/components/ui/text';\nimport { useBackHandler } from '@/hooks/use-back-handler';\nimport { cn } from '@/lib/cn';\nimport type { TextRef } from '@/components/ui/refs';\n\ninterface DialogContextValue {\n open: boolean;\n setOpen: (open: boolean) => void;\n}\n\nconst DialogContext = createContext(null);\n\nfunction useDialog(component: string): DialogContextValue {\n const context = useContext(DialogContext);\n if (!context) {\n throw new Error(`${component} must be used within a `);\n }\n return context;\n}\n\nexport interface DialogProps {\n children: ReactNode;\n /** Controlled open state. */\n open?: boolean;\n onOpenChange?: (open: boolean) => void;\n /** Initial state when uncontrolled. */\n defaultOpen?: boolean;\n}\n\nfunction DialogRoot({ children, open, onOpenChange, defaultOpen = false }: DialogProps) {\n const [internalOpen, setInternalOpen] = useState(defaultOpen);\n const isControlled = open !== undefined;\n const resolvedOpen = isControlled ? open : internalOpen;\n\n const setOpen = useCallback(\n (next: boolean) => {\n if (!isControlled) setInternalOpen(next);\n onOpenChange?.(next);\n },\n [isControlled, onOpenChange]\n );\n\n const context = useMemo(\n () => ({ open: resolvedOpen, setOpen }),\n [resolvedOpen, setOpen]\n );\n\n return {children};\n}\n\ninterface DialogTriggerProps {\n children: ReactElement<{ onPress?: (...args: unknown[]) => void }>;\n}\n\n/** Wraps its child and opens the dialog on press. */\nfunction DialogTrigger({ children }: DialogTriggerProps) {\n const { setOpen } = useDialog('Dialog.Trigger');\n if (!isValidElement(children)) return children;\n\n return cloneElement(children, {\n onPress: (...args: unknown[]) => {\n children.props.onPress?.(...args);\n setOpen(true);\n },\n });\n}\n\nexport interface DialogContentProps extends ViewProps {\n className?: string;\n /** Tap on the backdrop closes the dialog. Default true. */\n dismissible?: boolean;\n /**\n * Frost the screen behind the dialog instead of dimming it. Uses `expo-blur`\n * when installed and falls back to the dim when it is not, so it is safe to\n * pass either way.\n *\n * Someone who has Reduce Transparency switched on gets an opaque\n * backdrop instead, which is the whole point of the setting.\n */\n blur?: boolean;\n children?: ReactNode;\n}\n\nfunction DialogContent({\n className,\n dismissible = true,\n blur = false,\n children,\n ...props\n}: DialogContentProps) {\n const context = useDialog('Dialog.Content');\n const { open, setOpen } = context;\n\n // While the dialog is up, the Android back button closes it rather than\n // popping the screen behind — but only when tapping outside would too.\n useBackHandler(open && dismissible, () => setOpen(false));\n\n if (!open) return null;\n\n return (\n \n {/* Portal content mounts under PortalHost, outside this provider's\n subtree — re-provide the context so Dialog.Close etc. keep working. */}\n \n \n \n setOpen(false) : undefined}\n />\n \n {textChildren(children)}\n \n \n \n \n );\n}\n\nconst DialogTitle = forwardRef(\n ({ className, ...props }, ref) => (\n \n )\n);\nDialogTitle.displayName = 'Dialog.Title';\n\nconst DialogDescription = forwardRef(\n ({ className, ...props }, ref) => (\n \n )\n);\nDialogDescription.displayName = 'Dialog.Description';\n\n/**\n * The dialog's padding, in points. The panel footer bleeds back out through it,\n * so the two have to agree — a mismatch is a band inset from one edge.\n */\nconst DIALOG_PADDING = 20;\n\nconst dialogFooterVariants = tv({\n base: 'flex-row items-center justify-end gap-2',\n variants: {\n /**\n * Whether the footer draws a surface of its own.\n *\n * `plain` is part of the dialog: same background, sitting under the content\n * with a gap above it. `panel` is a band set into it — a rule across the\n * top, a step darker, and the dialog's own bottom corners — which separates\n * what the dialog says from what you can do about it. Worth it on a dialog\n * with a form in it, where the buttons are otherwise one more row of it.\n */\n variant: {\n plain: 'mt-4',\n panel: 'mt-5 rounded-b-2xl border-t border-border bg-inset',\n },\n },\n defaultVariants: {\n variant: 'plain',\n },\n});\n\nexport interface DialogFooterProps extends ViewProps, VariantProps {\n className?: string;\n}\n\nfunction DialogFooter({ className, variant, style, ...props }: DialogFooterProps) {\n return (\n \n );\n}\n\ninterface DialogCloseProps {\n children: ReactElement<{ onPress?: (...args: unknown[]) => void }>;\n}\n\n/** Wraps its child and closes the dialog on press. */\nfunction DialogClose({ children }: DialogCloseProps) {\n const { setOpen } = useDialog('Dialog.Close');\n if (!isValidElement(children)) return children;\n\n return cloneElement(children, {\n onPress: (...args: unknown[]) => {\n children.props.onPress?.(...args);\n setOpen(false);\n },\n });\n}\n\nexport const Dialog = Object.assign(DialogRoot, {\n Trigger: DialogTrigger,\n Content: DialogContent,\n Title: DialogTitle,\n Description: DialogDescription,\n Footer: DialogFooter,\n Close: DialogClose,\n});\n" } ] } diff --git a/apps/docs/public/r/empty-state.json b/apps/docs/public/r/empty-state.json index c3950cfec..a505dcaeb 100644 --- a/apps/docs/public/r/empty-state.json +++ b/apps/docs/public/r/empty-state.json @@ -3,6 +3,7 @@ "type": "registry:ui", "description": "Placeholder for a list or screen with no content.", "registryDependencies": [ + "refs", "text" ], "dependencies": [ @@ -13,7 +14,7 @@ { "path": "ui/empty-state.tsx", "type": "registry:ui", - "content": "/**\n * EmptyState — the placeholder shown when a list or view has no content.\n *\n * Centred Header / Media / Title / Description / Content, with an `icon` media\n * variant that stacks two rotated ghost cards behind the icon. On the web that\n * is a pair of CSS pseudo-elements; React Native has none, so it is two\n * absolutely positioned siblings with static rotate transforms.\n */\nimport { createContext, forwardRef, useContext, type ReactNode } from 'react';\nimport { View, type Text as RNText, type ViewProps } from 'react-native';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport { Text, type TextProps, textChildren } from '@/components/ui/text';\n\ntype EmptyStateSize = 'sm' | 'default' | 'lg';\n\n/** Sub-components read the size so the title, description and media scale with the root. */\nconst EmptyStateContext = createContext<{ size: EmptyStateSize }>({ size: 'default' });\n\nconst emptyStateVariants = tv({\n slots: {\n root: 'w-full items-center justify-center',\n header: 'w-full max-w-sm items-center gap-1',\n title: 'text-center font-semibold text-foreground',\n description: 'text-center text-muted-foreground',\n content: 'w-full max-w-sm items-center gap-3',\n },\n variants: {\n variant: {\n // Fills whatever space it is given — a whole screen, or a flex parent.\n default: { root: 'flex-1' },\n // A self-contained block that sits inside content rather than filling a\n // screen: bordered, on the card fill, sized to its contents.\n card: { root: 'rounded-2xl border border-border bg-card' },\n },\n size: {\n sm: { root: 'gap-4 px-4 py-8', title: 'text-base', description: 'text-sm' },\n default: { root: 'gap-6 px-6 py-12', title: 'text-xl', description: 'text-sm' },\n lg: { root: 'gap-6 px-6 py-16', title: 'text-2xl', description: 'text-base' },\n },\n },\n defaultVariants: {\n variant: 'default',\n size: 'default',\n },\n});\n\nconst mediaVariants = tv({\n slots: {\n wrapper: 'items-center justify-center',\n card: 'shrink-0 items-center justify-center',\n ghost: 'absolute rounded-md border border-border bg-card',\n },\n variants: {\n variant: {\n default: {\n card: 'bg-transparent',\n ghost: 'hidden',\n },\n icon: {\n card: 'rounded-md border border-border bg-card',\n },\n },\n size: {\n sm: { card: 'h-8 w-8', ghost: 'h-8 w-8' },\n default: { card: 'h-9 w-9', ghost: 'h-9 w-9' },\n lg: { card: 'h-11 w-11', ghost: 'h-11 w-11' },\n },\n },\n defaultVariants: {\n variant: 'default',\n size: 'default',\n },\n});\n\nexport interface EmptyStateProps\n extends ViewProps,\n VariantProps {\n className?: string;\n /**\n * `default` fills its parent — a whole screen, or a flex slot. `card` is a\n * self-contained bordered block for an empty state that sits inside content\n * rather than owning the screen.\n */\n variant?: 'default' | 'card';\n /** Scales the padding, media, title and description together. */\n size?: EmptyStateSize;\n children?: ReactNode;\n}\n\nconst EmptyStateRoot = forwardRef(\n ({ className, variant, size = 'default', ...props }, ref) => {\n const { root } = emptyStateVariants({ variant, size });\n return (\n \n \n \n );\n }\n);\nEmptyStateRoot.displayName = 'EmptyState';\n\n/** Groups Media, Title and Description into one centered column. */\nconst EmptyStateHeader = forwardRef(\n ({ className, ...props }, ref) => {\n const { header } = emptyStateVariants();\n return ;\n }\n);\nEmptyStateHeader.displayName = 'EmptyState.Header';\n\nexport interface EmptyStateMediaProps\n extends ViewProps,\n VariantProps {\n className?: string;\n children?: ReactNode;\n}\n\n/**\n * Visual anchor above the title. `variant=\"icon\"` frames the child in a small\n * card with two rotated ghost cards fanned out behind it.\n */\nconst EmptyStateMedia = forwardRef(\n ({ className, variant = 'default', children, ...props }, ref) => {\n const { size } = useContext(EmptyStateContext);\n const { wrapper, card, ghost } = mediaVariants({ variant, size });\n\n return (\n \n {variant === 'icon' ? (\n <>\n \n \n \n ) : null}\n {textChildren(children)}\n \n );\n }\n);\nEmptyStateMedia.displayName = 'EmptyState.Media';\n\nconst EmptyStateTitle = forwardRef(({ className, ...props }, ref) => {\n const { size } = useContext(EmptyStateContext);\n const { title } = emptyStateVariants({ size });\n return ;\n});\nEmptyStateTitle.displayName = 'EmptyState.Title';\n\nconst EmptyStateDescription = forwardRef(\n ({ className, ...props }, ref) => {\n const { size } = useContext(EmptyStateContext);\n const { description } = emptyStateVariants({ size });\n return ;\n }\n);\nEmptyStateDescription.displayName = 'EmptyState.Description';\n\n/** Slot below the header, for actions such as a primary button. */\nconst EmptyStateContent = forwardRef(\n ({ className, ...props }, ref) => {\n const { content } = emptyStateVariants();\n return ;\n }\n);\nEmptyStateContent.displayName = 'EmptyState.Content';\n\nexport const EmptyState = Object.assign(EmptyStateRoot, {\n Header: EmptyStateHeader,\n Media: EmptyStateMedia,\n Title: EmptyStateTitle,\n Description: EmptyStateDescription,\n Content: EmptyStateContent,\n});\n" + "content": "/**\n * EmptyState — the placeholder shown when a list or view has no content.\n *\n * Centred Header / Media / Title / Description / Content, with an `icon` media\n * variant that stacks two rotated ghost cards behind the icon. On the web that\n * is a pair of CSS pseudo-elements; React Native has none, so it is two\n * absolutely positioned siblings with static rotate transforms.\n */\nimport { createContext, forwardRef, useContext, type ReactNode } from 'react';\nimport { View, type Text as RNText, type ViewProps } from 'react-native';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport { Text, type TextProps, textChildren } from '@/components/ui/text';\nimport type { TextRef, ViewRef } from '@/components/ui/refs';\n\ntype EmptyStateSize = 'sm' | 'default' | 'lg';\n\n/** Sub-components read the size so the title, description and media scale with the root. */\nconst EmptyStateContext = createContext<{ size: EmptyStateSize }>({ size: 'default' });\n\nconst emptyStateVariants = tv({\n slots: {\n root: 'w-full items-center justify-center',\n header: 'w-full max-w-sm items-center gap-1',\n title: 'text-center font-semibold text-foreground',\n description: 'text-center text-muted-foreground',\n content: 'w-full max-w-sm items-center gap-3',\n },\n variants: {\n variant: {\n // Fills whatever space it is given — a whole screen, or a flex parent.\n default: { root: 'flex-1' },\n // A self-contained block that sits inside content rather than filling a\n // screen: bordered, on the card fill, sized to its contents.\n card: { root: 'rounded-2xl border border-border bg-card' },\n },\n size: {\n sm: { root: 'gap-4 px-4 py-8', title: 'text-base', description: 'text-sm' },\n default: { root: 'gap-6 px-6 py-12', title: 'text-xl', description: 'text-sm' },\n lg: { root: 'gap-6 px-6 py-16', title: 'text-2xl', description: 'text-base' },\n },\n },\n defaultVariants: {\n variant: 'default',\n size: 'default',\n },\n});\n\nconst mediaVariants = tv({\n slots: {\n wrapper: 'items-center justify-center',\n card: 'shrink-0 items-center justify-center',\n ghost: 'absolute rounded-md border border-border bg-card',\n },\n variants: {\n variant: {\n default: {\n card: 'bg-transparent',\n ghost: 'hidden',\n },\n icon: {\n card: 'rounded-md border border-border bg-card',\n },\n },\n size: {\n sm: { card: 'h-8 w-8', ghost: 'h-8 w-8' },\n default: { card: 'h-9 w-9', ghost: 'h-9 w-9' },\n lg: { card: 'h-11 w-11', ghost: 'h-11 w-11' },\n },\n },\n defaultVariants: {\n variant: 'default',\n size: 'default',\n },\n});\n\nexport interface EmptyStateProps\n extends ViewProps,\n VariantProps {\n className?: string;\n /**\n * `default` fills its parent — a whole screen, or a flex slot. `card` is a\n * self-contained bordered block for an empty state that sits inside content\n * rather than owning the screen.\n */\n variant?: 'default' | 'card';\n /** Scales the padding, media, title and description together. */\n size?: EmptyStateSize;\n children?: ReactNode;\n}\n\nconst EmptyStateRoot = forwardRef(\n ({ className, variant, size = 'default', ...props }, ref) => {\n const { root } = emptyStateVariants({ variant, size });\n return (\n \n \n \n );\n }\n);\nEmptyStateRoot.displayName = 'EmptyState';\n\n/** Groups Media, Title and Description into one centered column. */\nconst EmptyStateHeader = forwardRef(\n ({ className, ...props }, ref) => {\n const { header } = emptyStateVariants();\n return ;\n }\n);\nEmptyStateHeader.displayName = 'EmptyState.Header';\n\nexport interface EmptyStateMediaProps\n extends ViewProps,\n VariantProps {\n className?: string;\n children?: ReactNode;\n}\n\n/**\n * Visual anchor above the title. `variant=\"icon\"` frames the child in a small\n * card with two rotated ghost cards fanned out behind it.\n */\nconst EmptyStateMedia = forwardRef(\n ({ className, variant = 'default', children, ...props }, ref) => {\n const { size } = useContext(EmptyStateContext);\n const { wrapper, card, ghost } = mediaVariants({ variant, size });\n\n return (\n \n {variant === 'icon' ? (\n <>\n \n \n \n ) : null}\n {textChildren(children)}\n \n );\n }\n);\nEmptyStateMedia.displayName = 'EmptyState.Media';\n\nconst EmptyStateTitle = forwardRef(({ className, ...props }, ref) => {\n const { size } = useContext(EmptyStateContext);\n const { title } = emptyStateVariants({ size });\n return ;\n});\nEmptyStateTitle.displayName = 'EmptyState.Title';\n\nconst EmptyStateDescription = forwardRef(\n ({ className, ...props }, ref) => {\n const { size } = useContext(EmptyStateContext);\n const { description } = emptyStateVariants({ size });\n return ;\n }\n);\nEmptyStateDescription.displayName = 'EmptyState.Description';\n\n/** Slot below the header, for actions such as a primary button. */\nconst EmptyStateContent = forwardRef(\n ({ className, ...props }, ref) => {\n const { content } = emptyStateVariants();\n return ;\n }\n);\nEmptyStateContent.displayName = 'EmptyState.Content';\n\nexport const EmptyState = Object.assign(EmptyStateRoot, {\n Header: EmptyStateHeader,\n Media: EmptyStateMedia,\n Title: EmptyStateTitle,\n Description: EmptyStateDescription,\n Content: EmptyStateContent,\n});\n" } ] } diff --git a/apps/docs/public/r/fab.json b/apps/docs/public/r/fab.json index ac3738a1c..751f33711 100644 --- a/apps/docs/public/r/fab.json +++ b/apps/docs/public/r/fab.json @@ -9,6 +9,7 @@ "haptics", "icons", "native", + "refs", "scrim", "text", "use-back-handler" @@ -23,7 +24,7 @@ { "path": "ui/fab.tsx", "type": "registry:ui", - "content": "/**\n * Fab — the floating action button: one screen, one thing it is mostly for.\n *\n * ```tsx\n * } accessibilityLabel=\"New note\" onPress={compose} />\n * ```\n *\n * ## It floats, which is the whole problem with it\n *\n * A button pinned over the content is a button covering some of it, and the\n * bottom-right corner of a scrolling list is exactly where the last row goes. So\n * a Fab is right when a screen has *one* action worth that trade and wrong when\n * it has three — a corner with three buttons in it is a toolbar that has been\n * put in the wrong place. Where a screen has several, the honest shapes are a\n * `ButtonGroup` in a bar or, if one of them really does lead, this with a\n * `Fab.Group` behind it.\n *\n * It positions itself absolutely against its nearest positioned ancestor, which\n * on a screen means the screen. Give the content below it enough bottom padding\n * to scroll clear — this cannot know how tall your list is, and a Fab sitting on\n * the last row forever is the failure people actually hit.\n *\n * ## Extended, and when to bother\n *\n * ```tsx\n * }>Write\n * ```\n *\n * A lone glyph is a guess unless the glyph is a plus. `extended` spells the\n * action out, costs the width, and is worth it for anything a plus would not\n * have said.\n *\n * ## The speed dial\n *\n * ```tsx\n * } accessibilityLabel=\"Add\">\n * } label=\"Photo\" onPress={addPhoto} />\n * } label=\"File\" onPress={addFile} />\n * \n * ```\n *\n * The actions come out of the button one after another rather than together —\n * a stagger of a few frames each, which is enough to read as a list unfolding\n * instead of a menu appearing. Every action carries its label beside it, because\n * a column of unlabelled circles is a quiz.\n *\n * ## The menu\n *\n * ```tsx\n * } accessibilityLabel=\"Add\">\n * } label=\"Photo\" onPress={addPhoto} />\n * } label=\"File\" onPress={addFile} />\n * \n * ```\n *\n * `layout=\"menu\"` unfolds one panel of rows out of the button instead of a\n * column of buttons — the shape the platform's own menus take. Each row is a\n * label with its glyph, on the side the appearance puts it or `iconPlacement`\n * says, and the panel springs out of the corner the\n * trigger sits in, so it reads as the button opening rather than a sheet\n * arriving. Drawn in glass, the panel is one piece of the material; the rows\n * are content on it.\n *\n * ## Glass\n *\n * ```tsx\n * } accessibilityLabel=\"New note\" />\n * ```\n *\n * `glass` draws the button in the material iOS 26 uses for its own floating\n * controls, in place of the variant's fill. A control floating over content is\n * exactly what that material is for: it refracts what scrolls under it rather\n * than covering it, and lifts its own edge, so the shadow goes too.\n *\n * Every variant takes the plain material, because a tint is a dimmer: measured\n * over content, a colour laid on the glass turns it back into a fill and a\n * monochrome one only greys it. The glyph reads in the ordinary foreground\n * colour, and in red on `destructive` — the colour that carries meaning goes\n * on the glyph, where it stays legible. Pressed, it answers the way the platform's\n * own glass controls do: the material swells and brightens under the finger,\n * in place of this component's press scale. The material exists on iOS 26 and\n * above with the optional `expo-glass-effect` installed. Everywhere else — older iOS,\n * Android, web, Reduce Transparency on — the flag is inert and the button keeps\n * its ordinary fill, so nothing has to be written twice.\n *\n * Opening also drops a scrim over the screen. Not for looks: an open dial is\n * modal — the next tap either picks something or closes it — and a scrim is what\n * says so, as well as what catches the tap that closes it.\n *\n * ## Where a group has to be written\n *\n * A `Fab.Group` draws its scrim and its buttons as two absolutely positioned\n * siblings in whatever it is written inside, so put it in the screen's root\n * container. That is where its `offset` is measured from, and it is what the\n * scrim covers.\n *\n * It stays in the screen's own view tree rather than being lifted out of it,\n * which is the part that matters after navigation: pushing a screen over this\n * one hides the dial with everything else on it. A group lifted to the top of\n * the app would still be drawn over the screen that replaced it.\n */\nimport {\n Children,\n cloneElement,\n createContext,\n forwardRef,\n isValidElement,\n useCallback,\n useContext,\n useMemo,\n useState,\n type ReactElement,\n type ReactNode,\n} from 'react';\nimport {\n Pressable,\n StyleSheet,\n View,\n type GestureResponderEvent,\n type ViewProps,\n} from 'react-native';\nimport Animated, {\n Easing,\n Extrapolation,\n interpolate,\n runOnJS,\n useAnimatedStyle,\n useDerivedValue,\n withSpring,\n type SharedValue,\n} from 'react-native-reanimated';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport { useCSSVariable } from 'uniwind';\nimport { IconColorProvider } from '@/components/ui/icons';\nimport { useBackHandler } from '@/hooks/use-back-handler';\nimport {\n NativeHost,\n getComposeMenu,\n getSwiftUIMenu,\n getSwiftUIModifiers,\n} from '@/lib/native';\nimport {\n AnimatedPressable,\n type AnimatedPressableProps,\n} from '@/components/ui/animated-pressable';\nimport { Glass, GlassContainer, useGlassMaterial } from '@/components/ui/glass';\nimport { Scrim } from '@/components/ui/scrim';\nimport { Text } from '@/components/ui/text';\nimport { cn } from '@/lib/cn';\nimport { selectionTick } from '@/lib/haptics';\n\n/** Which corner the button sits in. */\nexport type FabPlacement = 'bottom-right' | 'bottom-center' | 'bottom-left';\n\n/** How far from the edges a floating button sits, in points. */\nconst DEFAULT_OFFSET = 16;\n\n/**\n * The spring the dial opens on. Underdamped enough to overshoot a little and\n * settle, which is what makes buttons read as arriving rather than being\n * placed; the stagger between actions comes from where each one reads the\n * spring, not from a delay.\n */\nconst OPEN_SPRING = { damping: 15, stiffness: 170, mass: 0.9 } as const;\n\n/** How far behind the action above starts, as a share of the dial's opening. */\nconst STAGGER = 0.08;\n\n/** How far an action starts below its resting place, in points. */\nconst ACTION_TRAVEL = 12;\n\n/**\n * How a glass dial's action starts: nothing, sitting on the trigger, and\n * merging with it while the two are within this distance. Scale, not\n * opacity — the material survives a zero scale and not a zero opacity. Shorter than the\n * dial's gaps, so pieces at rest stay separate and only overlapping ones\n * flow together.\n */\nconst RISE_FROM_SCALE = 0;\nconst DIAL_BLEND = 6;\n\n/** How far into an action's arrival its label grows out of it, and from how far aside. */\nconst LABEL_AFTER = 0.45;\nconst LABEL_TRAVEL = 16;\n\n/** A quarter turn on the trigger while the dial is open — a plus becomes a cross. */\nconst OPEN_ROTATION = 45;\n\n/** How the menu is drawn: the platform's own shape, or rows with a glyph well. */\nexport type FabMenuAppearance = 'platform' | 'wells';\n\n/** Which side of a menu row the glyph sits on. */\nexport type FabMenuIconPlacement = 'leading' | 'trailing';\n\n/**\n * The metrics of each appearance, in points. Fixed rather than measured so\n * the panel's height — and with it the point it grows from — is known on the\n * first frame.\n */\ninterface MenuMetrics {\n width: number;\n radius: number;\n row: number;\n hairline: number;\n padding: number;\n icon: FabMenuIconPlacement;\n}\n\nconst MENU_METRICS: Record = {\n /* As wide and as round as the platform's own menus, with the glyph after\n the label and a hairline between rows, the way they draw it. */\n platform: { width: 250, radius: 26, row: 44, hairline: 1, padding: 6, icon: 'trailing' },\n /* Tighter, with the glyph leading in a tinted well and each row its own\n pill — the shape of a menu an app designed rather than the system. */\n wells: { width: 224, radius: 22, row: 48, hairline: 0, padding: 6, icon: 'leading' },\n};\n\n/**\n * How small the menu is once it has left the trigger, and how far into the\n * dial's spring it gets there.\n *\n * A closed panel has to be *nothing*, not something small. Resting at a\n * fraction of its size leaves a box above the trigger for as long as the\n * spring takes to satisfy its completion threshold — which is most of a\n * second, and is not a frame anyone would call an animation. So zero is where\n * it starts and ends, and the pop to `MENU_FROM_SCALE` happens over the first\n * sliver of the spring: the panel still reads as coming out of the button at a\n * size rather than growing from a point.\n */\nconst MENU_FROM_SCALE = 0.3;\nconst MENU_POP_AT = 0.06;\n\n/** The gap between the panel and the trigger — the group's `gap-3`. */\nconst GROUP_GAP = 12;\n\n/**\n * Where the menu grows from: the centre of the trigger, in the panel's own\n * coordinates.\n *\n * Not the panel's corner. The panel sits a gap above the trigger, and a panel\n * scaling from its own corner grows out of thin air just above the button.\n * Scaling about the trigger's centre — below the panel, and in from its\n * edge by half a button — is what makes it come out of the button.\n */\nfunction menuOrigin(\n placement: FabPlacement,\n metrics: MenuMetrics,\n width: number,\n count: number,\n size: FabSize\n): [number, number, number] {\n const height =\n metrics.padding * 2 + metrics.row * count + metrics.hairline * Math.max(0, count - 1);\n const half = SIZE_PX[size] / 2;\n const y = height + GROUP_GAP + half;\n if (placement === 'bottom-left') return [half, y, 0];\n if (placement === 'bottom-center') return [width / 2, y, 0];\n return [width - half, y, 0];\n}\n\n/** How the group lines its parts up under each placement. */\nconst GROUP_ALIGN: Record = {\n 'bottom-right': 'items-end',\n 'bottom-center': 'items-center',\n 'bottom-left': 'items-start',\n};\n\n/** The diameter of each size, in points — the radius the material rounds itself to. */\nconst SIZE_PX = { sm: 44, md: 56, lg: 64 } as const;\n\nconst fabVariants = tv({\n slots: {\n root: 'items-center justify-center rounded-full bg-primary shadow-lg',\n content: 'flex-row items-center justify-center gap-2',\n label: 'font-medium text-primary-foreground',\n },\n variants: {\n size: {\n sm: { root: 'h-11 min-w-11', label: 'text-sm' },\n md: { root: 'h-14 min-w-14', label: 'text-base' },\n lg: { root: 'h-16 min-w-16', label: 'text-lg' },\n },\n /* An icon-only button is a circle, so its width is its height and it takes\n no padding. Extended, it is a stadium and the padding is what makes it\n one. */\n extended: {\n true: {},\n false: {},\n },\n variant: {\n primary: { root: 'bg-primary', label: 'text-primary-foreground' },\n secondary: { root: 'bg-secondary', label: 'text-secondary-foreground' },\n surface: { root: 'border border-border bg-popover', label: 'text-foreground' },\n destructive: {\n root: 'bg-destructive',\n label: 'text-destructive-solid-foreground',\n },\n },\n disabled: {\n true: { root: 'opacity-[0.64] shadow-none' },\n },\n /* The material replaces the fill, the border and the shadow: it draws its\n own edge, and a shadow under a translucent surface is a smudge behind it.\n Only set when the material is really being drawn — see `useGlassMaterial`. */\n glass: {\n true: { root: 'border-0 bg-transparent shadow-none' },\n },\n },\n compoundVariants: [\n /* A faded material stops being one, so a disabled glass button dims what\n sits on the material rather than the material itself. */\n { glass: true, disabled: true, class: { root: 'opacity-100', content: 'opacity-[0.64]' } },\n { extended: true, size: 'sm', class: { root: 'px-4' } },\n { extended: true, size: 'md', class: { root: 'px-5' } },\n { extended: true, size: 'lg', class: { root: 'px-6' } },\n { extended: false, size: 'sm', class: { root: 'w-11' } },\n { extended: false, size: 'md', class: { root: 'w-14' } },\n { extended: false, size: 'lg', class: { root: 'w-16' } },\n ],\n defaultVariants: {\n size: 'md',\n variant: 'primary',\n extended: false,\n glass: false,\n },\n});\n\ntype FabVariantProps = VariantProps;\n\n/** How big the button is. */\nexport type FabSize = NonNullable;\n/** What the button is drawn in. */\nexport type FabVariant = NonNullable;\n\n/** The theme token each variant's icon reads against. */\nconst CONTENT_COLOR_VAR: Record = {\n primary: '--color-primary-foreground',\n destructive: '--color-destructive-solid-foreground',\n secondary: '--color-secondary-foreground',\n surface: '--color-foreground',\n};\n\n/**\n * The token each variant's glyph reads in over the glass.\n *\n * The material is never tinted: a tint is a dimmer, and at any strength it\n * turns the glass back into a fill or greys it. So the glass is neither light\n * nor dark and the glyph reads in the ordinary foreground — except on\n * `destructive`, where the colour that carries meaning goes on the glyph\n * instead, the way a menu's destructive row is red text on the same panel.\n */\nconst GLASS_CONTENT_COLOR_VAR: Record = {\n primary: '--color-foreground',\n destructive: '--color-destructive',\n secondary: '--color-foreground',\n surface: '--color-foreground',\n};\n\n/** Where a floating button parks itself, given its offset. */\nfunction anchor(placement: FabPlacement, offset: number) {\n const base = { position: 'absolute' as const, bottom: offset };\n if (placement === 'bottom-left') return { ...base, left: offset };\n if (placement === 'bottom-center') return { ...base, alignSelf: 'center' as const };\n return { ...base, right: offset };\n}\n\n/* -------------------------------------------------------------------------- *\n * Fab\n * -------------------------------------------------------------------------- */\n\nexport interface FabProps\n extends Omit,\n Omit {\n className?: string;\n /** The glyph. Sized by you — this is the one thing that should not guess. */\n icon?: ReactNode;\n /**\n * The label, which turns the circle into a stadium. Needs `extended`; a\n * label with nowhere to go is a label that gets clipped by the circle.\n */\n children?: ReactNode;\n /** Spell the action out beside the glyph. */\n extended?: boolean;\n /**\n * Pin it over the content, in a corner.\n *\n * Left out, it is an ordinary button in the flow — which is what you want\n * inside a `Fab.Group`, or when the screen already has somewhere for it to\n * sit.\n */\n placement?: FabPlacement;\n /** Distance from the edges when `placement` is set, in points. */\n offset?: number;\n /** Placement-aware view style. Press-state styling belongs in `className`. */\n style?: ViewProps['style'];\n disabled?: boolean;\n /**\n * A tick on press. Off by default — needs the optional `expo-haptics`, and\n * is silent without it.\n */\n haptics?: boolean;\n /**\n * Draw it in Liquid Glass — the material iOS 26 uses for its own floating\n * controls — instead of the variant's fill. Every variant takes the plain\n * material, with its glyph in the foreground colour and in red on\n * `destructive`. Pressed, the material swells and brightens the way the\n * platform's own glass controls do.\n *\n * Needs iOS 26 and the optional `expo-glass-effect`. Below that, on Android,\n * on web, or with Reduce Transparency on, it does nothing and the button\n * keeps its ordinary fill.\n */\n glass?: boolean;\n /** Required for an icon-only button. A lone glyph reads out as nothing. */\n accessibilityLabel?: string;\n}\n\nconst FabRoot = forwardRef(\n (\n {\n className,\n icon,\n children,\n extended = false,\n placement,\n offset = DEFAULT_OFFSET,\n size,\n variant,\n disabled = false,\n haptics = false,\n glass = false,\n onPress,\n style,\n accessibilityState,\n ...props\n },\n ref\n ) => {\n const isDisabled = Boolean(disabled);\n // Asked for *and* drawable. Where the material cannot be drawn the flag\n // changes nothing, so the fill, border and shadow all stay.\n const material = useGlassMaterial() && glass;\n const resolvedVariant = variant ?? 'primary';\n const { root, content, label } = fabVariants({\n size,\n variant,\n extended: extended && !!children,\n disabled: isDisabled,\n glass: material,\n });\n\n const themed = useCSSVariable(\n (material ? GLASS_CONTENT_COLOR_VAR : CONTENT_COLOR_VAR)[resolvedVariant]\n );\n const contentColor = typeof themed === 'string' ? themed : undefined;\n\n const handlePress = useCallback>(\n (event) => {\n if (haptics) selectionTick();\n onPress?.(event);\n },\n [haptics, onPress]\n );\n\n const pressable = (\n \n \n {icon}\n {extended && children ? (\n \n {children}\n \n ) : null}\n \n \n );\n\n /*\n * Drawn in glass, the material is the box and the pressable fills it. The\n * platform only tracks a touch that lands inside the glass view, and\n * tracking it is what makes the button swell and glow under the finger,\n * so the pressable has to be inside rather than under it. It rounds\n * itself to the button's own radius: the material clipped by a rounded\n * parent loses its lit edge.\n */\n return (\n \n {material ? (\n \n {pressable}\n \n ) : (\n pressable\n )}\n \n );\n }\n);\n\nFabRoot.displayName = 'Fab';\n\n/* -------------------------------------------------------------------------- *\n * Group — the speed dial\n * -------------------------------------------------------------------------- */\n\n/** Whatever was written as a dial child, and the one prop a slot reaches for. */\ntype PressableChild = ReactElement<{ onPress?: (event: GestureResponderEvent) => void }>;\n\n/**\n * What opens out of the trigger: a column of buttons, one panel of rows, or\n * the platform's own menu.\n */\nexport type FabGroupLayout = 'dial' | 'menu' | 'native';\n\n/**\n * How big a hosted glyph is drawn in a platform menu row, in points.\n *\n * Stated rather than measured because it has to be: anything of ours inside\n * the native tree needs a definite size on both axes above it, and an icon\n * whose box the platform is left to work out is the crash this library has\n * already paid for twice.\n */\nconst NATIVE_ROW_ICON = 24;\n\ninterface FabGroupContextValue {\n /** 0 closed, 1 open. Every action reads it and its own index off it. */\n progress: SharedValue;\n count: number;\n size: FabSize;\n /** The trigger's material, which the actions unfold in too. */\n glass: boolean;\n layout: FabGroupLayout;\n /** The menu's appearance and where its rows put the glyph. */\n appearance: FabMenuAppearance;\n iconPlacement: FabMenuIconPlacement;\n rowClassName?: string;\n close: () => void;\n}\n\nconst FabGroupContext = createContext(null);\n\nfunction useFabGroup(part: string): FabGroupContextValue {\n const ctx = useContext(FabGroupContext);\n if (!ctx) throw new Error(`${part} must be used inside .`);\n return ctx;\n}\n\nexport interface FabGroupProps extends Omit {\n className?: string;\n /** The glyph on the trigger. */\n icon?: ReactNode;\n /** The trigger's label, if it should be extended while closed. */\n label?: string;\n /** `Fab.Action` children, in the order they should unfold. */\n children?: ReactNode;\n /** Controlled open state. */\n open?: boolean;\n onOpenChange?: (open: boolean) => void;\n /** Which corner of the *screen* the whole dial parks in. */\n placement?: FabPlacement;\n /** Distance from the screen's edges, in points. Add your safe-area inset. */\n offset?: number;\n /**\n * What opens out of the trigger. `dial`, the default, is a column of round\n * buttons with their labels beside them. `menu` is one panel of rows — a\n * label with its glyph, on the side the appearance puts it — that springs\n * out of the trigger's corner, the way the platform's own menus do.\n * `native` hands the menu to the platform: SwiftUI on iOS, Jetpack Compose\n * on Android.\n *\n * A native menu is drawn by the platform, so `className` and the theme\n * tokens do not reach it, and the rows take `label` and `systemImage` rather\n * than an `icon` element. `blur` still applies: the scrim behind the menu is\n * ours, so the page recedes the way it does behind the dial and the panel.\n *\n * On iOS the platform owns the menu's open state, so `open` and\n * `onOpenChange` do nothing there. Android's menu is controlled and honours\n * both.\n *\n * Where the platform menu cannot be drawn — on the web, or without\n * `@expo/ui` installed — this falls back to `menu`.\n */\n layout?: FabGroupLayout;\n /**\n * How a menu is drawn. `platform`, the default, is the shape the platform's\n * own menus take: a hairline between rows and the glyph after the label.\n * `wells` is tighter, with the glyph leading in a tinted well and each row\n * its own pill — a menu the app designed rather than the system.\n *\n * Menu layout only.\n */\n appearance?: FabMenuAppearance;\n /** Which side of a menu row the glyph sits on. Each appearance has its own default. */\n iconPlacement?: FabMenuIconPlacement;\n /** The menu panel's width in points. Each appearance has its own default. */\n menuWidth?: number;\n /** The menu panel's corner radius in points. Each appearance has its own default. */\n menuRadius?: number;\n /** Extra classes for the menu panel. */\n menuClassName?: string;\n /** Extra classes for every menu row. A row's own `className` comes after. */\n rowClassName?: string;\n size?: FabSize;\n variant?: FabVariant;\n disabled?: boolean;\n haptics?: boolean;\n /**\n * Draw the trigger and its actions in Liquid Glass. The same flag as on\n * `Fab`, with the same floor: iOS 26 and `expo-glass-effect`, inert\n * elsewhere.\n */\n glass?: boolean;\n /** Frost the screen behind the open dial instead of dimming it. */\n blur?: boolean;\n /** Required — the trigger is a lone glyph until it is opened. */\n accessibilityLabel?: string;\n /**\n * Turn the trigger's glyph a quarter circle while the dial is open.\n *\n * On by default, and it is doing real work when the glyph is a plus: the\n * same mark becomes a cross, which says \"this closes now\" without a second\n * icon that has to be swapped in. Turn it off for a glyph that means\n * something at one angle only.\n */\n rotateOnOpen?: boolean;\n}\n\n/**\n * A trigger with actions behind it.\n *\n * The scrim and the dial are two absolutely positioned siblings written into\n * the group's own parent — the scrim first, so the dial is drawn over it. Both\n * are laid out against that parent, which is why a group belongs in the\n * screen's root container: it is what `offset` is measured from and what the\n * scrim covers.\n */\nconst FabGroup = forwardRef(\n (\n {\n className,\n icon,\n label,\n children,\n open: openProp,\n onOpenChange,\n placement = 'bottom-right',\n offset = DEFAULT_OFFSET,\n layout = 'dial',\n appearance = 'platform',\n iconPlacement,\n menuWidth,\n menuRadius,\n menuClassName,\n rowClassName,\n size = 'md',\n variant,\n disabled = false,\n haptics = false,\n glass = false,\n blur = false,\n rotateOnOpen = true,\n accessibilityLabel,\n style,\n ...props\n },\n ref\n ) => {\n const [internalOpen, setInternalOpen] = useState(false);\n const open = openProp ?? internalOpen;\n // Asked for *and* drawable, decided once here so the trigger, the actions\n // and their motion all agree; where the material cannot be drawn the flag\n // is inert everywhere rather than in the trigger alone.\n const material = useGlassMaterial() && glass;\n\n const setOpen = useCallback(\n (next: boolean) => {\n if (openProp === undefined) setInternalOpen(next);\n onOpenChange?.(next);\n },\n [openProp, onOpenChange]\n );\n\n const actions = Children.toArray(children).filter(isValidElement) as PressableChild[];\n /*\n * The count, pulled out as a plain number before any worklet sees it.\n *\n * A worklet captures every variable it *references*, whole — so reading\n * `actions.length` inside one would drag the array of React elements onto\n * the UI thread with it, and an element holds a fibre, which cannot be\n * serialised. The error that produces names neither this file nor this\n * line, so: never reach through an object into a worklet.\n */\n const actionCount = actions.length;\n\n /*\n * The platform's menu, when one was asked for and the toolkit is there.\n *\n * Resolved before anything below reads `layout`, because a native menu\n * that cannot be drawn is not an error — it falls back to the panel, and\n * every decision after this one has to be made against the layout that is\n * really being drawn rather than the one that was requested.\n */\n const swiftMenu = layout === 'native' ? getSwiftUIMenu() : null;\n const composeMenu = layout === 'native' && !swiftMenu ? getComposeMenu() : null;\n const resolvedLayout: FabGroupLayout =\n layout === 'native' && !swiftMenu && !composeMenu ? 'menu' : layout;\n\n /*\n * One shared value for the whole dial rather than one per action.\n *\n * The stagger is a function of the action's index, applied on the UI\n * thread inside each action's own style — so opening is a single timing\n * driving every row, and there is no chain of JavaScript timeouts to get\n * out of step with itself when the dial is closed halfway through opening.\n */\n /*\n * The actions stay mounted while the spring runs back down, so closing is\n * the opening in reverse rather than a cut. `closing` is set the moment\n * `open` drops and cleared by the spring when it comes to rest; the\n * mounted-only-while-open rule below still holds once it has.\n */\n const [closing, setClosing] = useState(false);\n // Noted during render rather than in an effect: an effect lands a frame\n // after the render that dropped `open`, and that frame would have no\n // actions in it — a blink, and a remount of the material mid-flight.\n const [wasOpen, setWasOpen] = useState(open);\n if (wasOpen !== open) {\n setWasOpen(open);\n if (!open) setClosing(true);\n }\n const settle = useCallback(() => setClosing(false), []);\n const progress = useDerivedValue(\n () =>\n withSpring(open ? 1 : 0, OPEN_SPRING, (finished) => {\n if (finished && !open) runOnJS(settle)();\n }),\n [open, settle]\n );\n const present = open || closing;\n\n const rotation = useAnimatedStyle(() => ({\n transform: [\n { rotate: `${interpolate(progress.value, [0, 1], [0, OPEN_ROTATION])}deg` },\n ],\n }));\n\n const close = useCallback(() => setOpen(false), [setOpen]);\n\n const context = useMemo(\n () => ({\n progress,\n count: actionCount,\n size,\n glass: material,\n layout: resolvedLayout,\n appearance,\n iconPlacement: iconPlacement ?? MENU_METRICS[appearance].icon,\n rowClassName,\n close,\n }),\n [progress, actionCount, size, material, resolvedLayout, appearance, iconPlacement, rowClassName, close]\n );\n\n const toggle = useCallback(() => {\n if (haptics) selectionTick();\n setOpen(!open);\n }, [haptics, open, setOpen]);\n\n // An open dial owns the back button: back should shut it, not leave it\n // standing over the screen underneath.\n useBackHandler(open, close);\n\n const themedDestructive = useCSSVariable('--color-destructive');\n\n const Group = material && resolvedLayout === 'dial' ? GlassContainer : View;\n\n /*\n * The trigger as the platform hosts it.\n *\n * Sized here rather than left to the platform, on both axes where both are\n * known. An axis handed to `matchContents` is written back to for good, and\n * a hosted view with nothing definite above it is the crash this library\n * has already paid for twice — so a circle states its diameter, and only an\n * extended button's width, which is its label's, is the platform's to\n * measure.\n */\n const triggerSize = SIZE_PX[size];\n const triggerExtended = !!label;\n const triggerFrame = triggerExtended\n ? { height: triggerSize }\n : { width: triggerSize, height: triggerSize };\n\n const nativeTrigger = (\n \n \n {label}\n \n \n );\n\n /*\n * The screen behind an open platform menu, frosted the way it is behind\n * the dial and the panel — the page is what recedes, and a native menu\n * over an untouched page reads as a control belonging to something else.\n *\n * It closes on a tap, which is the safety net rather than the mechanism.\n * The platform dismisses its own menu from its own window, so this never\n * sees that tap and never competes for it; what it catches is the next\n * one. Menu content is not promised an `onDisappear`, and a frosted page\n * with no way back would be worse than no scrim at all.\n */\n const nativeScrim = open ? (\n \n \n \n ) : null;\n\n /*\n * SwiftUI's menu. The trigger goes through the `label` slot, which takes a\n * view as readily as a string — so the button stays ours and only the menu\n * is the platform's.\n *\n * The open state is not passed and cannot be: SwiftUI owns it. `open` and\n * `onOpenChange` are inert on this path, which is stated on the prop.\n */\n if (swiftMenu) {\n const { Host, RNHostView, Menu, Button } = swiftMenu;\n const swiftModifiers = getSwiftUIModifiers();\n\n /*\n * What each row carries beyond its label.\n *\n * The first one also carries the menu's open state. SwiftUI's menu owns\n * that state and reports nothing about it, but it builds its content\n * only when it presents it — so the first row appearing is the menu\n * appearing, and that is what puts the screen behind it under the\n * scrim.\n */\n const itemModifiers = (item: Partial, index: number) => {\n const list: unknown[] = [];\n if (item.disabled && swiftModifiers?.disabled) {\n list.push(swiftModifiers.disabled(true));\n }\n if (index === 0 && swiftModifiers?.onAppear) {\n list.push(swiftModifiers.onAppear(() => setOpen(true)));\n }\n /*\n * Every row carries the teardown, not just the first.\n *\n * The one that reports the menu opening is enough to raise the scrim,\n * but taking it down cannot rest on a single view: whichever row\n * SwiftUI tears down first has to be able to say so, or a menu\n * dismissed from anywhere else leaves the page frosted behind it.\n */\n if (swiftModifiers?.onDisappear) {\n list.push(swiftModifiers.onDisappear(() => setOpen(false)));\n }\n return list.length > 0 ? list : undefined;\n };\n\n return (\n <>\n {nativeScrim}\n \n {nativeTrigger}}>\n {actions.map((action, index) => {\n const item = action.props as Partial;\n return (\n {\n close();\n item.onPress?.();\n }\n }\n modifiers={itemModifiers(item, index)}\n />\n );\n })}\n \n \n \n );\n }\n\n /*\n * Compose's dropdown menu — the same instruction, a different control.\n *\n * This one takes the open state rather than owning it, so the group's own\n * state drives it and `open`/`onOpenChange` work here. Its rows take a text\n * slot rather than a label prop, and a glyph of ours goes in the leading\n * slot at a stated size.\n */\n if (composeMenu) {\n const {\n Host,\n RNHostView,\n Text: ComposeText,\n DropdownMenu,\n DropdownMenuItem,\n } = composeMenu;\n const destructiveColor =\n typeof themedDestructive === 'string' ? themedDestructive : undefined;\n\n return (\n <>\n {nativeScrim}\n \n \n \n {nativeTrigger}\n \n \n {actions.map((action, index) => {\n const item = action.props as Partial;\n return (\n {\n close();\n item.onPress?.();\n }}\n >\n {item.icon ? (\n \n \n \n {item.icon}\n \n \n \n ) : null}\n \n {item.label ?? ''}\n \n \n );\n })}\n \n \n \n \n );\n }\n\n /*\n * Two absolutely positioned siblings, scrim first, both in the group's own\n * parent — which is why a group belongs in the screen's root container.\n *\n * This used to go through a portal, and the portal was the bug. Portalled\n * content is mounted at the app root, above the router, and is removed only\n * when the component that declared it unmounts. A stack keeps the screen\n * you pushed from mounted, so a group declared on that screen carried on\n * drawing over every screen after it — open or closed, since the trigger\n * travelled through the portal too.\n *\n * Ordering is the reason the portal was reached for in the first place: a\n * scrim that covers the screen must not cover the dial it belongs to. As\n * siblings it comes out right for free — the scrim is written first and the\n * dial after it, and the later sibling draws on top.\n *\n * Nothing remounts when the dial opens. The trigger is in the same place in\n * the tree either way, so opening adds the scrim and the actions and leaves\n * the button alone.\n */\n return (\n <>\n {open ? (\n // Catches the tap that closes the dial, and says it is modal.\n \n \n \n ) : null}\n\n \n {/* A glass dial's pieces merge while they overlap — the actions are\n one blob with the trigger until they rise clear of it. */}\n \n {/* Mounted only while open, and while closing: a column of\n actions kept alive behind the trigger would still be in the\n accessibility tree, and a screen reader would walk into four\n buttons nobody can see. */}\n {present && resolvedLayout === 'menu' ? (\n \n {actions}\n \n ) : null}\n {present && resolvedLayout === 'dial'\n ? actions.map((action, index) => (\n \n {action}\n \n ))\n : null}\n\n {icon}\n ) : (\n icon\n )\n }\n extended={!!label && !open}\n size={size}\n variant={variant}\n disabled={disabled}\n glass={glass}\n accessibilityLabel={accessibilityLabel}\n accessibilityState={{ disabled, expanded: open }}\n onPress={toggle}\n >\n {label}\n \n \n \n \n );\n }\n);\n\nFabGroup.displayName = 'Fab.Group';\n\n/**\n * The menu panel: one surface, springing out of the trigger's corner.\n *\n * It scales up from that corner rather than fading in, and the two are not\n * interchangeable: the material cannot be faded — at zero it stops drawing\n * and does not come back — and a panel growing out of the button is what\n * says the button opened. The rows inside fade in on the dial's own stagger,\n * nearest the trigger first.\n */\nfunction FabMenu({\n progress,\n placement,\n size,\n glass,\n appearance,\n width: widthProp,\n radius: radiusProp,\n className,\n children,\n}: {\n /** The dial's own 0-to-1, so the panel and its unmount cannot disagree. */\n progress: SharedValue;\n placement: FabPlacement;\n size: FabSize;\n glass: boolean;\n appearance: FabMenuAppearance;\n width?: number;\n radius?: number;\n className?: string;\n children: PressableChild[];\n}) {\n const metrics = MENU_METRICS[appearance];\n const width = widthProp ?? metrics.width;\n const radius = radiusProp ?? metrics.radius;\n\n /*\n * The panel rides the group's own spring rather than one of its own.\n *\n * It used to run a second, faster spring, and closing raced it against the\n * one that decides when the panel unmounts: the panel reached its collapsed\n * scale early and then sat there, a small box above the trigger, until the\n * slower spring came to rest and took it away. One value cannot race\n * itself, so the panel is gone exactly when the dial says it is.\n *\n * Scale and not opacity, for the same reason the dial's actions are: the\n * material survives a zero scale and does not come back from a zero\n * opacity.\n */\n const style = useAnimatedStyle(() => ({\n transform: [\n {\n scale: interpolate(\n progress.value,\n [0, MENU_POP_AT, 1],\n [0, MENU_FROM_SCALE, 1],\n Extrapolation.CLAMP\n ),\n },\n ],\n }));\n\n return (\n \n \n {children.map((action, index) => (\n 0 && index > 0}>\n {action}\n \n ))}\n \n \n );\n}\n\n/**\n * How far along its own arrival an action is, from the dial's one progress.\n *\n * The stagger runs bottom-up: the action nearest the trigger arrives first,\n * which is the order a hand travelling away from the button meets them in.\n */\nfunction slotProgress(progress: number, count: number, index: number): number {\n 'worklet';\n // A short lag per action rather than a full turn each: the actions leave\n // the trigger nearly together and settle nearly together, so a later one\n // does not rise through a slot an earlier one is already sitting in.\n const from = (count - 1 - index) * STAGGER;\n const to = from + 1 - STAGGER * Math.max(0, count - 1);\n // The spring's overshoot past its target is passed on to every action, so\n // a button arrives with a little bounce rather than stopping dead. It is\n // added on top of the clamped window rather than read through it: a window\n // that ends before the dial's does would otherwise leave its action past\n // its slot for good.\n const within = interpolate(progress, [from, Math.min(1, to)], [0, 1], Extrapolation.CLAMP);\n return within + Math.max(0, progress - 1);\n}\n\n/** The same progress, decelerating into 1 and never past it — for size and opacity. */\nfunction settled(t: number): number {\n 'worklet';\n return Easing.out(Easing.cubic)(Math.min(1, Math.max(0, t)));\n}\n\n/** Which slot an action is in — what it needs to know to animate itself. */\nconst FabSlotContext = createContext(0);\n\n/**\n * One action's slot in the unfolding.\n *\n * The stagger runs bottom-up: the action nearest the trigger arrives first,\n * which is the order a hand travelling away from the button meets them in.\n */\nfunction FabActionSlot({\n index,\n separator = false,\n children,\n}: {\n index: number;\n /** A hairline above the slot — a menu row's, never a dial button's. */\n separator?: boolean;\n children: PressableChild;\n}) {\n const { progress, count, glass, layout, close } = useFabGroup('Fab.Action');\n // A menu's rows are content on one panel, not glass of their own, so they\n // may fade; a dial's actions are each their own material.\n const material = glass && layout === 'dial';\n\n /*\n * Whatever is in the slot closes the dial when it is pressed.\n *\n * `Fab.Action` does this itself, but a plain `Fab` written as a child is a\n * perfectly reasonable thing to reach for and knows nothing about the dial it\n * is in. Left alone it runs its action — navigating, usually — with the dial\n * still open behind it. Wrapping the handler here covers both, and closing\n * twice is closing once.\n */\n const { onPress } = children.props;\n const child = onPress\n ? cloneElement(children, {\n onPress: (event: GestureResponderEvent) => {\n close();\n onPress(event);\n },\n })\n : children;\n\n /*\n * A glass action never fades. The material stops drawing under an ancestor\n * at zero opacity and does not come back when the opacity does, so a fade\n * from zero is a button that sometimes never appears — whichever ones got\n * their first frame at zero. A glass dial's action animates its own parts\n * instead, rising out of the trigger; see `Fab.Action`. The slot only\n * fades a plain action, where it reads better on a flat surface.\n */\n const style = useAnimatedStyle(() => {\n if (material) return {};\n const t = slotProgress(progress.value, count, index);\n return {\n opacity: settled(t),\n transform: [{ translateY: interpolate(t, [0, 1], [ACTION_TRAVEL, 0]) }],\n };\n });\n\n return (\n \n \n {separator ? : null}\n {child}\n \n \n );\n}\n\n/* -------------------------------------------------------------------------- *\n * Action\n * -------------------------------------------------------------------------- */\n\nexport interface FabActionProps extends Omit {\n className?: string;\n /** The glyph. */\n icon?: ReactNode;\n /**\n * The glyph for a native menu row, as an SF Symbol name.\n *\n * `layout=\"native\"` only, and iOS only — SwiftUI names its symbols rather\n * than taking a view for them, so `icon` cannot cross over. Ignored\n * everywhere else, so a group can carry both and be right on either path.\n */\n systemImage?: string;\n /** What it does, beside the glyph. A column of unlabelled circles is a quiz. */\n label?: string;\n onPress?: () => void;\n disabled?: boolean;\n /** Draws it in the destructive colour, for the one that removes something. */\n destructive?: boolean;\n /** Extra classes for the label — the chip in a dial, the row's text in a menu. */\n labelClassName?: string;\n}\n\n/**\n * One choice in an open dial: a small round button with its name beside it.\n *\n * The label is a chip of its own rather than text on the background, because\n * the background is whatever the screen behind the scrim happens to be and\n * plain text over it is legible on some screens and not others.\n *\n * Pressing one closes the dial before running the action. Leaving it open over\n * whatever the action just did is the wrong default: the dial is a menu, and a\n * menu that stays up after a choice reads as the choice not having registered.\n */\nconst FabAction = forwardRef(\n (\n {\n className,\n icon,\n // Read off the element by a native group, never rendered here.\n systemImage: _systemImage,\n label,\n onPress,\n disabled = false,\n destructive = false,\n labelClassName,\n ...props\n },\n ref\n ) => {\n const { progress, count, size, glass, layout, appearance, iconPlacement, rowClassName, close } =\n useFabGroup('Fab.Action');\n const index = useContext(FabSlotContext);\n const [pressed, setPressed] = useState(false);\n\n /*\n * A glass dial's action rises out of the trigger.\n *\n * It starts small and sitting on the button, and springs up to its slot\n * on the dial's stagger; inside the group's glass container the two\n * materials are one blob until it pulls free. The distance is known from\n * the fixed sizes, so nothing is measured. The label follows once the\n * button is most of the way there, sliding in from the button's side —\n * it is glass too, so it moves rather than fades.\n */\n const actionSize: FabSize = size === 'lg' ? 'md' : 'sm';\n const rise =\n (count - 1 - index) * (SIZE_PX[actionSize] + GROUP_GAP) +\n GROUP_GAP +\n SIZE_PX[size] / 2 +\n SIZE_PX[actionSize] / 2;\n // Travel follows the spring, overshoot and all; size settles without it.\n const buttonStyle = useAnimatedStyle(() => {\n const t = slotProgress(progress.value, count, index);\n return {\n transform: [\n { translateY: interpolate(t, [0, 1], [rise, 0]) },\n { scale: interpolate(settled(t), [0, 1], [RISE_FROM_SCALE, 1]) },\n ],\n };\n });\n const chipStyle = useAnimatedStyle(() => {\n const t = slotProgress(progress.value, count, index);\n // Rides up with its button, then grows out of it once the button is\n // most of the way to its slot.\n const late = settled(interpolate(t, [LABEL_AFTER, 1], [0, 1], 'clamp'));\n return {\n transform: [\n { translateY: interpolate(t, [0, 1], [rise, 0]) },\n { translateX: interpolate(late, [0, 1], [LABEL_TRAVEL, 0]) },\n { scale: interpolate(late, [0, 1], [RISE_FROM_SCALE, 1]) },\n ],\n };\n });\n\n const handlePress = useCallback(() => {\n close();\n onPress?.();\n }, [close, onPress]);\n\n const themedRow = useCSSVariable(destructive ? '--color-destructive' : '--color-foreground');\n const rowColor = typeof themedRow === 'string' ? themedRow : undefined;\n\n /*\n * In a menu the action is a row on the panel. The press tints the row\n * rather than scaling it — a row that shrinks inside a panel that does\n * not looks detached from it — and the tint is React state rather than a\n * worklet because it is a colour toggle, not a curve.\n */\n if (layout === 'menu') {\n const wells = appearance === 'wells';\n const glyph = wells ? (\n \n {icon}\n \n ) : (\n icon\n );\n return (\n \n setPressed(true)}\n onPressOut={() => setPressed(false)}\n pressScale={1}\n className={cn(\n 'flex-row items-center gap-3',\n wells ? 'h-12 rounded-2xl px-2' : 'h-11 px-4',\n iconPlacement === 'trailing' ? 'justify-between' : null,\n pressed && 'bg-foreground/10',\n disabled && 'opacity-40',\n rowClassName,\n className\n )}\n {...props}\n >\n {iconPlacement === 'leading' ? glyph : null}\n \n {label}\n \n {iconPlacement === 'trailing' ? glyph : null}\n \n \n );\n }\n\n return (\n \n {label ? (\n // The chip is in the same material as the button beside it, so a\n // glass dial is glass all the way across and not glass with paper\n // labels. Without the material it is the popover surface it was.\n glass ? (\n \n \n \n {label}\n \n \n \n ) : (\n \n \n {label}\n \n \n )\n ) : null}\n \n \n \n \n );\n }\n);\n\nFabAction.displayName = 'Fab.Action';\n\nexport const Fab = Object.assign(FabRoot, {\n Group: FabGroup,\n Action: FabAction,\n});\n" + "content": "/**\n * Fab — the floating action button: one screen, one thing it is mostly for.\n *\n * ```tsx\n * } accessibilityLabel=\"New note\" onPress={compose} />\n * ```\n *\n * ## It floats, which is the whole problem with it\n *\n * A button pinned over the content is a button covering some of it, and the\n * bottom-right corner of a scrolling list is exactly where the last row goes. So\n * a Fab is right when a screen has *one* action worth that trade and wrong when\n * it has three — a corner with three buttons in it is a toolbar that has been\n * put in the wrong place. Where a screen has several, the honest shapes are a\n * `ButtonGroup` in a bar or, if one of them really does lead, this with a\n * `Fab.Group` behind it.\n *\n * It positions itself absolutely against its nearest positioned ancestor, which\n * on a screen means the screen. Give the content below it enough bottom padding\n * to scroll clear — this cannot know how tall your list is, and a Fab sitting on\n * the last row forever is the failure people actually hit.\n *\n * ## Extended, and when to bother\n *\n * ```tsx\n * }>Write\n * ```\n *\n * A lone glyph is a guess unless the glyph is a plus. `extended` spells the\n * action out, costs the width, and is worth it for anything a plus would not\n * have said.\n *\n * ## The speed dial\n *\n * ```tsx\n * } accessibilityLabel=\"Add\">\n * } label=\"Photo\" onPress={addPhoto} />\n * } label=\"File\" onPress={addFile} />\n * \n * ```\n *\n * The actions come out of the button one after another rather than together —\n * a stagger of a few frames each, which is enough to read as a list unfolding\n * instead of a menu appearing. Every action carries its label beside it, because\n * a column of unlabelled circles is a quiz.\n *\n * ## The menu\n *\n * ```tsx\n * } accessibilityLabel=\"Add\">\n * } label=\"Photo\" onPress={addPhoto} />\n * } label=\"File\" onPress={addFile} />\n * \n * ```\n *\n * `layout=\"menu\"` unfolds one panel of rows out of the button instead of a\n * column of buttons — the shape the platform's own menus take. Each row is a\n * label with its glyph, on the side the appearance puts it or `iconPlacement`\n * says, and the panel springs out of the corner the\n * trigger sits in, so it reads as the button opening rather than a sheet\n * arriving. Drawn in glass, the panel is one piece of the material; the rows\n * are content on it.\n *\n * ## Glass\n *\n * ```tsx\n * } accessibilityLabel=\"New note\" />\n * ```\n *\n * `glass` draws the button in the material iOS 26 uses for its own floating\n * controls, in place of the variant's fill. A control floating over content is\n * exactly what that material is for: it refracts what scrolls under it rather\n * than covering it, and lifts its own edge, so the shadow goes too.\n *\n * Every variant takes the plain material, because a tint is a dimmer: measured\n * over content, a colour laid on the glass turns it back into a fill and a\n * monochrome one only greys it. The glyph reads in the ordinary foreground\n * colour, and in red on `destructive` — the colour that carries meaning goes\n * on the glyph, where it stays legible. Pressed, it answers the way the platform's\n * own glass controls do: the material swells and brightens under the finger,\n * in place of this component's press scale. The material exists on iOS 26 and\n * above with the optional `expo-glass-effect` installed. Everywhere else — older iOS,\n * Android, web, Reduce Transparency on — the flag is inert and the button keeps\n * its ordinary fill, so nothing has to be written twice.\n *\n * Opening also drops a scrim over the screen. Not for looks: an open dial is\n * modal — the next tap either picks something or closes it — and a scrim is what\n * says so, as well as what catches the tap that closes it.\n *\n * ## Where a group has to be written\n *\n * A `Fab.Group` draws its scrim and its buttons as two absolutely positioned\n * siblings in whatever it is written inside, so put it in the screen's root\n * container. That is where its `offset` is measured from, and it is what the\n * scrim covers.\n *\n * It stays in the screen's own view tree rather than being lifted out of it,\n * which is the part that matters after navigation: pushing a screen over this\n * one hides the dial with everything else on it. A group lifted to the top of\n * the app would still be drawn over the screen that replaced it.\n */\nimport {\n Children,\n cloneElement,\n createContext,\n forwardRef,\n isValidElement,\n useCallback,\n useContext,\n useMemo,\n useState,\n type ReactElement,\n type ReactNode,\n} from 'react';\nimport {\n Pressable,\n StyleSheet,\n View,\n type GestureResponderEvent,\n type ViewProps,\n} from 'react-native';\nimport Animated, {\n Easing,\n Extrapolation,\n interpolate,\n runOnJS,\n useAnimatedStyle,\n useDerivedValue,\n withSpring,\n type SharedValue,\n} from 'react-native-reanimated';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport { useCSSVariable } from 'uniwind';\nimport { IconColorProvider } from '@/components/ui/icons';\nimport { useBackHandler } from '@/hooks/use-back-handler';\nimport {\n NativeHost,\n getComposeMenu,\n getSwiftUIMenu,\n getSwiftUIModifiers,\n} from '@/lib/native';\nimport {\n AnimatedPressable,\n type AnimatedPressableProps,\n} from '@/components/ui/animated-pressable';\nimport { Glass, GlassContainer, useGlassMaterial } from '@/components/ui/glass';\nimport { Scrim } from '@/components/ui/scrim';\nimport { Text } from '@/components/ui/text';\nimport { cn } from '@/lib/cn';\nimport { selectionTick } from '@/lib/haptics';\nimport type { ViewRef } from '@/components/ui/refs';\n\n/** Which corner the button sits in. */\nexport type FabPlacement = 'bottom-right' | 'bottom-center' | 'bottom-left';\n\n/** How far from the edges a floating button sits, in points. */\nconst DEFAULT_OFFSET = 16;\n\n/**\n * The spring the dial opens on. Underdamped enough to overshoot a little and\n * settle, which is what makes buttons read as arriving rather than being\n * placed; the stagger between actions comes from where each one reads the\n * spring, not from a delay.\n */\nconst OPEN_SPRING = { damping: 15, stiffness: 170, mass: 0.9 } as const;\n\n/** How far behind the action above starts, as a share of the dial's opening. */\nconst STAGGER = 0.08;\n\n/** How far an action starts below its resting place, in points. */\nconst ACTION_TRAVEL = 12;\n\n/**\n * How a glass dial's action starts: nothing, sitting on the trigger, and\n * merging with it while the two are within this distance. Scale, not\n * opacity — the material survives a zero scale and not a zero opacity. Shorter than the\n * dial's gaps, so pieces at rest stay separate and only overlapping ones\n * flow together.\n */\nconst RISE_FROM_SCALE = 0;\nconst DIAL_BLEND = 6;\n\n/** How far into an action's arrival its label grows out of it, and from how far aside. */\nconst LABEL_AFTER = 0.45;\nconst LABEL_TRAVEL = 16;\n\n/** A quarter turn on the trigger while the dial is open — a plus becomes a cross. */\nconst OPEN_ROTATION = 45;\n\n/** How the menu is drawn: the platform's own shape, or rows with a glyph well. */\nexport type FabMenuAppearance = 'platform' | 'wells';\n\n/** Which side of a menu row the glyph sits on. */\nexport type FabMenuIconPlacement = 'leading' | 'trailing';\n\n/**\n * The metrics of each appearance, in points. Fixed rather than measured so\n * the panel's height — and with it the point it grows from — is known on the\n * first frame.\n */\ninterface MenuMetrics {\n width: number;\n radius: number;\n row: number;\n hairline: number;\n padding: number;\n icon: FabMenuIconPlacement;\n}\n\nconst MENU_METRICS: Record = {\n /* As wide and as round as the platform's own menus, with the glyph after\n the label and a hairline between rows, the way they draw it. */\n platform: { width: 250, radius: 26, row: 44, hairline: 1, padding: 6, icon: 'trailing' },\n /* Tighter, with the glyph leading in a tinted well and each row its own\n pill — the shape of a menu an app designed rather than the system. */\n wells: { width: 224, radius: 22, row: 48, hairline: 0, padding: 6, icon: 'leading' },\n};\n\n/**\n * How small the menu is once it has left the trigger, and how far into the\n * dial's spring it gets there.\n *\n * A closed panel has to be *nothing*, not something small. Resting at a\n * fraction of its size leaves a box above the trigger for as long as the\n * spring takes to satisfy its completion threshold — which is most of a\n * second, and is not a frame anyone would call an animation. So zero is where\n * it starts and ends, and the pop to `MENU_FROM_SCALE` happens over the first\n * sliver of the spring: the panel still reads as coming out of the button at a\n * size rather than growing from a point.\n */\nconst MENU_FROM_SCALE = 0.3;\nconst MENU_POP_AT = 0.06;\n\n/** The gap between the panel and the trigger — the group's `gap-3`. */\nconst GROUP_GAP = 12;\n\n/**\n * Where the menu grows from: the centre of the trigger, in the panel's own\n * coordinates.\n *\n * Not the panel's corner. The panel sits a gap above the trigger, and a panel\n * scaling from its own corner grows out of thin air just above the button.\n * Scaling about the trigger's centre — below the panel, and in from its\n * edge by half a button — is what makes it come out of the button.\n */\nfunction menuOrigin(\n placement: FabPlacement,\n metrics: MenuMetrics,\n width: number,\n count: number,\n size: FabSize\n): [number, number, number] {\n const height =\n metrics.padding * 2 + metrics.row * count + metrics.hairline * Math.max(0, count - 1);\n const half = SIZE_PX[size] / 2;\n const y = height + GROUP_GAP + half;\n if (placement === 'bottom-left') return [half, y, 0];\n if (placement === 'bottom-center') return [width / 2, y, 0];\n return [width - half, y, 0];\n}\n\n/** How the group lines its parts up under each placement. */\nconst GROUP_ALIGN: Record = {\n 'bottom-right': 'items-end',\n 'bottom-center': 'items-center',\n 'bottom-left': 'items-start',\n};\n\n/** The diameter of each size, in points — the radius the material rounds itself to. */\nconst SIZE_PX = { sm: 44, md: 56, lg: 64 } as const;\n\nconst fabVariants = tv({\n slots: {\n root: 'items-center justify-center rounded-full bg-primary shadow-lg',\n content: 'flex-row items-center justify-center gap-2',\n label: 'font-medium text-primary-foreground',\n },\n variants: {\n size: {\n sm: { root: 'h-11 min-w-11', label: 'text-sm' },\n md: { root: 'h-14 min-w-14', label: 'text-base' },\n lg: { root: 'h-16 min-w-16', label: 'text-lg' },\n },\n /* An icon-only button is a circle, so its width is its height and it takes\n no padding. Extended, it is a stadium and the padding is what makes it\n one. */\n extended: {\n true: {},\n false: {},\n },\n variant: {\n primary: { root: 'bg-primary', label: 'text-primary-foreground' },\n secondary: { root: 'bg-secondary', label: 'text-secondary-foreground' },\n surface: { root: 'border border-border bg-popover', label: 'text-foreground' },\n destructive: {\n root: 'bg-destructive',\n label: 'text-destructive-solid-foreground',\n },\n },\n disabled: {\n true: { root: 'opacity-[0.64] shadow-none' },\n },\n /* The material replaces the fill, the border and the shadow: it draws its\n own edge, and a shadow under a translucent surface is a smudge behind it.\n Only set when the material is really being drawn — see `useGlassMaterial`. */\n glass: {\n true: { root: 'border-0 bg-transparent shadow-none' },\n },\n },\n compoundVariants: [\n /* A faded material stops being one, so a disabled glass button dims what\n sits on the material rather than the material itself. */\n { glass: true, disabled: true, class: { root: 'opacity-100', content: 'opacity-[0.64]' } },\n { extended: true, size: 'sm', class: { root: 'px-4' } },\n { extended: true, size: 'md', class: { root: 'px-5' } },\n { extended: true, size: 'lg', class: { root: 'px-6' } },\n { extended: false, size: 'sm', class: { root: 'w-11' } },\n { extended: false, size: 'md', class: { root: 'w-14' } },\n { extended: false, size: 'lg', class: { root: 'w-16' } },\n ],\n defaultVariants: {\n size: 'md',\n variant: 'primary',\n extended: false,\n glass: false,\n },\n});\n\ntype FabVariantProps = VariantProps;\n\n/** How big the button is. */\nexport type FabSize = NonNullable;\n/** What the button is drawn in. */\nexport type FabVariant = NonNullable;\n\n/** The theme token each variant's icon reads against. */\nconst CONTENT_COLOR_VAR: Record = {\n primary: '--color-primary-foreground',\n destructive: '--color-destructive-solid-foreground',\n secondary: '--color-secondary-foreground',\n surface: '--color-foreground',\n};\n\n/**\n * The token each variant's glyph reads in over the glass.\n *\n * The material is never tinted: a tint is a dimmer, and at any strength it\n * turns the glass back into a fill or greys it. So the glass is neither light\n * nor dark and the glyph reads in the ordinary foreground — except on\n * `destructive`, where the colour that carries meaning goes on the glyph\n * instead, the way a menu's destructive row is red text on the same panel.\n */\nconst GLASS_CONTENT_COLOR_VAR: Record = {\n primary: '--color-foreground',\n destructive: '--color-destructive',\n secondary: '--color-foreground',\n surface: '--color-foreground',\n};\n\n/** Where a floating button parks itself, given its offset. */\nfunction anchor(placement: FabPlacement, offset: number) {\n const base = { position: 'absolute' as const, bottom: offset };\n if (placement === 'bottom-left') return { ...base, left: offset };\n if (placement === 'bottom-center') return { ...base, alignSelf: 'center' as const };\n return { ...base, right: offset };\n}\n\n/* -------------------------------------------------------------------------- *\n * Fab\n * -------------------------------------------------------------------------- */\n\nexport interface FabProps\n extends Omit,\n Omit {\n className?: string;\n /** The glyph. Sized by you — this is the one thing that should not guess. */\n icon?: ReactNode;\n /**\n * The label, which turns the circle into a stadium. Needs `extended`; a\n * label with nowhere to go is a label that gets clipped by the circle.\n */\n children?: ReactNode;\n /** Spell the action out beside the glyph. */\n extended?: boolean;\n /**\n * Pin it over the content, in a corner.\n *\n * Left out, it is an ordinary button in the flow — which is what you want\n * inside a `Fab.Group`, or when the screen already has somewhere for it to\n * sit.\n */\n placement?: FabPlacement;\n /** Distance from the edges when `placement` is set, in points. */\n offset?: number;\n /** Placement-aware view style. Press-state styling belongs in `className`. */\n style?: ViewProps['style'];\n disabled?: boolean;\n /**\n * A tick on press. Off by default — needs the optional `expo-haptics`, and\n * is silent without it.\n */\n haptics?: boolean;\n /**\n * Draw it in Liquid Glass — the material iOS 26 uses for its own floating\n * controls — instead of the variant's fill. Every variant takes the plain\n * material, with its glyph in the foreground colour and in red on\n * `destructive`. Pressed, the material swells and brightens the way the\n * platform's own glass controls do.\n *\n * Needs iOS 26 and the optional `expo-glass-effect`. Below that, on Android,\n * on web, or with Reduce Transparency on, it does nothing and the button\n * keeps its ordinary fill.\n */\n glass?: boolean;\n /** Required for an icon-only button. A lone glyph reads out as nothing. */\n accessibilityLabel?: string;\n}\n\nconst FabRoot = forwardRef(\n (\n {\n className,\n icon,\n children,\n extended = false,\n placement,\n offset = DEFAULT_OFFSET,\n size,\n variant,\n disabled = false,\n haptics = false,\n glass = false,\n onPress,\n style,\n accessibilityState,\n ...props\n },\n ref\n ) => {\n const isDisabled = Boolean(disabled);\n // Asked for *and* drawable. Where the material cannot be drawn the flag\n // changes nothing, so the fill, border and shadow all stay.\n const material = useGlassMaterial() && glass;\n const resolvedVariant = variant ?? 'primary';\n const { root, content, label } = fabVariants({\n size,\n variant,\n extended: extended && !!children,\n disabled: isDisabled,\n glass: material,\n });\n\n const themed = useCSSVariable(\n (material ? GLASS_CONTENT_COLOR_VAR : CONTENT_COLOR_VAR)[resolvedVariant]\n );\n const contentColor = typeof themed === 'string' ? themed : undefined;\n\n const handlePress = useCallback>(\n (event) => {\n if (haptics) selectionTick();\n onPress?.(event);\n },\n [haptics, onPress]\n );\n\n const pressable = (\n \n \n {icon}\n {extended && children ? (\n \n {children}\n \n ) : null}\n \n \n );\n\n /*\n * Drawn in glass, the material is the box and the pressable fills it. The\n * platform only tracks a touch that lands inside the glass view, and\n * tracking it is what makes the button swell and glow under the finger,\n * so the pressable has to be inside rather than under it. It rounds\n * itself to the button's own radius: the material clipped by a rounded\n * parent loses its lit edge.\n */\n return (\n \n {material ? (\n \n {pressable}\n \n ) : (\n pressable\n )}\n \n );\n }\n);\n\nFabRoot.displayName = 'Fab';\n\n/* -------------------------------------------------------------------------- *\n * Group — the speed dial\n * -------------------------------------------------------------------------- */\n\n/** Whatever was written as a dial child, and the one prop a slot reaches for. */\ntype PressableChild = ReactElement<{ onPress?: (event: GestureResponderEvent) => void }>;\n\n/**\n * What opens out of the trigger: a column of buttons, one panel of rows, or\n * the platform's own menu.\n */\nexport type FabGroupLayout = 'dial' | 'menu' | 'native';\n\n/**\n * How big a hosted glyph is drawn in a platform menu row, in points.\n *\n * Stated rather than measured because it has to be: anything of ours inside\n * the native tree needs a definite size on both axes above it, and an icon\n * whose box the platform is left to work out is the crash this library has\n * already paid for twice.\n */\nconst NATIVE_ROW_ICON = 24;\n\ninterface FabGroupContextValue {\n /** 0 closed, 1 open. Every action reads it and its own index off it. */\n progress: SharedValue;\n count: number;\n size: FabSize;\n /** The trigger's material, which the actions unfold in too. */\n glass: boolean;\n layout: FabGroupLayout;\n /** The menu's appearance and where its rows put the glyph. */\n appearance: FabMenuAppearance;\n iconPlacement: FabMenuIconPlacement;\n rowClassName?: string;\n close: () => void;\n}\n\nconst FabGroupContext = createContext(null);\n\nfunction useFabGroup(part: string): FabGroupContextValue {\n const ctx = useContext(FabGroupContext);\n if (!ctx) throw new Error(`${part} must be used inside .`);\n return ctx;\n}\n\nexport interface FabGroupProps extends Omit {\n className?: string;\n /** The glyph on the trigger. */\n icon?: ReactNode;\n /** The trigger's label, if it should be extended while closed. */\n label?: string;\n /** `Fab.Action` children, in the order they should unfold. */\n children?: ReactNode;\n /** Controlled open state. */\n open?: boolean;\n onOpenChange?: (open: boolean) => void;\n /** Which corner of the *screen* the whole dial parks in. */\n placement?: FabPlacement;\n /** Distance from the screen's edges, in points. Add your safe-area inset. */\n offset?: number;\n /**\n * What opens out of the trigger. `dial`, the default, is a column of round\n * buttons with their labels beside them. `menu` is one panel of rows — a\n * label with its glyph, on the side the appearance puts it — that springs\n * out of the trigger's corner, the way the platform's own menus do.\n * `native` hands the menu to the platform: SwiftUI on iOS, Jetpack Compose\n * on Android.\n *\n * A native menu is drawn by the platform, so `className` and the theme\n * tokens do not reach it, and the rows take `label` and `systemImage` rather\n * than an `icon` element. `blur` still applies: the scrim behind the menu is\n * ours, so the page recedes the way it does behind the dial and the panel.\n *\n * On iOS the platform owns the menu's open state, so `open` and\n * `onOpenChange` do nothing there. Android's menu is controlled and honours\n * both.\n *\n * Where the platform menu cannot be drawn — on the web, or without\n * `@expo/ui` installed — this falls back to `menu`.\n */\n layout?: FabGroupLayout;\n /**\n * How a menu is drawn. `platform`, the default, is the shape the platform's\n * own menus take: a hairline between rows and the glyph after the label.\n * `wells` is tighter, with the glyph leading in a tinted well and each row\n * its own pill — a menu the app designed rather than the system.\n *\n * Menu layout only.\n */\n appearance?: FabMenuAppearance;\n /** Which side of a menu row the glyph sits on. Each appearance has its own default. */\n iconPlacement?: FabMenuIconPlacement;\n /** The menu panel's width in points. Each appearance has its own default. */\n menuWidth?: number;\n /** The menu panel's corner radius in points. Each appearance has its own default. */\n menuRadius?: number;\n /** Extra classes for the menu panel. */\n menuClassName?: string;\n /** Extra classes for every menu row. A row's own `className` comes after. */\n rowClassName?: string;\n size?: FabSize;\n variant?: FabVariant;\n disabled?: boolean;\n haptics?: boolean;\n /**\n * Draw the trigger and its actions in Liquid Glass. The same flag as on\n * `Fab`, with the same floor: iOS 26 and `expo-glass-effect`, inert\n * elsewhere.\n */\n glass?: boolean;\n /** Frost the screen behind the open dial instead of dimming it. */\n blur?: boolean;\n /** Required — the trigger is a lone glyph until it is opened. */\n accessibilityLabel?: string;\n /**\n * Turn the trigger's glyph a quarter circle while the dial is open.\n *\n * On by default, and it is doing real work when the glyph is a plus: the\n * same mark becomes a cross, which says \"this closes now\" without a second\n * icon that has to be swapped in. Turn it off for a glyph that means\n * something at one angle only.\n */\n rotateOnOpen?: boolean;\n}\n\n/**\n * A trigger with actions behind it.\n *\n * The scrim and the dial are two absolutely positioned siblings written into\n * the group's own parent — the scrim first, so the dial is drawn over it. Both\n * are laid out against that parent, which is why a group belongs in the\n * screen's root container: it is what `offset` is measured from and what the\n * scrim covers.\n */\nconst FabGroup = forwardRef(\n (\n {\n className,\n icon,\n label,\n children,\n open: openProp,\n onOpenChange,\n placement = 'bottom-right',\n offset = DEFAULT_OFFSET,\n layout = 'dial',\n appearance = 'platform',\n iconPlacement,\n menuWidth,\n menuRadius,\n menuClassName,\n rowClassName,\n size = 'md',\n variant,\n disabled = false,\n haptics = false,\n glass = false,\n blur = false,\n rotateOnOpen = true,\n accessibilityLabel,\n style,\n ...props\n },\n ref\n ) => {\n const [internalOpen, setInternalOpen] = useState(false);\n const open = openProp ?? internalOpen;\n // Asked for *and* drawable, decided once here so the trigger, the actions\n // and their motion all agree; where the material cannot be drawn the flag\n // is inert everywhere rather than in the trigger alone.\n const material = useGlassMaterial() && glass;\n\n const setOpen = useCallback(\n (next: boolean) => {\n if (openProp === undefined) setInternalOpen(next);\n onOpenChange?.(next);\n },\n [openProp, onOpenChange]\n );\n\n const actions = Children.toArray(children).filter(isValidElement) as PressableChild[];\n /*\n * The count, pulled out as a plain number before any worklet sees it.\n *\n * A worklet captures every variable it *references*, whole — so reading\n * `actions.length` inside one would drag the array of React elements onto\n * the UI thread with it, and an element holds a fibre, which cannot be\n * serialised. The error that produces names neither this file nor this\n * line, so: never reach through an object into a worklet.\n */\n const actionCount = actions.length;\n\n /*\n * The platform's menu, when one was asked for and the toolkit is there.\n *\n * Resolved before anything below reads `layout`, because a native menu\n * that cannot be drawn is not an error — it falls back to the panel, and\n * every decision after this one has to be made against the layout that is\n * really being drawn rather than the one that was requested.\n */\n const swiftMenu = layout === 'native' ? getSwiftUIMenu() : null;\n const composeMenu = layout === 'native' && !swiftMenu ? getComposeMenu() : null;\n const resolvedLayout: FabGroupLayout =\n layout === 'native' && !swiftMenu && !composeMenu ? 'menu' : layout;\n\n /*\n * One shared value for the whole dial rather than one per action.\n *\n * The stagger is a function of the action's index, applied on the UI\n * thread inside each action's own style — so opening is a single timing\n * driving every row, and there is no chain of JavaScript timeouts to get\n * out of step with itself when the dial is closed halfway through opening.\n */\n /*\n * The actions stay mounted while the spring runs back down, so closing is\n * the opening in reverse rather than a cut. `closing` is set the moment\n * `open` drops and cleared by the spring when it comes to rest; the\n * mounted-only-while-open rule below still holds once it has.\n */\n const [closing, setClosing] = useState(false);\n // Noted during render rather than in an effect: an effect lands a frame\n // after the render that dropped `open`, and that frame would have no\n // actions in it — a blink, and a remount of the material mid-flight.\n const [wasOpen, setWasOpen] = useState(open);\n if (wasOpen !== open) {\n setWasOpen(open);\n if (!open) setClosing(true);\n }\n const settle = useCallback(() => setClosing(false), []);\n const progress = useDerivedValue(\n () =>\n withSpring(open ? 1 : 0, OPEN_SPRING, (finished) => {\n if (finished && !open) runOnJS(settle)();\n }),\n [open, settle]\n );\n const present = open || closing;\n\n const rotation = useAnimatedStyle(() => ({\n transform: [\n { rotate: `${interpolate(progress.value, [0, 1], [0, OPEN_ROTATION])}deg` },\n ],\n }));\n\n const close = useCallback(() => setOpen(false), [setOpen]);\n\n const context = useMemo(\n () => ({\n progress,\n count: actionCount,\n size,\n glass: material,\n layout: resolvedLayout,\n appearance,\n iconPlacement: iconPlacement ?? MENU_METRICS[appearance].icon,\n rowClassName,\n close,\n }),\n [progress, actionCount, size, material, resolvedLayout, appearance, iconPlacement, rowClassName, close]\n );\n\n const toggle = useCallback(() => {\n if (haptics) selectionTick();\n setOpen(!open);\n }, [haptics, open, setOpen]);\n\n // An open dial owns the back button: back should shut it, not leave it\n // standing over the screen underneath.\n useBackHandler(open, close);\n\n const themedDestructive = useCSSVariable('--color-destructive');\n\n const Group = material && resolvedLayout === 'dial' ? GlassContainer : View;\n\n /*\n * The trigger as the platform hosts it.\n *\n * Sized here rather than left to the platform, on both axes where both are\n * known. An axis handed to `matchContents` is written back to for good, and\n * a hosted view with nothing definite above it is the crash this library\n * has already paid for twice — so a circle states its diameter, and only an\n * extended button's width, which is its label's, is the platform's to\n * measure.\n */\n const triggerSize = SIZE_PX[size];\n const triggerExtended = !!label;\n const triggerFrame = triggerExtended\n ? { height: triggerSize }\n : { width: triggerSize, height: triggerSize };\n\n const nativeTrigger = (\n \n \n {label}\n \n \n );\n\n /*\n * The screen behind an open platform menu, frosted the way it is behind\n * the dial and the panel — the page is what recedes, and a native menu\n * over an untouched page reads as a control belonging to something else.\n *\n * It closes on a tap, which is the safety net rather than the mechanism.\n * The platform dismisses its own menu from its own window, so this never\n * sees that tap and never competes for it; what it catches is the next\n * one. Menu content is not promised an `onDisappear`, and a frosted page\n * with no way back would be worse than no scrim at all.\n */\n const nativeScrim = open ? (\n \n \n \n ) : null;\n\n /*\n * SwiftUI's menu. The trigger goes through the `label` slot, which takes a\n * view as readily as a string — so the button stays ours and only the menu\n * is the platform's.\n *\n * The open state is not passed and cannot be: SwiftUI owns it. `open` and\n * `onOpenChange` are inert on this path, which is stated on the prop.\n */\n if (swiftMenu) {\n const { Host, RNHostView, Menu, Button } = swiftMenu;\n const swiftModifiers = getSwiftUIModifiers();\n\n /*\n * What each row carries beyond its label.\n *\n * The first one also carries the menu's open state. SwiftUI's menu owns\n * that state and reports nothing about it, but it builds its content\n * only when it presents it — so the first row appearing is the menu\n * appearing, and that is what puts the screen behind it under the\n * scrim.\n */\n const itemModifiers = (item: Partial, index: number) => {\n const list: unknown[] = [];\n if (item.disabled && swiftModifiers?.disabled) {\n list.push(swiftModifiers.disabled(true));\n }\n if (index === 0 && swiftModifiers?.onAppear) {\n list.push(swiftModifiers.onAppear(() => setOpen(true)));\n }\n /*\n * Every row carries the teardown, not just the first.\n *\n * The one that reports the menu opening is enough to raise the scrim,\n * but taking it down cannot rest on a single view: whichever row\n * SwiftUI tears down first has to be able to say so, or a menu\n * dismissed from anywhere else leaves the page frosted behind it.\n */\n if (swiftModifiers?.onDisappear) {\n list.push(swiftModifiers.onDisappear(() => setOpen(false)));\n }\n return list.length > 0 ? list : undefined;\n };\n\n return (\n <>\n {nativeScrim}\n \n {nativeTrigger}}>\n {actions.map((action, index) => {\n const item = action.props as Partial;\n return (\n {\n close();\n item.onPress?.();\n }\n }\n modifiers={itemModifiers(item, index)}\n />\n );\n })}\n \n \n \n );\n }\n\n /*\n * Compose's dropdown menu — the same instruction, a different control.\n *\n * This one takes the open state rather than owning it, so the group's own\n * state drives it and `open`/`onOpenChange` work here. Its rows take a text\n * slot rather than a label prop, and a glyph of ours goes in the leading\n * slot at a stated size.\n */\n if (composeMenu) {\n const {\n Host,\n RNHostView,\n Text: ComposeText,\n DropdownMenu,\n DropdownMenuItem,\n } = composeMenu;\n const destructiveColor =\n typeof themedDestructive === 'string' ? themedDestructive : undefined;\n\n return (\n <>\n {nativeScrim}\n \n \n \n {nativeTrigger}\n \n \n {actions.map((action, index) => {\n const item = action.props as Partial;\n return (\n {\n close();\n item.onPress?.();\n }}\n >\n {item.icon ? (\n \n \n \n {item.icon}\n \n \n \n ) : null}\n \n {item.label ?? ''}\n \n \n );\n })}\n \n \n \n \n );\n }\n\n /*\n * Two absolutely positioned siblings, scrim first, both in the group's own\n * parent — which is why a group belongs in the screen's root container.\n *\n * This used to go through a portal, and the portal was the bug. Portalled\n * content is mounted at the app root, above the router, and is removed only\n * when the component that declared it unmounts. A stack keeps the screen\n * you pushed from mounted, so a group declared on that screen carried on\n * drawing over every screen after it — open or closed, since the trigger\n * travelled through the portal too.\n *\n * Ordering is the reason the portal was reached for in the first place: a\n * scrim that covers the screen must not cover the dial it belongs to. As\n * siblings it comes out right for free — the scrim is written first and the\n * dial after it, and the later sibling draws on top.\n *\n * Nothing remounts when the dial opens. The trigger is in the same place in\n * the tree either way, so opening adds the scrim and the actions and leaves\n * the button alone.\n */\n return (\n <>\n {open ? (\n // Catches the tap that closes the dial, and says it is modal.\n \n \n \n ) : null}\n\n \n {/* A glass dial's pieces merge while they overlap — the actions are\n one blob with the trigger until they rise clear of it. */}\n \n {/* Mounted only while open, and while closing: a column of\n actions kept alive behind the trigger would still be in the\n accessibility tree, and a screen reader would walk into four\n buttons nobody can see. */}\n {present && resolvedLayout === 'menu' ? (\n \n {actions}\n \n ) : null}\n {present && resolvedLayout === 'dial'\n ? actions.map((action, index) => (\n \n {action}\n \n ))\n : null}\n\n {icon}\n ) : (\n icon\n )\n }\n extended={!!label && !open}\n size={size}\n variant={variant}\n disabled={disabled}\n glass={glass}\n accessibilityLabel={accessibilityLabel}\n accessibilityState={{ disabled, expanded: open }}\n onPress={toggle}\n >\n {label}\n \n \n \n \n );\n }\n);\n\nFabGroup.displayName = 'Fab.Group';\n\n/**\n * The menu panel: one surface, springing out of the trigger's corner.\n *\n * It scales up from that corner rather than fading in, and the two are not\n * interchangeable: the material cannot be faded — at zero it stops drawing\n * and does not come back — and a panel growing out of the button is what\n * says the button opened. The rows inside fade in on the dial's own stagger,\n * nearest the trigger first.\n */\nfunction FabMenu({\n progress,\n placement,\n size,\n glass,\n appearance,\n width: widthProp,\n radius: radiusProp,\n className,\n children,\n}: {\n /** The dial's own 0-to-1, so the panel and its unmount cannot disagree. */\n progress: SharedValue;\n placement: FabPlacement;\n size: FabSize;\n glass: boolean;\n appearance: FabMenuAppearance;\n width?: number;\n radius?: number;\n className?: string;\n children: PressableChild[];\n}) {\n const metrics = MENU_METRICS[appearance];\n const width = widthProp ?? metrics.width;\n const radius = radiusProp ?? metrics.radius;\n\n /*\n * The panel rides the group's own spring rather than one of its own.\n *\n * It used to run a second, faster spring, and closing raced it against the\n * one that decides when the panel unmounts: the panel reached its collapsed\n * scale early and then sat there, a small box above the trigger, until the\n * slower spring came to rest and took it away. One value cannot race\n * itself, so the panel is gone exactly when the dial says it is.\n *\n * Scale and not opacity, for the same reason the dial's actions are: the\n * material survives a zero scale and does not come back from a zero\n * opacity.\n */\n const style = useAnimatedStyle(() => ({\n transform: [\n {\n scale: interpolate(\n progress.value,\n [0, MENU_POP_AT, 1],\n [0, MENU_FROM_SCALE, 1],\n Extrapolation.CLAMP\n ),\n },\n ],\n }));\n\n return (\n \n \n {children.map((action, index) => (\n 0 && index > 0}>\n {action}\n \n ))}\n \n \n );\n}\n\n/**\n * How far along its own arrival an action is, from the dial's one progress.\n *\n * The stagger runs bottom-up: the action nearest the trigger arrives first,\n * which is the order a hand travelling away from the button meets them in.\n */\nfunction slotProgress(progress: number, count: number, index: number): number {\n 'worklet';\n // A short lag per action rather than a full turn each: the actions leave\n // the trigger nearly together and settle nearly together, so a later one\n // does not rise through a slot an earlier one is already sitting in.\n const from = (count - 1 - index) * STAGGER;\n const to = from + 1 - STAGGER * Math.max(0, count - 1);\n // The spring's overshoot past its target is passed on to every action, so\n // a button arrives with a little bounce rather than stopping dead. It is\n // added on top of the clamped window rather than read through it: a window\n // that ends before the dial's does would otherwise leave its action past\n // its slot for good.\n const within = interpolate(progress, [from, Math.min(1, to)], [0, 1], Extrapolation.CLAMP);\n return within + Math.max(0, progress - 1);\n}\n\n/** The same progress, decelerating into 1 and never past it — for size and opacity. */\nfunction settled(t: number): number {\n 'worklet';\n return Easing.out(Easing.cubic)(Math.min(1, Math.max(0, t)));\n}\n\n/** Which slot an action is in — what it needs to know to animate itself. */\nconst FabSlotContext = createContext(0);\n\n/**\n * One action's slot in the unfolding.\n *\n * The stagger runs bottom-up: the action nearest the trigger arrives first,\n * which is the order a hand travelling away from the button meets them in.\n */\nfunction FabActionSlot({\n index,\n separator = false,\n children,\n}: {\n index: number;\n /** A hairline above the slot — a menu row's, never a dial button's. */\n separator?: boolean;\n children: PressableChild;\n}) {\n const { progress, count, glass, layout, close } = useFabGroup('Fab.Action');\n // A menu's rows are content on one panel, not glass of their own, so they\n // may fade; a dial's actions are each their own material.\n const material = glass && layout === 'dial';\n\n /*\n * Whatever is in the slot closes the dial when it is pressed.\n *\n * `Fab.Action` does this itself, but a plain `Fab` written as a child is a\n * perfectly reasonable thing to reach for and knows nothing about the dial it\n * is in. Left alone it runs its action — navigating, usually — with the dial\n * still open behind it. Wrapping the handler here covers both, and closing\n * twice is closing once.\n */\n const { onPress } = children.props;\n const child = onPress\n ? cloneElement(children, {\n onPress: (event: GestureResponderEvent) => {\n close();\n onPress(event);\n },\n })\n : children;\n\n /*\n * A glass action never fades. The material stops drawing under an ancestor\n * at zero opacity and does not come back when the opacity does, so a fade\n * from zero is a button that sometimes never appears — whichever ones got\n * their first frame at zero. A glass dial's action animates its own parts\n * instead, rising out of the trigger; see `Fab.Action`. The slot only\n * fades a plain action, where it reads better on a flat surface.\n */\n const style = useAnimatedStyle(() => {\n if (material) return {};\n const t = slotProgress(progress.value, count, index);\n return {\n opacity: settled(t),\n transform: [{ translateY: interpolate(t, [0, 1], [ACTION_TRAVEL, 0]) }],\n };\n });\n\n return (\n \n \n {separator ? : null}\n {child}\n \n \n );\n}\n\n/* -------------------------------------------------------------------------- *\n * Action\n * -------------------------------------------------------------------------- */\n\nexport interface FabActionProps extends Omit {\n className?: string;\n /** The glyph. */\n icon?: ReactNode;\n /**\n * The glyph for a native menu row, as an SF Symbol name.\n *\n * `layout=\"native\"` only, and iOS only — SwiftUI names its symbols rather\n * than taking a view for them, so `icon` cannot cross over. Ignored\n * everywhere else, so a group can carry both and be right on either path.\n */\n systemImage?: string;\n /** What it does, beside the glyph. A column of unlabelled circles is a quiz. */\n label?: string;\n onPress?: () => void;\n disabled?: boolean;\n /** Draws it in the destructive colour, for the one that removes something. */\n destructive?: boolean;\n /** Extra classes for the label — the chip in a dial, the row's text in a menu. */\n labelClassName?: string;\n}\n\n/**\n * One choice in an open dial: a small round button with its name beside it.\n *\n * The label is a chip of its own rather than text on the background, because\n * the background is whatever the screen behind the scrim happens to be and\n * plain text over it is legible on some screens and not others.\n *\n * Pressing one closes the dial before running the action. Leaving it open over\n * whatever the action just did is the wrong default: the dial is a menu, and a\n * menu that stays up after a choice reads as the choice not having registered.\n */\nconst FabAction = forwardRef(\n (\n {\n className,\n icon,\n // Read off the element by a native group, never rendered here.\n systemImage: _systemImage,\n label,\n onPress,\n disabled = false,\n destructive = false,\n labelClassName,\n ...props\n },\n ref\n ) => {\n const { progress, count, size, glass, layout, appearance, iconPlacement, rowClassName, close } =\n useFabGroup('Fab.Action');\n const index = useContext(FabSlotContext);\n const [pressed, setPressed] = useState(false);\n\n /*\n * A glass dial's action rises out of the trigger.\n *\n * It starts small and sitting on the button, and springs up to its slot\n * on the dial's stagger; inside the group's glass container the two\n * materials are one blob until it pulls free. The distance is known from\n * the fixed sizes, so nothing is measured. The label follows once the\n * button is most of the way there, sliding in from the button's side —\n * it is glass too, so it moves rather than fades.\n */\n const actionSize: FabSize = size === 'lg' ? 'md' : 'sm';\n const rise =\n (count - 1 - index) * (SIZE_PX[actionSize] + GROUP_GAP) +\n GROUP_GAP +\n SIZE_PX[size] / 2 +\n SIZE_PX[actionSize] / 2;\n // Travel follows the spring, overshoot and all; size settles without it.\n const buttonStyle = useAnimatedStyle(() => {\n const t = slotProgress(progress.value, count, index);\n return {\n transform: [\n { translateY: interpolate(t, [0, 1], [rise, 0]) },\n { scale: interpolate(settled(t), [0, 1], [RISE_FROM_SCALE, 1]) },\n ],\n };\n });\n const chipStyle = useAnimatedStyle(() => {\n const t = slotProgress(progress.value, count, index);\n // Rides up with its button, then grows out of it once the button is\n // most of the way to its slot.\n const late = settled(interpolate(t, [LABEL_AFTER, 1], [0, 1], 'clamp'));\n return {\n transform: [\n { translateY: interpolate(t, [0, 1], [rise, 0]) },\n { translateX: interpolate(late, [0, 1], [LABEL_TRAVEL, 0]) },\n { scale: interpolate(late, [0, 1], [RISE_FROM_SCALE, 1]) },\n ],\n };\n });\n\n const handlePress = useCallback(() => {\n close();\n onPress?.();\n }, [close, onPress]);\n\n const themedRow = useCSSVariable(destructive ? '--color-destructive' : '--color-foreground');\n const rowColor = typeof themedRow === 'string' ? themedRow : undefined;\n\n /*\n * In a menu the action is a row on the panel. The press tints the row\n * rather than scaling it — a row that shrinks inside a panel that does\n * not looks detached from it — and the tint is React state rather than a\n * worklet because it is a colour toggle, not a curve.\n */\n if (layout === 'menu') {\n const wells = appearance === 'wells';\n const glyph = wells ? (\n \n {icon}\n \n ) : (\n icon\n );\n return (\n \n setPressed(true)}\n onPressOut={() => setPressed(false)}\n pressScale={1}\n className={cn(\n 'flex-row items-center gap-3',\n wells ? 'h-12 rounded-2xl px-2' : 'h-11 px-4',\n iconPlacement === 'trailing' ? 'justify-between' : null,\n pressed && 'bg-foreground/10',\n disabled && 'opacity-40',\n rowClassName,\n className\n )}\n {...props}\n >\n {iconPlacement === 'leading' ? glyph : null}\n \n {label}\n \n {iconPlacement === 'trailing' ? glyph : null}\n \n \n );\n }\n\n return (\n \n {label ? (\n // The chip is in the same material as the button beside it, so a\n // glass dial is glass all the way across and not glass with paper\n // labels. Without the material it is the popover surface it was.\n glass ? (\n \n \n \n {label}\n \n \n \n ) : (\n \n \n {label}\n \n \n )\n ) : null}\n \n \n \n \n );\n }\n);\n\nFabAction.displayName = 'Fab.Action';\n\nexport const Fab = Object.assign(FabRoot, {\n Group: FabGroup,\n Action: FabAction,\n});\n" } ] } diff --git a/apps/docs/public/r/feedback.json b/apps/docs/public/r/feedback.json index 02c9ff999..fc68aa652 100644 --- a/apps/docs/public/r/feedback.json +++ b/apps/docs/public/r/feedback.json @@ -8,6 +8,7 @@ "icons", "keyboard-avoider", "portal", + "refs", "scrim", "text", "use-back-handler" @@ -21,7 +22,7 @@ { "path": "ui/feedback.tsx", "type": "registry:ui", - "content": "/**\n * Feedback — a dialog whose body is something to write in, and whose\n * actions sit in a band around it.\n *\n * ```tsx\n * \n * \n * \n * What should we fix first?\n * \n * \n * \n * \n * \n * \n * \n * \n * \n * ```\n *\n * ## Why the panel is a panel and not the dialog\n *\n * A dialog that asks for a sentence has two jobs on screen at once: hold what\n * is being written, and offer the two things to do with it. `Dialog` puts both\n * on one surface, which is right when the body is a line of prose — nothing\n * about it says \"this is where you type\".\n *\n * Here the writing surface is a well set into the dialog, and the buttons sit\n * in the band around it. The recess is the whole affordance: it says the field\n * is the page and everything else is the frame around it, before a word has\n * been read. It is also why the field has no border of its own — an outline\n * inside a well is two edges saying the same thing.\n *\n * ## The actions are equal, and one of them is not\n *\n * Cancel and Submit take the same width, because they are the same size of\n * decision — this is a sentence somebody wrote, not a deletion. What separates\n * them is weight: Submit is filled in the foreground colour and Cancel is a\n * tint of it, so the difference is legible at a glance without either being\n * hidden.\n *\n * ## Nothing is submitted empty\n *\n * `Submit` disables itself while the field is empty. A dialog that accepts an\n * empty answer produces empty feedback, and the person who sent it believes\n * they said something.\n *\n * Disabled, it drops the fill instead of dimming it. A tinted accent pill\n * still reads as the thing to press, whatever its opacity.\n */\nimport {\n createContext,\n forwardRef,\n isValidElement,\n useCallback,\n useContext,\n useMemo,\n useState,\n type ReactElement,\n type ReactNode,\n} from 'react';\nimport {\n Pressable,\n TextInput,\n View,\n type TextInputProps,\n type ViewProps,\n} from 'react-native';\nimport Animated, { FadeIn, FadeOut, ZoomIn } from 'react-native-reanimated';\nimport { tv } from 'tailwind-variants';\nimport { XIcon } from '@/components/ui/icons';\nimport { AnimatedPressable } from '@/components/ui/animated-pressable';\nimport { KeyboardAvoider } from '@/components/ui/keyboard-avoider';\nimport { ModalPortal } from '@/components/ui/portal';\nimport { Scrim } from '@/components/ui/scrim';\nimport { Text, textChildren, type TextProps } from '@/components/ui/text';\nimport { useBackHandler } from '@/hooks/use-back-handler';\nimport { cn } from '@/lib/cn';\n\n/**\n * The shell's padding, in points, and the corner it draws.\n *\n * The panel's corner is the shell's less that padding, which is what keeps the\n * two curves concentric — a well whose radius does not account for the band\n * around it leaves a crescent of shell at each corner, thicker there than it is\n * along the sides.\n */\nconst SHELL_PADDING = 8;\nconst SHELL_RADIUS = 38;\nconst PANEL_RADIUS = SHELL_RADIUS - SHELL_PADDING;\n\n/**\n * How far the action row is held in from the shell's own edge, beyond the\n * padding the panel gets.\n *\n * The buttons are narrower than the well above them on purpose: a row that ran\n * the full width would read as a third edge of the dialog rather than as two\n * things to press.\n */\nconst FOOTER_INSET = 26;\n\n/** Room to write in before the field starts growing. */\nconst FIELD_MIN_HEIGHT = 200;\n\n/**\n * The ✕'s drawn size, and the slop that takes its touch box to 48.\n *\n * Made up with slop rather than with size, because the glyph is set against\n * the title's cap height and a circle large enough to press comfortably would\n * be taller than the line it sits on.\n */\nconst CLOSE_SIZE = 22;\nconst CLOSE_HIT_SLOP = 13;\n\nconst feedbackVariants = tv({\n slots: {\n /*\n * The shell is `bg-popover` with an `inset` band laid over it rather than\n * a colour of its own. `--color-inset` is a translucent black in every\n * theme, so the shell always comes out darker than the panel it holds —\n * which is the one thing this design cannot get from the surface ladder,\n * because that ladder runs darker in a light theme and lighter in a dark\n * one, and the recess has to read the same way in both.\n */\n shell: 'w-full max-w-sm overflow-hidden bg-popover shadow-xl',\n recess: 'absolute inset-0 bg-inset',\n panel: 'overflow-hidden bg-popover',\n title: 'pe-9 text-[22px] font-semibold leading-[28px] text-popover-foreground',\n close: 'absolute items-center justify-center rounded-full bg-foreground/10',\n field: 'p-0 text-[17px] leading-[24px] text-muted-foreground',\n footer: 'flex-row items-center gap-3.5',\n action: 'h-11 flex-1 items-center justify-center rounded-full',\n actionLabel: 'text-[16px]',\n },\n variants: {\n tone: {\n /** The one that discards. A tint of the foreground, not a fill. */\n cancel: {\n action: 'bg-foreground/10',\n actionLabel: 'font-medium text-muted-foreground',\n },\n /** The one that sends. Filled, and the only filled thing on the dialog. */\n submit: {\n action: 'bg-primary',\n actionLabel: 'font-semibold text-primary-foreground',\n },\n },\n disabled: {\n true: {},\n },\n },\n compoundVariants: [\n /*\n * A disabled Submit loses its fill rather than dimming it.\n *\n * A tinted accent pill is still an accent pill: at any opacity it reads as\n * the thing on the dialog to press, so an inert one gets pressed and then\n * gets reported as broken. Dropping the fill is what makes the state\n * legible before the press.\n *\n * The tint is fainter than Cancel's own on purpose. Matched fills would\n * read as two Cancels rather than as one live action and one dead one.\n */\n {\n tone: 'submit',\n disabled: true,\n class: {\n action: 'bg-foreground/[0.04]',\n actionLabel: 'font-medium text-muted-foreground/50',\n },\n },\n { tone: 'cancel', disabled: true, class: { action: 'opacity-[0.45]' } },\n ],\n});\n\ninterface FeedbackContextValue {\n open: boolean;\n setOpen: (open: boolean) => void;\n /** What is in the field, so Submit can refuse to send nothing. */\n value: string;\n setValue: (value: string) => void;\n}\n\nconst FeedbackContext = createContext(null);\n\nfunction useFeedback(component: string): FeedbackContextValue {\n const context = useContext(FeedbackContext);\n if (!context) {\n throw new Error(`${component} must be used within a `);\n }\n return context;\n}\n\nexport interface FeedbackProps {\n children: ReactNode;\n /** Controlled open state. */\n open?: boolean;\n onOpenChange?: (open: boolean) => void;\n /** Initial state when uncontrolled. */\n defaultOpen?: boolean;\n /** The message, when the caller holds it. Leave unset to let the field keep it. */\n value?: string;\n /** Starting message for an uncontrolled field. Ignored once `value` is passed. */\n defaultValue?: string;\n onValueChange?: (value: string) => void;\n}\n\nfunction FeedbackRoot({\n children,\n open,\n onOpenChange,\n defaultOpen = false,\n value,\n defaultValue = '',\n onValueChange,\n}: FeedbackProps) {\n const [internalOpen, setInternalOpen] = useState(defaultOpen);\n const openControlled = open !== undefined;\n const resolvedOpen = openControlled ? open : internalOpen;\n\n const [internalValue, setInternalValue] = useState(defaultValue);\n const valueControlled = value !== undefined;\n const resolvedValue = valueControlled ? value : internalValue;\n\n const setOpen = useCallback(\n (next: boolean) => {\n if (!openControlled) setInternalOpen(next);\n onOpenChange?.(next);\n },\n [openControlled, onOpenChange]\n );\n\n const setValue = useCallback(\n (next: string) => {\n if (!valueControlled) setInternalValue(next);\n onValueChange?.(next);\n },\n [valueControlled, onValueChange]\n );\n\n const context = useMemo(\n () => ({ open: resolvedOpen, setOpen, value: resolvedValue, setValue }),\n [resolvedOpen, setOpen, resolvedValue, setValue]\n );\n\n return (\n \n {children}\n \n );\n}\nFeedbackRoot.displayName = 'Feedback';\n\ninterface FeedbackTriggerProps {\n children: ReactElement<{ onPress?: (...args: unknown[]) => void }>;\n}\n\n/** Wraps its child and opens the dialog on press. */\nfunction FeedbackTrigger({ children }: FeedbackTriggerProps) {\n const { setOpen } = useFeedback('Feedback.Trigger');\n if (!isValidElement(children)) return children;\n return (\n setOpen(true)}>\n {children}\n \n );\n}\nFeedbackTrigger.displayName = 'Feedback.Trigger';\n\nexport interface FeedbackContentProps extends ViewProps {\n className?: string;\n /** Whether tapping outside or pressing back closes it. */\n dismissible?: boolean;\n /** Frost the screen behind instead of dimming it. Needs `expo-blur`. */\n blur?: boolean;\n children?: ReactNode;\n}\n\n/** The shell: the recessed band, and everything laid in it. */\nfunction FeedbackContent({\n className,\n dismissible = true,\n blur = false,\n children,\n ...props\n}: FeedbackContentProps) {\n const context = useFeedback('Feedback.Content');\n const { open, setOpen } = context;\n const slots = feedbackVariants();\n\n useBackHandler(open && dismissible, () => setOpen(false));\n\n if (!open) return null;\n\n return (\n \n {/* Portal content mounts under PortalHost, outside this provider's\n subtree — re-provide the context so Close and Submit keep working. */}\n \n \n \n setOpen(false) : undefined}\n />\n {/*\n The body is a field, so the dialog gets out of the keyboard's way\n rather than sitting behind it. `lift` and not `dock`: it is centred\n on the screen rather than pinned to an edge, so it only has to move\n by however much the keyboard actually overlaps it.\n */}\n \n \n \n {textChildren(children)}\n \n \n \n \n \n );\n}\nFeedbackContent.displayName = 'Feedback.Content';\n\nexport interface FeedbackPanelProps extends ViewProps {\n className?: string;\n children?: ReactNode;\n}\n\n/** The well set into the shell: the title, the ✕ and the field. */\nconst FeedbackPanel = forwardRef(\n ({ className, children, ...props }, ref) => {\n const slots = feedbackVariants();\n return (\n \n {textChildren(children)}\n \n );\n }\n);\nFeedbackPanel.displayName = 'Feedback.Panel';\n\nconst FeedbackTitle = forwardRef, TextProps>(\n ({ className, ...props }, ref) => {\n const slots = feedbackVariants();\n return ;\n }\n);\nFeedbackTitle.displayName = 'Feedback.Title';\n\nexport interface FeedbackCloseProps extends ViewProps {\n className?: string;\n /** How the ✕ announces itself. */\n label?: string;\n /** Runs instead of closing. Call `onOpenChange` yourself if you pass this. */\n onPress?: () => void;\n}\n\n/** The ✕ in the panel's corner. */\nfunction FeedbackClose({\n className,\n label = 'Close',\n onPress,\n ...props\n}: FeedbackCloseProps) {\n const { setOpen } = useFeedback('Feedback.Close');\n const slots = feedbackVariants();\n return (\n setOpen(false))}\n className={slots.close({ className })}\n style={{ top: 20, right: 20, width: CLOSE_SIZE, height: CLOSE_SIZE }}\n {...props}\n >\n \n \n );\n}\nFeedbackClose.displayName = 'Feedback.Close';\n\nexport interface FeedbackFieldProps\n extends Omit {\n className?: string;\n /** The message. Leave unset to let the dialog hold it. */\n value?: string;\n onChangeText?: (value: string) => void;\n /** Room to write in before the field starts growing. */\n minHeight?: number;\n}\n\n/**\n * What is being written.\n *\n * No border and no background of its own: it is already inside a well, and an\n * outline drawn inside one is two edges making the same point.\n *\n * The caret is deliberately left to the platform. Every system draws its own\n * accent there, and a field that overrides it is a field that looks like it\n * belongs to a different phone.\n */\nconst FeedbackField = forwardRef(\n (\n {\n className,\n value,\n onChangeText,\n minHeight = FIELD_MIN_HEIGHT,\n placeholder = 'Tell us what you think',\n style,\n ...props\n },\n ref\n ) => {\n const dialog = useFeedback('Feedback.Field');\n const slots = feedbackVariants();\n const text = value ?? dialog.value;\n\n return (\n {\n dialog.setValue(next);\n onChangeText?.(next);\n }}\n className={slots.field({ className })}\n style={[{ minHeight }, style]}\n {...props}\n />\n );\n }\n);\nFeedbackField.displayName = 'Feedback.Field';\n\nexport interface FeedbackFooterProps extends ViewProps {\n className?: string;\n children?: ReactNode;\n}\n\n/** The action row, in the band under the panel. */\nconst FeedbackFooter = forwardRef(\n ({ className, children, style, ...props }, ref) => {\n const slots = feedbackVariants();\n return (\n \n {children}\n \n );\n }\n);\nFeedbackFooter.displayName = 'Feedback.Footer';\n\nexport interface FeedbackActionProps extends ViewProps {\n className?: string;\n labelClassName?: string;\n disabled?: boolean;\n onPress?: () => void;\n children?: ReactNode;\n}\n\n/** Shared body for the two buttons, so they can only differ in the ways they should. */\nfunction Action({\n tone,\n className,\n labelClassName,\n disabled,\n onPress,\n children,\n ...props\n}: FeedbackActionProps & { tone: 'cancel' | 'submit' }) {\n const slots = feedbackVariants({ tone, disabled: !!disabled });\n return (\n \n {textChildren(children, (label) => (\n {label}\n ))}\n \n );\n}\n\n/** Discards and closes. Give it `onPress` to do something else first. */\nfunction FeedbackCancel({\n children = 'Cancel',\n onPress,\n ...props\n}: FeedbackActionProps) {\n const { setOpen } = useFeedback('Feedback.Cancel');\n return (\n setOpen(false))} {...props}>\n {children}\n \n );\n}\nFeedbackCancel.displayName = 'Feedback.Cancel';\n\nexport interface FeedbackSubmitProps extends FeedbackActionProps {\n /**\n * Hand the message to the caller. The dialog does not close itself here —\n * sending usually has to finish first, and a dialog that closed on the press\n * would take its own error message with it.\n */\n onSubmit?: (value: string) => void;\n}\n\n/** Sends. Inert while the field is empty. */\nfunction FeedbackSubmit({\n children = 'Submit',\n disabled,\n onPress,\n onSubmit,\n ...props\n}: FeedbackSubmitProps) {\n const { value } = useFeedback('Feedback.Submit');\n // Empty feedback is worse than none: it is sent by somebody who believes\n // they said something.\n const empty = value.trim().length === 0;\n return (\n {\n onPress?.();\n onSubmit?.(value);\n }}\n {...props}\n >\n {children}\n \n );\n}\nFeedbackSubmit.displayName = 'Feedback.Submit';\n\nexport const Feedback = Object.assign(FeedbackRoot, {\n Trigger: FeedbackTrigger,\n Content: FeedbackContent,\n Panel: FeedbackPanel,\n Title: FeedbackTitle,\n Close: FeedbackClose,\n Field: FeedbackField,\n Footer: FeedbackFooter,\n Cancel: FeedbackCancel,\n Submit: FeedbackSubmit,\n});\n" + "content": "/**\n * Feedback — a dialog whose body is something to write in, and whose\n * actions sit in a band around it.\n *\n * ```tsx\n * \n * \n * \n * What should we fix first?\n * \n * \n * \n * \n * \n * \n * \n * \n * \n * ```\n *\n * ## Why the panel is a panel and not the dialog\n *\n * A dialog that asks for a sentence has two jobs on screen at once: hold what\n * is being written, and offer the two things to do with it. `Dialog` puts both\n * on one surface, which is right when the body is a line of prose — nothing\n * about it says \"this is where you type\".\n *\n * Here the writing surface is a well set into the dialog, and the buttons sit\n * in the band around it. The recess is the whole affordance: it says the field\n * is the page and everything else is the frame around it, before a word has\n * been read. It is also why the field has no border of its own — an outline\n * inside a well is two edges saying the same thing.\n *\n * ## The actions are equal, and one of them is not\n *\n * Cancel and Submit take the same width, because they are the same size of\n * decision — this is a sentence somebody wrote, not a deletion. What separates\n * them is weight: Submit is filled in the foreground colour and Cancel is a\n * tint of it, so the difference is legible at a glance without either being\n * hidden.\n *\n * ## Nothing is submitted empty\n *\n * `Submit` disables itself while the field is empty. A dialog that accepts an\n * empty answer produces empty feedback, and the person who sent it believes\n * they said something.\n *\n * Disabled, it drops the fill instead of dimming it. A tinted accent pill\n * still reads as the thing to press, whatever its opacity.\n */\nimport {\n createContext,\n forwardRef,\n isValidElement,\n useCallback,\n useContext,\n useMemo,\n useState,\n type ReactElement,\n type ReactNode,\n} from 'react';\nimport {\n Pressable,\n TextInput,\n View,\n type TextInputProps,\n type ViewProps,\n} from 'react-native';\nimport Animated, { FadeIn, FadeOut, ZoomIn } from 'react-native-reanimated';\nimport { tv } from 'tailwind-variants';\nimport { XIcon } from '@/components/ui/icons';\nimport { AnimatedPressable } from '@/components/ui/animated-pressable';\nimport { KeyboardAvoider } from '@/components/ui/keyboard-avoider';\nimport { ModalPortal } from '@/components/ui/portal';\nimport { Scrim } from '@/components/ui/scrim';\nimport { Text, textChildren, type TextProps } from '@/components/ui/text';\nimport { useBackHandler } from '@/hooks/use-back-handler';\nimport { cn } from '@/lib/cn';\nimport type { TextInputRef, TextRef, ViewRef } from '@/components/ui/refs';\n\n/**\n * The shell's padding, in points, and the corner it draws.\n *\n * The panel's corner is the shell's less that padding, which is what keeps the\n * two curves concentric — a well whose radius does not account for the band\n * around it leaves a crescent of shell at each corner, thicker there than it is\n * along the sides.\n */\nconst SHELL_PADDING = 8;\nconst SHELL_RADIUS = 38;\nconst PANEL_RADIUS = SHELL_RADIUS - SHELL_PADDING;\n\n/**\n * How far the action row is held in from the shell's own edge, beyond the\n * padding the panel gets.\n *\n * The buttons are narrower than the well above them on purpose: a row that ran\n * the full width would read as a third edge of the dialog rather than as two\n * things to press.\n */\nconst FOOTER_INSET = 26;\n\n/** Room to write in before the field starts growing. */\nconst FIELD_MIN_HEIGHT = 200;\n\n/**\n * The ✕'s drawn size, and the slop that takes its touch box to 48.\n *\n * Made up with slop rather than with size, because the glyph is set against\n * the title's cap height and a circle large enough to press comfortably would\n * be taller than the line it sits on.\n */\nconst CLOSE_SIZE = 22;\nconst CLOSE_HIT_SLOP = 13;\n\nconst feedbackVariants = tv({\n slots: {\n /*\n * The shell is `bg-popover` with an `inset` band laid over it rather than\n * a colour of its own. `--color-inset` is a translucent black in every\n * theme, so the shell always comes out darker than the panel it holds —\n * which is the one thing this design cannot get from the surface ladder,\n * because that ladder runs darker in a light theme and lighter in a dark\n * one, and the recess has to read the same way in both.\n */\n shell: 'w-full max-w-sm overflow-hidden bg-popover shadow-xl',\n recess: 'absolute inset-0 bg-inset',\n panel: 'overflow-hidden bg-popover',\n title: 'pe-9 text-[22px] font-semibold leading-[28px] text-popover-foreground',\n close: 'absolute items-center justify-center rounded-full bg-foreground/10',\n field: 'p-0 text-[17px] leading-[24px] text-muted-foreground',\n footer: 'flex-row items-center gap-3.5',\n action: 'h-11 flex-1 items-center justify-center rounded-full',\n actionLabel: 'text-[16px]',\n },\n variants: {\n tone: {\n /** The one that discards. A tint of the foreground, not a fill. */\n cancel: {\n action: 'bg-foreground/10',\n actionLabel: 'font-medium text-muted-foreground',\n },\n /** The one that sends. Filled, and the only filled thing on the dialog. */\n submit: {\n action: 'bg-primary',\n actionLabel: 'font-semibold text-primary-foreground',\n },\n },\n disabled: {\n true: {},\n },\n },\n compoundVariants: [\n /*\n * A disabled Submit loses its fill rather than dimming it.\n *\n * A tinted accent pill is still an accent pill: at any opacity it reads as\n * the thing on the dialog to press, so an inert one gets pressed and then\n * gets reported as broken. Dropping the fill is what makes the state\n * legible before the press.\n *\n * The tint is fainter than Cancel's own on purpose. Matched fills would\n * read as two Cancels rather than as one live action and one dead one.\n */\n {\n tone: 'submit',\n disabled: true,\n class: {\n action: 'bg-foreground/[0.04]',\n actionLabel: 'font-medium text-muted-foreground/50',\n },\n },\n { tone: 'cancel', disabled: true, class: { action: 'opacity-[0.45]' } },\n ],\n});\n\ninterface FeedbackContextValue {\n open: boolean;\n setOpen: (open: boolean) => void;\n /** What is in the field, so Submit can refuse to send nothing. */\n value: string;\n setValue: (value: string) => void;\n}\n\nconst FeedbackContext = createContext(null);\n\nfunction useFeedback(component: string): FeedbackContextValue {\n const context = useContext(FeedbackContext);\n if (!context) {\n throw new Error(`${component} must be used within a `);\n }\n return context;\n}\n\nexport interface FeedbackProps {\n children: ReactNode;\n /** Controlled open state. */\n open?: boolean;\n onOpenChange?: (open: boolean) => void;\n /** Initial state when uncontrolled. */\n defaultOpen?: boolean;\n /** The message, when the caller holds it. Leave unset to let the field keep it. */\n value?: string;\n /** Starting message for an uncontrolled field. Ignored once `value` is passed. */\n defaultValue?: string;\n onValueChange?: (value: string) => void;\n}\n\nfunction FeedbackRoot({\n children,\n open,\n onOpenChange,\n defaultOpen = false,\n value,\n defaultValue = '',\n onValueChange,\n}: FeedbackProps) {\n const [internalOpen, setInternalOpen] = useState(defaultOpen);\n const openControlled = open !== undefined;\n const resolvedOpen = openControlled ? open : internalOpen;\n\n const [internalValue, setInternalValue] = useState(defaultValue);\n const valueControlled = value !== undefined;\n const resolvedValue = valueControlled ? value : internalValue;\n\n const setOpen = useCallback(\n (next: boolean) => {\n if (!openControlled) setInternalOpen(next);\n onOpenChange?.(next);\n },\n [openControlled, onOpenChange]\n );\n\n const setValue = useCallback(\n (next: string) => {\n if (!valueControlled) setInternalValue(next);\n onValueChange?.(next);\n },\n [valueControlled, onValueChange]\n );\n\n const context = useMemo(\n () => ({ open: resolvedOpen, setOpen, value: resolvedValue, setValue }),\n [resolvedOpen, setOpen, resolvedValue, setValue]\n );\n\n return (\n \n {children}\n \n );\n}\nFeedbackRoot.displayName = 'Feedback';\n\ninterface FeedbackTriggerProps {\n children: ReactElement<{ onPress?: (...args: unknown[]) => void }>;\n}\n\n/** Wraps its child and opens the dialog on press. */\nfunction FeedbackTrigger({ children }: FeedbackTriggerProps) {\n const { setOpen } = useFeedback('Feedback.Trigger');\n if (!isValidElement(children)) return children;\n return (\n setOpen(true)}>\n {children}\n \n );\n}\nFeedbackTrigger.displayName = 'Feedback.Trigger';\n\nexport interface FeedbackContentProps extends ViewProps {\n className?: string;\n /** Whether tapping outside or pressing back closes it. */\n dismissible?: boolean;\n /** Frost the screen behind instead of dimming it. Needs `expo-blur`. */\n blur?: boolean;\n children?: ReactNode;\n}\n\n/** The shell: the recessed band, and everything laid in it. */\nfunction FeedbackContent({\n className,\n dismissible = true,\n blur = false,\n children,\n ...props\n}: FeedbackContentProps) {\n const context = useFeedback('Feedback.Content');\n const { open, setOpen } = context;\n const slots = feedbackVariants();\n\n useBackHandler(open && dismissible, () => setOpen(false));\n\n if (!open) return null;\n\n return (\n \n {/* Portal content mounts under PortalHost, outside this provider's\n subtree — re-provide the context so Close and Submit keep working. */}\n \n \n \n setOpen(false) : undefined}\n />\n {/*\n The body is a field, so the dialog gets out of the keyboard's way\n rather than sitting behind it. `lift` and not `dock`: it is centred\n on the screen rather than pinned to an edge, so it only has to move\n by however much the keyboard actually overlaps it.\n */}\n \n \n \n {textChildren(children)}\n \n \n \n \n \n );\n}\nFeedbackContent.displayName = 'Feedback.Content';\n\nexport interface FeedbackPanelProps extends ViewProps {\n className?: string;\n children?: ReactNode;\n}\n\n/** The well set into the shell: the title, the ✕ and the field. */\nconst FeedbackPanel = forwardRef(\n ({ className, children, ...props }, ref) => {\n const slots = feedbackVariants();\n return (\n \n {textChildren(children)}\n \n );\n }\n);\nFeedbackPanel.displayName = 'Feedback.Panel';\n\nconst FeedbackTitle = forwardRef(\n ({ className, ...props }, ref) => {\n const slots = feedbackVariants();\n return ;\n }\n);\nFeedbackTitle.displayName = 'Feedback.Title';\n\nexport interface FeedbackCloseProps extends ViewProps {\n className?: string;\n /** How the ✕ announces itself. */\n label?: string;\n /** Runs instead of closing. Call `onOpenChange` yourself if you pass this. */\n onPress?: () => void;\n}\n\n/** The ✕ in the panel's corner. */\nfunction FeedbackClose({\n className,\n label = 'Close',\n onPress,\n ...props\n}: FeedbackCloseProps) {\n const { setOpen } = useFeedback('Feedback.Close');\n const slots = feedbackVariants();\n return (\n setOpen(false))}\n className={slots.close({ className })}\n style={{ top: 20, right: 20, width: CLOSE_SIZE, height: CLOSE_SIZE }}\n {...props}\n >\n \n \n );\n}\nFeedbackClose.displayName = 'Feedback.Close';\n\nexport interface FeedbackFieldProps\n extends Omit {\n className?: string;\n /** The message. Leave unset to let the dialog hold it. */\n value?: string;\n onChangeText?: (value: string) => void;\n /** Room to write in before the field starts growing. */\n minHeight?: number;\n}\n\n/**\n * What is being written.\n *\n * No border and no background of its own: it is already inside a well, and an\n * outline drawn inside one is two edges making the same point.\n *\n * The caret is deliberately left to the platform. Every system draws its own\n * accent there, and a field that overrides it is a field that looks like it\n * belongs to a different phone.\n */\nconst FeedbackField = forwardRef(\n (\n {\n className,\n value,\n onChangeText,\n minHeight = FIELD_MIN_HEIGHT,\n placeholder = 'Tell us what you think',\n style,\n ...props\n },\n ref\n ) => {\n const dialog = useFeedback('Feedback.Field');\n const slots = feedbackVariants();\n const text = value ?? dialog.value;\n\n return (\n {\n dialog.setValue(next);\n onChangeText?.(next);\n }}\n className={slots.field({ className })}\n style={[{ minHeight }, style]}\n {...props}\n />\n );\n }\n);\nFeedbackField.displayName = 'Feedback.Field';\n\nexport interface FeedbackFooterProps extends ViewProps {\n className?: string;\n children?: ReactNode;\n}\n\n/** The action row, in the band under the panel. */\nconst FeedbackFooter = forwardRef(\n ({ className, children, style, ...props }, ref) => {\n const slots = feedbackVariants();\n return (\n \n {children}\n \n );\n }\n);\nFeedbackFooter.displayName = 'Feedback.Footer';\n\nexport interface FeedbackActionProps extends ViewProps {\n className?: string;\n labelClassName?: string;\n disabled?: boolean;\n onPress?: () => void;\n children?: ReactNode;\n}\n\n/** Shared body for the two buttons, so they can only differ in the ways they should. */\nfunction Action({\n tone,\n className,\n labelClassName,\n disabled,\n onPress,\n children,\n ...props\n}: FeedbackActionProps & { tone: 'cancel' | 'submit' }) {\n const slots = feedbackVariants({ tone, disabled: !!disabled });\n return (\n \n {textChildren(children, (label) => (\n {label}\n ))}\n \n );\n}\n\n/** Discards and closes. Give it `onPress` to do something else first. */\nfunction FeedbackCancel({\n children = 'Cancel',\n onPress,\n ...props\n}: FeedbackActionProps) {\n const { setOpen } = useFeedback('Feedback.Cancel');\n return (\n setOpen(false))} {...props}>\n {children}\n \n );\n}\nFeedbackCancel.displayName = 'Feedback.Cancel';\n\nexport interface FeedbackSubmitProps extends FeedbackActionProps {\n /**\n * Hand the message to the caller. The dialog does not close itself here —\n * sending usually has to finish first, and a dialog that closed on the press\n * would take its own error message with it.\n */\n onSubmit?: (value: string) => void;\n}\n\n/** Sends. Inert while the field is empty. */\nfunction FeedbackSubmit({\n children = 'Submit',\n disabled,\n onPress,\n onSubmit,\n ...props\n}: FeedbackSubmitProps) {\n const { value } = useFeedback('Feedback.Submit');\n // Empty feedback is worse than none: it is sent by somebody who believes\n // they said something.\n const empty = value.trim().length === 0;\n return (\n {\n onPress?.();\n onSubmit?.(value);\n }}\n {...props}\n >\n {children}\n \n );\n}\nFeedbackSubmit.displayName = 'Feedback.Submit';\n\nexport const Feedback = Object.assign(FeedbackRoot, {\n Trigger: FeedbackTrigger,\n Content: FeedbackContent,\n Panel: FeedbackPanel,\n Title: FeedbackTitle,\n Close: FeedbackClose,\n Field: FeedbackField,\n Footer: FeedbackFooter,\n Cancel: FeedbackCancel,\n Submit: FeedbackSubmit,\n});\n" } ] } diff --git a/apps/docs/public/r/field.json b/apps/docs/public/r/field.json index aa2efad23..c2c59866f 100644 --- a/apps/docs/public/r/field.json +++ b/apps/docs/public/r/field.json @@ -4,6 +4,7 @@ "description": "Layout and validation-state kit a form control composes into.", "registryDependencies": [ "label", + "refs", "separator", "text" ], @@ -15,7 +16,7 @@ { "path": "ui/field.tsx", "type": "registry:ui", - "content": "/**\n * Field — the layout and validation-state kit a form control composes into.\n *\n * PanelUI's own controls (`Input`, `Checkbox`, `Switch`, `RadioGroup`) already\n * carry their own label, description and error slot, so the common case needs\n * no wrapper at all — pass those props straight through. `Field` exists for\n * what doesn't fit that shape: a horizontal row pairing a control with a\n * label off to the side (a `Switch` in a settings list), several controls\n * grouped under one legend, or a rule breaking a long form into sections.\n *\n * `invalid` / `disabled` / `required` set on the root flow to `Field.Label`\n * and `Field.Description` through context, so a multi-field row states its\n * validity once instead of repeating the prop at every leaf.\n *\n * ```tsx\n * \n * \n * Marketing emails\n * Product updates, at most weekly.\n * \n * \n * \n * ```\n */\nimport {\n createContext,\n forwardRef,\n useContext,\n useId,\n useMemo,\n type ReactNode,\n} from 'react';\nimport { View, type Text as RNText, type ViewProps } from 'react-native';\nimport { tv } from 'tailwind-variants';\nimport { Text, type TextProps, textChildren } from '@/components/ui/text';\nimport { Label, type LabelProps } from '@/components/ui/label';\nimport { Separator } from '@/components/ui/separator';\n\nconst fieldVariants = tv({\n slots: {\n root: 'gap-2',\n content: 'flex-1 flex-col gap-0.5',\n description: 'text-sm text-muted-foreground',\n error: 'gap-1',\n errorText: 'text-sm text-destructive',\n bullet: 'flex-row gap-1.5',\n bulletDot: 'text-sm text-destructive',\n set: 'gap-4',\n legend: 'text-foreground',\n group: 'gap-6',\n separator: 'flex-row items-center gap-3',\n separatorLabel: 'text-xs text-muted-foreground',\n title: 'text-base font-medium text-foreground',\n },\n variants: {\n orientation: {\n vertical: { root: 'flex-col' },\n horizontal: { root: 'flex-row items-center justify-between gap-4' },\n },\n disabled: {\n true: { root: 'opacity-[0.64]' },\n },\n legendVariant: {\n legend: { legend: 'text-base font-semibold' },\n label: { legend: 'text-sm font-medium' },\n },\n },\n defaultVariants: {\n orientation: 'vertical',\n },\n});\n\ninterface FieldState {\n invalid: boolean;\n disabled: boolean;\n required: boolean;\n relationshipIds: string[];\n}\n\nconst FieldContext = createContext({\n invalid: false,\n disabled: false,\n required: false,\n relationshipIds: [],\n});\n\n/** Android can announce the label, help and error text rendered beside a control. */\nexport function useFieldLabelledBy(): string[] | undefined {\n const { relationshipIds } = useContext(FieldContext);\n return relationshipIds.length > 0 ? relationshipIds : undefined;\n}\n\nexport interface FieldProps extends ViewProps {\n className?: string;\n /** `horizontal` puts a label beside the control instead of above it. */\n orientation?: 'vertical' | 'horizontal';\n /** Marks every `Field.Label`/`Field.Description` below as invalid. */\n invalid?: boolean;\n disabled?: boolean;\n /** Marks every `Field.Label` below as required, same as `Label`'s own prop. */\n required?: boolean;\n children?: ReactNode;\n}\n\nconst FieldRoot = forwardRef(\n (\n {\n className,\n orientation = 'vertical',\n invalid = false,\n disabled = false,\n required = false,\n children,\n ...props\n },\n ref\n ) => {\n const { root } = fieldVariants({ orientation, disabled });\n const id = useId().replace(/:/g, '');\n const labelId = `panelui-field-${id}-label`;\n const titleId = `panelui-field-${id}-title`;\n const descriptionId = `panelui-field-${id}-description`;\n const errorId = `panelui-field-${id}-error`;\n const state = useMemo(\n () => ({\n invalid,\n disabled,\n required,\n relationshipIds: [labelId, titleId, descriptionId, errorId],\n }),\n [invalid, disabled, required, labelId, titleId, descriptionId, errorId]\n );\n\n return (\n \n \n {textChildren(children)}\n \n \n );\n }\n);\nFieldRoot.displayName = 'Field';\n\nexport interface FieldContentProps extends ViewProps {\n className?: string;\n children?: ReactNode;\n}\n\nconst FieldContent = forwardRef(\n ({ className, ...props }, ref) => {\n const { content } = fieldVariants();\n return ;\n }\n);\nFieldContent.displayName = 'Field.Content';\n\nexport interface FieldLabelProps extends LabelProps {}\n\nconst FieldLabel = forwardRef(\n ({ isRequired, isInvalid, isDisabled, nativeID, ...props }, ref) => {\n const ctx = useContext(FieldContext);\n return (\n \n );\n }\n);\nFieldLabel.displayName = 'Field.Label';\n\nexport interface FieldDescriptionProps extends TextProps {\n className?: string;\n}\n\nconst FieldDescription = forwardRef(\n ({ className, nativeID, ...props }, ref) => {\n const { disabled, relationshipIds } = useContext(FieldContext);\n const { description } = fieldVariants({ disabled });\n return (\n \n );\n }\n);\nFieldDescription.displayName = 'Field.Description';\n\nexport interface FieldErrorProps extends Omit {\n className?: string;\n /**\n * Error messages to render, deduplicated by message. A single entry renders\n * as plain text; more than one renders as a bulleted list, since RN has no\n * `
    `. Prefer this over `children` when the messages come from a form's\n * validation state, which is naturally an array.\n */\n errors?: Array;\n children?: ReactNode;\n}\n\nconst FieldError = forwardRef(\n ({ className, errors, children, nativeID, ...props }, ref) => {\n const { relationshipIds } = useContext(FieldContext);\n const messages = useMemo(() => {\n if (!errors) return null;\n const seen = new Set();\n const out: string[] = [];\n for (const item of errors) {\n const message = typeof item === 'string' ? item : item?.message;\n if (message && !seen.has(message)) {\n seen.add(message);\n out.push(message);\n }\n }\n return out;\n }, [errors]);\n\n const hasContent = messages ? messages.length > 0 : !!children;\n if (!hasContent) return null;\n\n const { error, errorText, bullet, bulletDot } = fieldVariants();\n\n return (\n \n {messages ? (\n messages.length === 1 ? (\n {messages[0]}\n ) : (\n messages.map((message) => (\n \n {'•'}\n {message}\n \n ))\n )\n ) : (\n textChildren(children, (text) => {text})\n )}\n \n );\n }\n);\nFieldError.displayName = 'Field.Error';\n\nexport interface FieldSetProps extends ViewProps {\n className?: string;\n children?: ReactNode;\n}\n\nconst FieldSet = forwardRef(\n ({ className, ...props }, ref) => {\n const { set } = fieldVariants();\n return ;\n }\n);\nFieldSet.displayName = 'Field.Set';\n\nexport interface FieldLegendProps extends TextProps {\n className?: string;\n /** `legend` reads as a section heading; `label` sits closer to a field label. */\n variant?: 'legend' | 'label';\n}\n\nconst FieldLegend = forwardRef(\n ({ className, variant = 'legend', ...props }, ref) => {\n const { legend } = fieldVariants({ legendVariant: variant });\n return (\n \n );\n }\n);\nFieldLegend.displayName = 'Field.Legend';\n\nexport interface FieldGroupProps extends ViewProps {\n className?: string;\n children?: ReactNode;\n}\n\nconst FieldGroup = forwardRef(\n ({ className, ...props }, ref) => {\n const { group } = fieldVariants();\n return ;\n }\n);\nFieldGroup.displayName = 'Field.Group';\n\nexport interface FieldSeparatorProps extends ViewProps {\n className?: string;\n /** Centered text, e.g. \"Or\" — omit for a plain rule. */\n children?: ReactNode;\n}\n\nconst FieldSeparator = forwardRef(\n ({ className, children, ...props }, ref) => {\n const { separator, separatorLabel } = fieldVariants();\n return (\n \n \n {children ? (\n <>\n {children}\n \n \n ) : null}\n \n );\n }\n);\nFieldSeparator.displayName = 'Field.Separator';\n\nexport interface FieldTitleProps extends TextProps {\n className?: string;\n}\n\nconst FieldTitle = forwardRef(\n ({ className, nativeID, ...props }, ref) => {\n const { title } = fieldVariants();\n const { relationshipIds } = useContext(FieldContext);\n return (\n \n );\n }\n);\nFieldTitle.displayName = 'Field.Title';\n\nexport const Field = Object.assign(FieldRoot, {\n Content: FieldContent,\n Label: FieldLabel,\n Description: FieldDescription,\n Error: FieldError,\n Set: FieldSet,\n Legend: FieldLegend,\n Group: FieldGroup,\n Separator: FieldSeparator,\n Title: FieldTitle,\n});\n" + "content": "/**\n * Field — the layout and validation-state kit a form control composes into.\n *\n * PanelUI's own controls (`Input`, `Checkbox`, `Switch`, `RadioGroup`) already\n * carry their own label, description and error slot, so the common case needs\n * no wrapper at all — pass those props straight through. `Field` exists for\n * what doesn't fit that shape: a horizontal row pairing a control with a\n * label off to the side (a `Switch` in a settings list), several controls\n * grouped under one legend, or a rule breaking a long form into sections.\n *\n * `invalid` / `disabled` / `required` set on the root flow to `Field.Label`\n * and `Field.Description` through context, so a multi-field row states its\n * validity once instead of repeating the prop at every leaf.\n *\n * ```tsx\n * \n * \n * Marketing emails\n * Product updates, at most weekly.\n * \n * \n * \n * ```\n */\nimport {\n createContext,\n forwardRef,\n useContext,\n useId,\n useMemo,\n type ReactNode,\n} from 'react';\nimport { View, type Text as RNText, type ViewProps } from 'react-native';\nimport { tv } from 'tailwind-variants';\nimport { Text, type TextProps, textChildren } from '@/components/ui/text';\nimport { Label, type LabelProps } from '@/components/ui/label';\nimport { Separator } from '@/components/ui/separator';\nimport type { TextRef, ViewRef } from '@/components/ui/refs';\n\nconst fieldVariants = tv({\n slots: {\n root: 'gap-2',\n content: 'flex-1 flex-col gap-0.5',\n description: 'text-sm text-muted-foreground',\n error: 'gap-1',\n errorText: 'text-sm text-destructive',\n bullet: 'flex-row gap-1.5',\n bulletDot: 'text-sm text-destructive',\n set: 'gap-4',\n legend: 'text-foreground',\n group: 'gap-6',\n separator: 'flex-row items-center gap-3',\n separatorLabel: 'text-xs text-muted-foreground',\n title: 'text-base font-medium text-foreground',\n },\n variants: {\n orientation: {\n vertical: { root: 'flex-col' },\n horizontal: { root: 'flex-row items-center justify-between gap-4' },\n },\n disabled: {\n true: { root: 'opacity-[0.64]' },\n },\n legendVariant: {\n legend: { legend: 'text-base font-semibold' },\n label: { legend: 'text-sm font-medium' },\n },\n },\n defaultVariants: {\n orientation: 'vertical',\n },\n});\n\ninterface FieldState {\n invalid: boolean;\n disabled: boolean;\n required: boolean;\n relationshipIds: string[];\n}\n\nconst FieldContext = createContext({\n invalid: false,\n disabled: false,\n required: false,\n relationshipIds: [],\n});\n\n/** Android can announce the label, help and error text rendered beside a control. */\nexport function useFieldLabelledBy(): string[] | undefined {\n const { relationshipIds } = useContext(FieldContext);\n return relationshipIds.length > 0 ? relationshipIds : undefined;\n}\n\nexport interface FieldProps extends ViewProps {\n className?: string;\n /** `horizontal` puts a label beside the control instead of above it. */\n orientation?: 'vertical' | 'horizontal';\n /** Marks every `Field.Label`/`Field.Description` below as invalid. */\n invalid?: boolean;\n disabled?: boolean;\n /** Marks every `Field.Label` below as required, same as `Label`'s own prop. */\n required?: boolean;\n children?: ReactNode;\n}\n\nconst FieldRoot = forwardRef(\n (\n {\n className,\n orientation = 'vertical',\n invalid = false,\n disabled = false,\n required = false,\n children,\n ...props\n },\n ref\n ) => {\n const { root } = fieldVariants({ orientation, disabled });\n const id = useId().replace(/:/g, '');\n const labelId = `panelui-field-${id}-label`;\n const titleId = `panelui-field-${id}-title`;\n const descriptionId = `panelui-field-${id}-description`;\n const errorId = `panelui-field-${id}-error`;\n const state = useMemo(\n () => ({\n invalid,\n disabled,\n required,\n relationshipIds: [labelId, titleId, descriptionId, errorId],\n }),\n [invalid, disabled, required, labelId, titleId, descriptionId, errorId]\n );\n\n return (\n \n \n {textChildren(children)}\n \n \n );\n }\n);\nFieldRoot.displayName = 'Field';\n\nexport interface FieldContentProps extends ViewProps {\n className?: string;\n children?: ReactNode;\n}\n\nconst FieldContent = forwardRef(\n ({ className, ...props }, ref) => {\n const { content } = fieldVariants();\n return ;\n }\n);\nFieldContent.displayName = 'Field.Content';\n\nexport interface FieldLabelProps extends LabelProps {}\n\nconst FieldLabel = forwardRef(\n ({ isRequired, isInvalid, isDisabled, nativeID, ...props }, ref) => {\n const ctx = useContext(FieldContext);\n return (\n \n );\n }\n);\nFieldLabel.displayName = 'Field.Label';\n\nexport interface FieldDescriptionProps extends TextProps {\n className?: string;\n}\n\nconst FieldDescription = forwardRef(\n ({ className, nativeID, ...props }, ref) => {\n const { disabled, relationshipIds } = useContext(FieldContext);\n const { description } = fieldVariants({ disabled });\n return (\n \n );\n }\n);\nFieldDescription.displayName = 'Field.Description';\n\nexport interface FieldErrorProps extends Omit {\n className?: string;\n /**\n * Error messages to render, deduplicated by message. A single entry renders\n * as plain text; more than one renders as a bulleted list, since RN has no\n * `
      `. Prefer this over `children` when the messages come from a form's\n * validation state, which is naturally an array.\n */\n errors?: Array;\n children?: ReactNode;\n}\n\nconst FieldError = forwardRef(\n ({ className, errors, children, nativeID, ...props }, ref) => {\n const { relationshipIds } = useContext(FieldContext);\n const messages = useMemo(() => {\n if (!errors) return null;\n const seen = new Set();\n const out: string[] = [];\n for (const item of errors) {\n const message = typeof item === 'string' ? item : item?.message;\n if (message && !seen.has(message)) {\n seen.add(message);\n out.push(message);\n }\n }\n return out;\n }, [errors]);\n\n const hasContent = messages ? messages.length > 0 : !!children;\n if (!hasContent) return null;\n\n const { error, errorText, bullet, bulletDot } = fieldVariants();\n\n return (\n \n {messages ? (\n messages.length === 1 ? (\n {messages[0]}\n ) : (\n messages.map((message) => (\n \n {'•'}\n {message}\n \n ))\n )\n ) : (\n textChildren(children, (text) => {text})\n )}\n \n );\n }\n);\nFieldError.displayName = 'Field.Error';\n\nexport interface FieldSetProps extends ViewProps {\n className?: string;\n children?: ReactNode;\n}\n\nconst FieldSet = forwardRef(\n ({ className, ...props }, ref) => {\n const { set } = fieldVariants();\n return ;\n }\n);\nFieldSet.displayName = 'Field.Set';\n\nexport interface FieldLegendProps extends TextProps {\n className?: string;\n /** `legend` reads as a section heading; `label` sits closer to a field label. */\n variant?: 'legend' | 'label';\n}\n\nconst FieldLegend = forwardRef(\n ({ className, variant = 'legend', ...props }, ref) => {\n const { legend } = fieldVariants({ legendVariant: variant });\n return (\n \n );\n }\n);\nFieldLegend.displayName = 'Field.Legend';\n\nexport interface FieldGroupProps extends ViewProps {\n className?: string;\n children?: ReactNode;\n}\n\nconst FieldGroup = forwardRef(\n ({ className, ...props }, ref) => {\n const { group } = fieldVariants();\n return ;\n }\n);\nFieldGroup.displayName = 'Field.Group';\n\nexport interface FieldSeparatorProps extends ViewProps {\n className?: string;\n /** Centered text, e.g. \"Or\" — omit for a plain rule. */\n children?: ReactNode;\n}\n\nconst FieldSeparator = forwardRef(\n ({ className, children, ...props }, ref) => {\n const { separator, separatorLabel } = fieldVariants();\n return (\n \n \n {children ? (\n <>\n {children}\n \n \n ) : null}\n \n );\n }\n);\nFieldSeparator.displayName = 'Field.Separator';\n\nexport interface FieldTitleProps extends TextProps {\n className?: string;\n}\n\nconst FieldTitle = forwardRef(\n ({ className, nativeID, ...props }, ref) => {\n const { title } = fieldVariants();\n const { relationshipIds } = useContext(FieldContext);\n return (\n \n );\n }\n);\nFieldTitle.displayName = 'Field.Title';\n\nexport const Field = Object.assign(FieldRoot, {\n Content: FieldContent,\n Label: FieldLabel,\n Description: FieldDescription,\n Error: FieldError,\n Set: FieldSet,\n Legend: FieldLegend,\n Group: FieldGroup,\n Separator: FieldSeparator,\n Title: FieldTitle,\n});\n" } ] } diff --git a/apps/docs/public/r/file-upload.json b/apps/docs/public/r/file-upload.json index f3e40836f..eed5f1815 100644 --- a/apps/docs/public/r/file-upload.json +++ b/apps/docs/public/r/file-upload.json @@ -11,6 +11,7 @@ "icons", "image-viewer", "menu", + "refs", "text" ], "dependencies": [ @@ -33,7 +34,7 @@ { "path": "ui/file-upload.tsx", "type": "registry:ui", - "content": "/**\n * FileUpload — pick files and photos, check them, and run their uploads.\n *\n * ```tsx\n * send(file, { signal, onProgress })}\n * >\n * \n * \n * \n * ```\n *\n * ## One component for the whole trip\n *\n * Choosing a document, choosing or taking a photo, and sending either of them\n * somewhere are one flow from the user's side: they add things, watch them go,\n * and fix the ones that failed. So the root owns all of it — the list of files,\n * the rules they have to pass, and the upload queue — and the parts are only\n * different ways of drawing that list. `Dropzone` and `Trigger` add to it,\n * `List` draws it as rows, `Grid` draws it as photo tiles, and `useFileUpload()`\n * hands the same state to anything drawn by hand.\n *\n * ## The upload belongs to the app\n *\n * The component never makes a request. `upload` is the app's function: it\n * receives one file, an `AbortSignal` and an `onProgress` callback, and\n * resolves when the server has the file. That keeps the storage provider, the\n * auth headers and the retry policy where they already live, and leaves the\n * component with the part every app repeats — running at most `concurrency`\n * at once, showing progress, and wiring cancel, retry and remove to the right\n * file. The status of every file lives in `value`, so a form can read whether\n * everything has finished without asking the component.\n *\n * ## Native modules are optional\n *\n * Each source reaches its Expo module only when it is used: `files` through\n * `expo-document-picker`, `camera` and `library` through `expo-image-picker`,\n * and resizing through `expo-image-manipulator`. An app that only takes\n * documents never installs the camera module. A source whose module is\n * missing reports an error with the install command rather than crashing.\n */\nimport {\n Children,\n cloneElement,\n createContext,\n forwardRef,\n isValidElement,\n useCallback,\n useContext,\n useEffect,\n useMemo,\n useRef,\n useState,\n type ReactElement,\n type ReactNode,\n} from 'react';\nimport {\n AccessibilityInfo,\n Image,\n ScrollView,\n View,\n type LayoutChangeEvent,\n type ViewProps,\n} from 'react-native';\nimport Animated, { useAnimatedProps, useSharedValue, withTiming } from 'react-native-reanimated';\nimport Svg, { Circle } from 'react-native-svg';\nimport { tv } from 'tailwind-variants';\nimport { useCSSVariable } from 'uniwind';\nimport {\n CameraIcon,\n CheckCircleIcon,\n FileIcon,\n FolderIcon,\n ImageIcon,\n PlusIcon,\n RotateCcwIcon,\n TrashIcon,\n UploadIcon,\n VideoIcon,\n XIcon,\n} from '@/components/ui/icons';\nimport { AnimatedPressable, type AnimatedPressableProps } from '@/components/ui/animated-pressable';\nimport { useControllableState } from '@/components/ui/controllable-state';\nimport { Text } from '@/components/ui/text';\nimport { cn } from '@/lib/cn';\nimport { Attachment, type AttachmentState } from '@/components/ui/attachment';\nimport { useFieldLabelledBy } from '@/components/ui/field';\nimport { ImageViewer } from '@/components/ui/image-viewer';\nimport { Menu } from '@/components/ui/menu';\nimport {\n createFileId,\n describeRules,\n extensionOf,\n fitWithin,\n formatFileSize,\n isActive,\n kindOf,\n libraryMediaTypes,\n nextToStart,\n partitionFiles,\n pickerTypes,\n truncateMiddle,\n type FileUploadFile,\n type FileUploadKind,\n type FileUploadRejection,\n type FileUploadSource,\n type FileUploadStatus,\n} from '@/components/ui/file-upload-core';\n\nexport {\n describeRules as describeFileUploadRules,\n formatFileSize,\n type FileUploadFile,\n type FileUploadKind,\n type FileUploadRejection,\n type FileUploadSource,\n type FileUploadStatus,\n};\n\n// ---------------------------------------------------------------------------\n// Optional dependencies\n// ---------------------------------------------------------------------------\n\ninterface PickerAsset {\n uri: string;\n width?: number;\n height?: number;\n fileName?: string | null;\n fileSize?: number | null;\n mimeType?: string | null;\n type?: string | null;\n}\n\ninterface PickerResult {\n canceled: boolean;\n assets?: PickerAsset[] | null;\n}\n\ninterface ImagePickerModule {\n requestCameraPermissionsAsync(): Promise<{ granted: boolean }>;\n launchCameraAsync(options: unknown): Promise;\n launchImageLibraryAsync(options: unknown): Promise;\n}\n\ninterface DocumentAsset {\n uri: string;\n name: string;\n size?: number | null;\n mimeType?: string | null;\n}\n\ninterface DocumentPickerModule {\n getDocumentAsync(options: unknown): Promise<{ canceled: boolean; assets?: DocumentAsset[] | null }>;\n}\n\ninterface ManipulatorContext {\n resize(size: { width?: number; height?: number }): ManipulatorContext;\n renderAsync(): Promise<{\n saveAsync(options: { format?: string; compress?: number }): Promise<{\n uri: string;\n width: number;\n height: number;\n }>;\n }>;\n}\n\ninterface ManipulatorModule {\n ImageManipulator?: { manipulate(uri: string): ManipulatorContext };\n}\n\n/*\n * Each `require` sits directly in its `try` block, which is what lets Metro\n * bundle an app that does not have the package.\n */\nfunction loadImagePicker(): ImagePickerModule | null {\n try {\n // eslint-disable-next-line @typescript-eslint/no-require-imports\n return require('expo-image-picker') as ImagePickerModule;\n } catch {\n return null;\n }\n}\n\nfunction loadDocumentPicker(): DocumentPickerModule | null {\n try {\n // eslint-disable-next-line @typescript-eslint/no-require-imports\n return require('expo-document-picker') as DocumentPickerModule;\n } catch {\n return null;\n }\n}\n\nfunction loadManipulator(): ManipulatorModule | null {\n try {\n // eslint-disable-next-line @typescript-eslint/no-require-imports\n return require('expo-image-manipulator') as ManipulatorModule;\n } catch {\n return null;\n }\n}\n\nconst MODULE_FOR: Record = {\n camera: 'expo-image-picker',\n library: 'expo-image-picker',\n files: 'expo-document-picker',\n};\n\n/** True when the module a source needs is installed. */\nexport function hasFileUploadSource(source: FileUploadSource): boolean {\n return source === 'files' ? loadDocumentPicker() !== null : loadImagePicker() !== null;\n}\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\n/** What the upload handler is given besides the file. */\nexport interface FileUploadHandlerContext {\n /** Aborted when the user cancels or removes the file. Pass it to `fetch`. */\n signal: AbortSignal;\n /** Report how far the upload is, 0–1. Calls are batched to one per frame. */\n onProgress: (progress: number) => void;\n /** Mark the bytes as sent and the server as still working on them. */\n setProcessing: () => void;\n}\n\n/**\n * Sends one file. Resolve when the server has it — the value is kept as the\n * file's `response` — and throw to mark it failed; the error's message is shown\n * on the row.\n */\nexport type FileUploadHandler = (\n file: FileUploadFile,\n context: FileUploadHandlerContext\n) => Promise;\n\n/** How photos are prepared before they are added. */\nexport interface FileUploadImageOptions {\n /** Scale photos wider than this down to it. Needs `expo-image-manipulator`. */\n maxWidth?: number;\n /** Scale photos taller than this down to it. Needs `expo-image-manipulator`. */\n maxHeight?: number;\n /** JPEG quality 0–1, for the picker and for a resized copy. Default 0.9. */\n quality?: number;\n /**\n * Open the system's crop editor after a photo is chosen or taken, at this\n * aspect ratio. Applies to a single photo only — the editor cannot run over\n * a multiple selection. iOS crops to a square whatever the ratio.\n */\n crop?: { aspect: [number, number] };\n}\n\nexport interface FileUploadProps {\n /** The files, controlled. */\n value?: FileUploadFile[];\n /** The files to start with, uncontrolled. */\n defaultValue?: FileUploadFile[];\n /** Called with the new list whenever a file is added, changes state or is removed. */\n onValueChange?: (files: FileUploadFile[]) => void;\n /**\n * Where files can come from, in the order the chooser lists them. One source\n * opens its picker straight away; more than one opens a sheet to choose from.\n * Default `['library', 'files']`.\n */\n sources?: FileUploadSource[];\n /**\n * Allowed types: MIME types (`application/pdf`), families (`image/*`) or\n * extensions (`.pdf`). Everything when left out.\n */\n accept?: string[];\n /**\n * Let the list hold more than one file. Without it the list holds one, and\n * picking again replaces it — the shape of a profile photo or a single\n * attachment field.\n */\n multiple?: boolean;\n /**\n * How many files the list can hold, with `multiple`. Pickers are limited to\n * what is left, and the add controls disable once it is reached.\n */\n maxFiles?: number;\n /**\n * Largest allowed file, in bytes. Checked against the size the picker\n * reports, after any resize; a file whose size is unknown passes.\n */\n maxSize?: number;\n /** Smallest allowed file, in bytes. */\n minSize?: number;\n /** An app-specific rule. Return a message to turn the file away. */\n validate?: (file: FileUploadFile) => string | null | undefined;\n /** Called with the files that were picked and turned away, and why. */\n onReject?: (rejections: FileUploadRejection[]) => void;\n /** Called when a picker cannot open — a missing module or a refused permission. */\n onError?: (error: Error) => void;\n /** Resize, compress or crop photos before they are added. */\n image?: FileUploadImageOptions;\n /** Sends one file. Without it files are only picked, and stay `idle`. */\n upload?: FileUploadHandler;\n /** How many uploads run at once. Default 2. */\n concurrency?: number;\n /**\n * Queue files for upload as soon as they are added. Default true. Turn it off\n * to send them later with `useFileUpload().upload()`.\n */\n autoUpload?: boolean;\n /** Called once per file when its upload resolves. */\n onUploadComplete?: (file: FileUploadFile) => void;\n /** Labels for the sources in the chooser. */\n sourceLabels?: Partial>;\n /** Nothing can be added, removed or retried. */\n disabled?: boolean;\n /** Draw the dropzone in its error state, for a form that failed validation. */\n invalid?: boolean;\n children?: ReactNode;\n}\n\ninterface FileUploadContextValue {\n files: FileUploadFile[];\n sources: FileUploadSource[];\n sourceLabels: Record;\n accept?: string[];\n maxFiles?: number;\n maxSize?: number;\n multiple: boolean;\n disabled: boolean;\n invalid: boolean;\n canAddMore: boolean;\n hasUpload: boolean;\n rejections: FileUploadRejection[];\n pick: (source: FileUploadSource) => Promise;\n remove: (id: string) => void;\n cancel: (id: string) => void;\n retry: (id: string) => void;\n upload: (id?: string) => void;\n clear: () => void;\n}\n\nconst FileUploadContext = createContext(null);\n\n/**\n * The state and actions of the nearest `FileUpload`, for drawing a part of it\n * by hand — a single avatar slot, a counter, a submit button that waits for\n * every upload.\n */\nexport function useFileUpload(): FileUploadContextValue {\n const context = useContext(FileUploadContext);\n if (!context) throw new Error('useFileUpload must be used inside .');\n return context;\n}\n\nconst DEFAULT_SOURCES: FileUploadSource[] = ['library', 'files'];\n\nconst DEFAULT_LABELS: Record = {\n camera: 'Take photo',\n library: 'Choose from library',\n files: 'Browse files',\n};\n\n/** How long the chooser sheet takes to leave before a system picker opens over it. */\nconst CHOOSER_EXIT_MS = 320;\n\n// ---------------------------------------------------------------------------\n// Root\n// ---------------------------------------------------------------------------\n\nfunction FileUploadRoot({\n value: valueProp,\n defaultValue = [],\n onValueChange,\n sources: sourcesProp = DEFAULT_SOURCES,\n accept,\n multiple = false,\n maxFiles: maxFilesProp,\n maxSize,\n minSize,\n validate,\n onReject,\n onError,\n image,\n upload: uploadHandler,\n concurrency = 2,\n autoUpload = true,\n onUploadComplete,\n sourceLabels,\n disabled = false,\n invalid = false,\n children,\n}: FileUploadProps) {\n const maxFiles = multiple ? maxFilesProp : 1;\n const { value, setValue } = useControllableState({\n value: valueProp,\n defaultValue,\n onChange: onValueChange,\n });\n\n /*\n * The list as this component last wrote it. Uploads report several changes\n * inside one tick — a file starts, the next one queues, progress arrives —\n * and a controlled owner has not re-rendered between them, so building each\n * change on `value` would drop all but the last. Every change is built on\n * this ref instead, and the ref takes the owner's value whenever it changes.\n */\n const latest = useRef(value);\n const seen = useRef(value);\n if (value !== seen.current) {\n seen.current = value;\n latest.current = value;\n }\n\n const commit = useCallback(\n (change: (files: FileUploadFile[]) => FileUploadFile[]) => {\n const next = change(latest.current);\n if (next === latest.current) return;\n latest.current = next;\n setValue(next);\n },\n [setValue]\n );\n\n const patch = useCallback(\n (id: string, fields: Partial) =>\n commit((files) =>\n files.some((file) => file.id === id)\n ? files.map((file) => (file.id === id ? { ...file, ...fields } : file))\n : files\n ),\n [commit]\n );\n\n const [rejections, setRejections] = useState([]);\n\n // Callbacks read through a ref so the queue never runs a stale handler.\n const handlers = useRef({ uploadHandler, onUploadComplete, onReject, onError, validate });\n handlers.current = { uploadHandler, onUploadComplete, onReject, onError, validate };\n\n // ---- upload queue -------------------------------------------------------\n\n const jobs = useRef(new Map());\n const pendingProgress = useRef(new Map());\n const frame = useRef(null);\n\n const flushProgress = useCallback(() => {\n frame.current = null;\n const pending = pendingProgress.current;\n if (pending.size === 0) return;\n pendingProgress.current = new Map();\n commit((files) =>\n files.map((file) =>\n pending.has(file.id) && file.status === 'uploading'\n ? { ...file, progress: pending.get(file.id) }\n : file\n )\n );\n }, [commit]);\n\n const pump = useCallback(() => {\n const handler = handlers.current.uploadHandler;\n if (!handler) return;\n for (const id of nextToStart(latest.current, concurrency)) {\n const file = latest.current.find((entry) => entry.id === id);\n if (!file) continue;\n const controller = new AbortController();\n jobs.current.set(id, controller);\n patch(id, { status: 'uploading', progress: 0, error: undefined });\n\n const context: FileUploadHandlerContext = {\n signal: controller.signal,\n onProgress: (progress) => {\n if (controller.signal.aborted) return;\n pendingProgress.current.set(id, Math.min(1, Math.max(0, progress)));\n frame.current ??= requestAnimationFrame(flushProgress);\n },\n setProcessing: () => {\n if (controller.signal.aborted) return;\n pendingProgress.current.delete(id);\n patch(id, { status: 'processing', progress: 1 });\n },\n };\n\n Promise.resolve()\n .then(() => handler({ ...file, status: 'uploading', progress: 0 }, context))\n .then(\n (response) => {\n if (controller.signal.aborted) return;\n jobs.current.delete(id);\n pendingProgress.current.delete(id);\n patch(id, { status: 'done', progress: 1, response, error: undefined });\n const done = latest.current.find((entry) => entry.id === id);\n if (done) handlers.current.onUploadComplete?.(done);\n },\n (error: unknown) => {\n if (controller.signal.aborted) return;\n jobs.current.delete(id);\n pendingProgress.current.delete(id);\n patch(id, {\n status: 'error',\n error: error instanceof Error && error.message ? error.message : 'Upload failed',\n });\n }\n );\n }\n }, [concurrency, flushProgress, patch]);\n\n // Whatever changed the list — a pick, a finish, a retry — may have freed a\n // slot or queued a file. Starting is idempotent, so running on every change\n // is the whole scheduler.\n useEffect(() => {\n pump();\n }, [value, pump, uploadHandler]);\n\n useEffect(() => {\n const running = jobs.current;\n return () => {\n for (const controller of running.values()) controller.abort();\n running.clear();\n if (frame.current !== null) cancelAnimationFrame(frame.current);\n };\n }, []);\n\n const stop = useCallback((id: string) => {\n jobs.current.get(id)?.abort();\n jobs.current.delete(id);\n pendingProgress.current.delete(id);\n }, []);\n\n const remove = useCallback(\n (id: string) => {\n stop(id);\n setRejections([]);\n commit((files) => files.filter((file) => file.id !== id));\n },\n [commit, stop]\n );\n\n const cancel = useCallback(\n (id: string) => {\n const file = latest.current.find((entry) => entry.id === id);\n if (!file || !(isActive(file.status) || file.status === 'queued')) return;\n stop(id);\n patch(id, { status: 'canceled', progress: undefined });\n },\n [patch, stop]\n );\n\n const retry = useCallback(\n (id: string) => {\n if (!handlers.current.uploadHandler) return;\n patch(id, { status: 'queued', progress: 0, error: undefined });\n },\n [patch]\n );\n\n const uploadNow = useCallback(\n (id?: string) => {\n if (!handlers.current.uploadHandler) return;\n commit((files) =>\n files.map((file) =>\n (id === undefined || file.id === id) &&\n (file.status === 'idle' || file.status === 'canceled' || file.status === 'error')\n ? { ...file, status: 'queued', progress: 0, error: undefined }\n : file\n )\n );\n },\n [commit]\n );\n\n const clear = useCallback(() => {\n for (const id of [...jobs.current.keys()]) stop(id);\n setRejections([]);\n commit(() => []);\n }, [commit, stop]);\n\n // ---- picking ------------------------------------------------------------\n\n const report = useCallback((error: Error) => {\n if (handlers.current.onError) handlers.current.onError(error);\n else if (__DEV__) console.warn(`FileUpload: ${error.message}`);\n }, []);\n\n const pick = useCallback(\n async (source: FileUploadSource) => {\n if (disabled) return;\n // A single-file upload replaces its file rather than filling up.\n const replacing = !multiple;\n const held = replacing ? 0 : latest.current.length;\n const remaining = maxFiles === undefined ? undefined : Math.max(0, maxFiles - held);\n if (remaining === 0) return;\n const many = multiple && remaining !== 1;\n const quality = image?.quality ?? 0.9;\n const crop = !many ? image?.crop : undefined;\n\n let picked: FileUploadFile[];\n try {\n if (source === 'files') {\n const picker = loadDocumentPicker();\n if (!picker) throw missing(source);\n const result = await picker.getDocumentAsync({\n type: pickerTypes(accept),\n multiple: many,\n copyToCacheDirectory: true,\n });\n if (result.canceled) return;\n picked = (result.assets ?? []).map((asset) => fromDocument(asset));\n } else {\n const picker = loadImagePicker();\n if (!picker) throw missing(source);\n const options = {\n mediaTypes: source === 'camera' ? ['images'] : libraryMediaTypes(accept),\n allowsMultipleSelection: source === 'library' && many,\n selectionLimit: many ? (remaining ?? 0) : 1,\n orderedSelection: true,\n allowsEditing: crop !== undefined,\n aspect: crop?.aspect,\n quality,\n exif: false,\n };\n let result: PickerResult;\n if (source === 'camera') {\n const permission = await picker.requestCameraPermissionsAsync();\n if (!permission.granted) {\n throw new Error('Camera permission was refused. It can be turned on in Settings.');\n }\n result = await picker.launchCameraAsync(options);\n } else {\n result = await picker.launchImageLibraryAsync(options);\n }\n if (result.canceled) return;\n picked = await Promise.all(\n (result.assets ?? []).map((asset, index) =>\n prepareImage(fromPickerAsset(asset, index), image, quality)\n )\n );\n }\n } catch (error) {\n report(error instanceof Error ? error : new Error(String(error)));\n return;\n }\n\n const { accepted, rejected } = partitionFiles(picked, held, {\n accept,\n maxSize,\n minSize,\n maxFiles,\n validate: handlers.current.validate,\n });\n setRejections(rejected);\n if (rejected.length > 0) {\n handlers.current.onReject?.(rejected);\n AccessibilityInfo.announceForAccessibility(rejected.map((entry) => entry.message).join(' '));\n }\n if (accepted.length === 0) return;\n const status: FileUploadStatus =\n handlers.current.uploadHandler && autoUpload ? 'queued' : 'idle';\n const added = accepted.map((file) => ({ ...file, status }));\n if (replacing) {\n for (const file of latest.current) stop(file.id);\n commit(() => added.slice(0, 1));\n } else {\n commit((files) => [...files, ...added]);\n }\n },\n [accept, autoUpload, commit, disabled, image, maxFiles, maxSize, minSize, multiple, report, stop]\n );\n\n // A source whose picker could never return an allowed file is left out.\n const sources = useMemo(\n () =>\n sourcesProp.filter((source) => {\n if (source === 'files') return true;\n const media = libraryMediaTypes(accept);\n return source === 'camera' ? media.includes('images') : media.length > 0;\n }),\n [accept, sourcesProp]\n );\n\n const context = useMemo(\n () => ({\n files: value,\n sources,\n sourceLabels: { ...DEFAULT_LABELS, ...sourceLabels },\n accept,\n maxFiles,\n maxSize,\n multiple,\n disabled,\n invalid,\n canAddMore: !disabled && (!multiple || maxFiles === undefined || value.length < maxFiles),\n hasUpload: uploadHandler !== undefined,\n rejections,\n pick,\n remove,\n cancel,\n retry,\n upload: uploadNow,\n clear,\n }),\n [\n accept,\n cancel,\n clear,\n disabled,\n invalid,\n maxFiles,\n maxSize,\n multiple,\n pick,\n rejections,\n remove,\n retry,\n sourceLabels,\n sources,\n uploadHandler,\n uploadNow,\n value,\n ]\n );\n\n return {children};\n}\n\nfunction missing(source: FileUploadSource): Error {\n const name = MODULE_FOR[source];\n return new Error(`Adding from \"${source}\" needs ${name}. Install it with: npx expo install ${name}`);\n}\n\nfunction fromDocument(asset: DocumentAsset): FileUploadFile {\n const file = {\n uri: asset.uri,\n name: asset.name,\n size: asset.size ?? undefined,\n mimeType: asset.mimeType ?? undefined,\n };\n return { id: createFileId(), ...file, kind: kindOf(file), status: 'idle' };\n}\n\nfunction fromPickerAsset(asset: PickerAsset, index: number): FileUploadFile {\n const video = asset.type === 'video' || asset.mimeType?.startsWith('video/');\n const fallbackExt = video ? 'mp4' : 'jpg';\n const name = asset.fileName || `${video ? 'Video' : 'Photo'} ${timestamp()}${index ? `-${index + 1}` : ''}.${fallbackExt}`;\n const mimeType = asset.mimeType ?? (video ? 'video/mp4' : extensionOf(name) === 'png' ? 'image/png' : 'image/jpeg');\n return {\n id: createFileId(),\n uri: asset.uri,\n name,\n size: asset.fileSize ?? undefined,\n mimeType,\n width: asset.width,\n height: asset.height,\n kind: video ? 'video' : 'image',\n status: 'idle',\n };\n}\n\nfunction timestamp(): string {\n const now = new Date();\n const pad = (n: number) => String(n).padStart(2, '0');\n return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}.${pad(now.getMinutes())}.${pad(now.getSeconds())}`;\n}\n\n/** Scale a photo into `maxWidth`/`maxHeight`, when it is larger and the manipulator is installed. */\nasync function prepareImage(\n file: FileUploadFile,\n options: FileUploadImageOptions | undefined,\n quality: number\n): Promise {\n if (file.kind !== 'image' || !options || !file.width || !file.height) return file;\n const target = fitWithin({ width: file.width, height: file.height }, options);\n if (!target) return file;\n const manipulator = loadManipulator()?.ImageManipulator;\n if (!manipulator) {\n if (__DEV__) {\n console.warn('FileUpload: resizing photos needs expo-image-manipulator. Install it with: npx expo install expo-image-manipulator');\n }\n return file;\n }\n const rendered = await manipulator.manipulate(file.uri).resize({ width: target.width }).renderAsync();\n const saved = await rendered.saveAsync({ format: 'jpeg', compress: quality });\n const name = file.name.replace(/\\.[^.]+$/, '') + '.jpg';\n // The resized file's size is not reported; it is smaller than the original.\n return { ...file, uri: saved.uri, width: saved.width, height: saved.height, name, size: undefined, mimeType: 'image/jpeg' };\n}\n\n// ---------------------------------------------------------------------------\n// Source chooser\n// ---------------------------------------------------------------------------\n\nconst SOURCE_ICONS: Record ReactNode> = {\n camera: (color) => ,\n library: (color) => ,\n files: (color) => ,\n};\n\n/**\n * Wraps one pressable. With one source the press opens its picker; with more,\n * a sheet of the sources opens first, and the picker waits for it to leave so\n * the two never animate over each other.\n */\nfunction SourceChooser({ children }: { children: ReactElement<{ onPress?: (...args: unknown[]) => void }> }) {\n const { sources, sourceLabels, pick, canAddMore } = useFileUpload();\n const iconColor = useCSSVariable('--color-foreground');\n const color = typeof iconColor === 'string' ? iconColor : undefined;\n\n if (sources.length <= 1) {\n const only = sources[0];\n return cloneElement(children, {\n onPress: (...args: unknown[]) => {\n children.props.onPress?.(...args);\n if (only && canAddMore) void pick(only);\n },\n });\n }\n\n if (!canAddMore) return children;\n\n const choose = (source: FileUploadSource) => {\n setTimeout(() => void pick(source), CHOOSER_EXIT_MS);\n };\n\n return (\n \n {children}\n \n {sources.map((source) => (\n choose(source)}>\n {sourceLabels[source]}\n \n ))}\n \n \n );\n}\n\n// ---------------------------------------------------------------------------\n// Trigger\n// ---------------------------------------------------------------------------\n\nexport interface FileUploadTriggerProps {\n /** One pressable — a Button, an icon button, an avatar. */\n children: ReactElement;\n}\n\n/**\n * Turns any pressable into the way files are added. With several sources it\n * opens a sheet listing them; with one it opens that picker directly.\n */\nfunction FileUploadTrigger({ children }: FileUploadTriggerProps) {\n const { disabled, canAddMore } = useFileUpload();\n const child = Children.only(children) as ReactElement<{ disabled?: boolean; onPress?: () => void }>;\n const locked = disabled || !canAddMore;\n return (\n \n {isValidElement(child) && locked ? cloneElement(child, { disabled: true }) : child}\n \n );\n}\n\n// ---------------------------------------------------------------------------\n// Dropzone\n// ---------------------------------------------------------------------------\n\nconst dropzoneVariants = tv({\n slots: {\n root: 'items-center justify-center rounded-2xl border-2 border-dashed border-border bg-muted/40 px-6 py-7 active:bg-muted',\n // The pressable animates its own opacity for press feedback, which would\n // overwrite a dimming class on it — so the contents are what dim.\n body: 'items-center gap-3',\n badge: 'h-12 w-12 items-center justify-center rounded-full border border-border bg-background',\n title: 'text-center text-base font-semibold text-foreground',\n hint: 'text-center text-sm text-muted-foreground',\n error: 'text-center text-sm text-destructive',\n },\n variants: {\n invalid: { true: { root: 'border-destructive bg-destructive-subtle' } },\n disabled: { true: { body: 'opacity-50' } },\n },\n});\n\nexport interface FileUploadDropzoneProps extends Omit {\n className?: string;\n /** Headline. Default \"Choose files\", or \"Choose a file\" for a single file. */\n title?: ReactNode;\n /** The line under it. Written from `accept`, `maxSize` and `maxFiles` when left out. */\n description?: ReactNode;\n /** Replaces the upload glyph in the badge. */\n icon?: ReactNode;\n /** Draw the inside yourself. The press still opens the sources. */\n children?: ReactNode;\n}\n\n/**\n * The large dashed area at the top of a form. Pressing anywhere on it adds\n * files, and the line under the title says what is allowed before anybody\n * picks something that is not. When a pick is turned away, the reason replaces\n * that line until the next pick.\n */\nconst FileUploadDropzone = forwardRef(\n ({ className, title, description, icon, children, ...props }, ref) => {\n const { accept, maxSize, maxFiles, multiple, disabled, invalid, canAddMore, rejections, files } =\n useFileUpload();\n const labelledBy = useFieldLabelledBy();\n const primary = useCSSVariable('--color-foreground');\n const full = multiple && maxFiles !== undefined && files.length >= maxFiles;\n const first = rejections[0];\n const rejected = first\n ? rejections.length > 1\n ? `${first.message} ${rejections.length - 1} more could not be added.`\n : first.message\n : null;\n const slots = dropzoneVariants({ invalid: invalid || rejected !== null, disabled: disabled || full });\n\n const heading = title ?? (multiple ? 'Choose files' : 'Choose a file');\n const hint = full\n ? `${files.length} of ${maxFiles} added. Remove one to add another.`\n : (description ?? describeRules({ accept, maxSize, maxFiles: multiple ? maxFiles : undefined }));\n\n return (\n \n \n \n {children ?? (\n <>\n \n {icon ?? }\n \n \n {typeof heading === 'string' ? {heading} : heading}\n {hint ? (\n typeof hint === 'string' ? {hint} : hint\n ) : null}\n {rejected ? (\n \n {rejected}\n \n ) : null}\n \n \n )}\n \n \n \n );\n }\n);\nFileUploadDropzone.displayName = 'FileUpload.Dropzone';\n\n// ---------------------------------------------------------------------------\n// Item and List\n// ---------------------------------------------------------------------------\n\nconst ATTACHMENT_STATE: Record = {\n idle: 'idle',\n queued: 'idle',\n uploading: 'uploading',\n processing: 'processing',\n done: 'done',\n error: 'error',\n canceled: 'idle',\n};\n\n/** \"PDF · 2.4 MB\", \"1.1 of 2.4 MB · 46%\", \"Waiting\", \"Upload failed\". */\nexport function describeFileStatus(file: FileUploadFile): string {\n const ext = extensionOf(file.name).toUpperCase();\n const size = file.size !== undefined ? formatFileSize(file.size) : '';\n const percent = `${Math.round((file.progress ?? 0) * 100)}%`;\n switch (file.status) {\n case 'queued':\n return ['Waiting', size].filter(Boolean).join(' · ');\n case 'uploading':\n return file.size !== undefined\n ? `${formatFileSize(file.size * (file.progress ?? 0))} of ${size} · ${percent}`\n : `Uploading · ${percent}`;\n case 'processing':\n return 'Processing…';\n case 'done':\n return [size, 'Uploaded'].filter(Boolean).join(' · ');\n case 'error':\n return file.error ?? 'Upload failed';\n case 'canceled':\n return ['Canceled', size].filter(Boolean).join(' · ');\n default:\n return [ext, size].filter(Boolean).join(' · ');\n }\n}\n\nfunction KindIcon({ kind, size = 18, color }: { kind: FileUploadKind; size?: number; color?: string }) {\n if (kind === 'image') return ;\n if (kind === 'video') return ;\n return ;\n}\n\nexport interface FileUploadItemProps {\n file: FileUploadFile;\n className?: string;\n /** Row density. */\n size?: 'default' | 'sm';\n /**\n * Longest name shown before it is shortened from the middle, keeping the\n * extension. Default 36.\n */\n nameLength?: number;\n}\n\n/**\n * One file as an Attachment row: a thumbnail or type icon, the name, a status\n * line, and the action that fits the state — cancel while it is moving, retry\n * when it failed or was stopped, remove otherwise.\n */\nfunction FileUploadItem({ file, className, size = 'default', nameLength = 36 }: FileUploadItemProps) {\n const { remove, cancel, retry, disabled, hasUpload } = useFileUpload();\n const muted = useCSSVariable('--color-muted-foreground');\n const success = useCSSVariable('--color-success');\n const mutedColor = typeof muted === 'string' ? muted : undefined;\n const moving = isActive(file.status) || file.status === 'queued';\n const failed = file.status === 'error' || file.status === 'canceled';\n const status = describeFileStatus(file);\n\n return (\n \n {file.kind === 'image' ? (\n \n \n \n ) : (\n \n \n \n )}\n \n {/* Shortened by hand: the shimmer a busy row draws cannot clip to one line. */}\n {truncateMiddle(file.name, nameLength)}\n \n {status}\n \n \n \n {file.status === 'done' ? (\n \n \n \n ) : null}\n {failed && hasUpload ? (\n retry(file.id)}\n >\n \n \n ) : null}\n (moving ? cancel(file.id) : remove(file.id))}\n >\n {moving ? : }\n \n \n \n );\n}\n\nexport interface FileUploadListProps extends ViewProps {\n className?: string;\n /** Lay the files out as one scrolling strip of fixed-width rows, for a message composer. */\n horizontal?: boolean;\n /** Row density. */\n size?: 'default' | 'sm';\n /** Draw a row yourself. `FileUpload.Item` is the default. */\n renderItem?: (file: FileUploadFile) => ReactNode;\n /** Shown when the list is empty. Nothing by default. */\n empty?: ReactNode;\n}\n\n/** Every file as a row, in the order it was added. */\nconst FileUploadList = forwardRef(\n ({ className, horizontal = false, size, renderItem, empty, ...props }, ref) => {\n const { files } = useFileUpload();\n if (files.length === 0) return empty ? <>{empty} : null;\n\n const rows = files.map((file) =>\n renderItem ? (\n {renderItem(file)}\n ) : (\n \n )\n );\n\n if (horizontal) {\n return (\n \n \n {rows}\n \n \n );\n }\n return (\n \n {rows}\n \n );\n }\n);\nFileUploadList.displayName = 'FileUpload.List';\n\n// ---------------------------------------------------------------------------\n// Grid\n// ---------------------------------------------------------------------------\n\nconst AnimatedCircle = Animated.createAnimatedComponent(Circle);\n\n/** A ring that fills with upload progress, drawn over a tile's scrim. */\nfunction ProgressRing({ progress, size = 36 }: { progress: number; size?: number }) {\n const stroke = 3;\n const radius = (size - stroke) / 2;\n const circumference = 2 * Math.PI * radius;\n const shown = useSharedValue(progress);\n\n useEffect(() => {\n shown.value = withTiming(progress, { duration: 180 });\n }, [progress, shown]);\n\n const animatedProps = useAnimatedProps(() => ({\n strokeDashoffset: circumference * (1 - shown.value),\n }));\n\n return (\n \n \n \n \n );\n}\n\nfunction GridTile({\n file,\n size,\n cover,\n radius,\n}: {\n file: FileUploadFile;\n size: number;\n cover: boolean;\n radius: number;\n}) {\n const { remove, cancel, retry, disabled, hasUpload } = useFileUpload();\n const muted = useCSSVariable('--color-muted-foreground');\n const foreground = useCSSVariable('--color-foreground');\n const moving = isActive(file.status) || file.status === 'queued';\n const failed = file.status === 'error' || file.status === 'canceled';\n const status = describeFileStatus(file);\n\n return (\n \n {file.kind === 'image' ? (\n \n \n \n ) : (\n \n \n \n {file.name}\n \n \n )}\n\n {moving || failed ? (\n \n {file.status === 'uploading' ? (\n \n ) : file.status === 'queued' || file.status === 'processing' ? (\n \n {file.status === 'queued' ? 'Waiting' : 'Processing'}\n \n ) : hasUpload ? (\n retry(file.id)}\n className=\"h-9 w-9 items-center justify-center rounded-full bg-white/20 active:bg-white/30\"\n >\n \n \n ) : null}\n \n ) : null}\n\n {cover ? (\n \n Cover\n \n ) : null}\n\n (moving ? cancel(file.id) : remove(file.id))}\n className=\"absolute right-1.5 top-1.5 h-6 w-6 items-center justify-center rounded-full border border-border bg-background active:bg-muted\"\n >\n \n \n \n );\n}\n\nexport interface FileUploadGridProps extends ViewProps {\n className?: string;\n /** Tiles per row. Default 3. Ignored when `horizontal`. */\n columns?: number;\n /** Lay the tiles out as one scrolling strip. */\n horizontal?: boolean;\n /** Tile size in points, for a horizontal strip. Default 88. */\n tileSize?: number;\n /** Space between tiles, in points. Default 8. */\n gap?: number;\n /** Corner radius of a tile, in points. Default 12. */\n radius?: number;\n /** Mark the first photo as the cover. */\n cover?: boolean;\n /** Draw empty slots for the rest of `maxFiles`, so the limit can be seen. */\n placeholders?: boolean;\n /** Label of the add tile. Default \"Add photo\" or \"Add photos\". */\n addLabel?: string;\n /** Leave the add tile out — for a grid with its own Trigger elsewhere. */\n hideAdd?: boolean;\n}\n\n/**\n * Photos as square tiles, led by a tile that adds more. Each photo opens in\n * the full-screen viewer, carries its own remove button, and shows a ring\n * while it uploads. The add tile counts against `maxFiles`, so the limit is\n * visible before anybody hits it.\n */\nconst FileUploadGrid = forwardRef(\n (\n {\n className,\n columns = 3,\n horizontal = false,\n tileSize = 88,\n gap = 8,\n radius = 12,\n cover = false,\n placeholders = false,\n addLabel,\n hideAdd = false,\n onLayout,\n ...props\n },\n ref\n ) => {\n const { files, maxFiles, multiple, canAddMore } = useFileUpload();\n const [width, setWidth] = useState(0);\n const background = useCSSVariable('--color-background');\n const muted = useCSSVariable('--color-muted-foreground');\n\n const size = horizontal\n ? tileSize\n : width > 0\n ? Math.floor((width - gap * (columns - 1)) / columns)\n : 0;\n\n const showAdd = !hideAdd && canAddMore;\n const slotCount =\n placeholders && maxFiles !== undefined\n ? Math.max(0, maxFiles - files.length - (showAdd ? 1 : 0))\n : 0;\n // Empty slots only finish the current row; a page of them is noise.\n const filled = files.length + (showAdd ? 1 : 0);\n const rowRemainder = filled % columns === 0 ? 0 : columns - (filled % columns);\n const visibleSlots = horizontal ? slotCount : Math.min(slotCount, rowRemainder);\n\n const label = addLabel ?? (multiple ? 'Add photos' : 'Add photo');\n const tiles: ReactNode[] = [];\n\n if (showAdd && size > 0) {\n tiles.push(\n \n \n \n \n \n {size >= 84 ? (\n \n {label}\n \n ) : null}\n {maxFiles !== undefined && multiple ? (\n \n {files.length}/{maxFiles}\n \n ) : null}\n \n \n );\n }\n\n if (size > 0) {\n files.forEach((file, index) =>\n tiles.push(\n \n )\n );\n for (let i = 0; i < visibleSlots; i += 1) {\n tiles.push(\n \n \n \n );\n }\n }\n\n const body = horizontal ? (\n \n {tiles}\n \n ) : (\n \n {tiles}\n \n );\n\n return (\n {\n setWidth(event.nativeEvent.layout.width);\n onLayout?.(event);\n }}\n {...props}\n >\n {body}\n \n );\n }\n);\nFileUploadGrid.displayName = 'FileUpload.Grid';\n\nFileUploadRoot.displayName = 'FileUpload';\nFileUploadTrigger.displayName = 'FileUpload.Trigger';\nFileUploadItem.displayName = 'FileUpload.Item';\n\nexport const FileUpload = Object.assign(FileUploadRoot, {\n Trigger: FileUploadTrigger,\n Dropzone: FileUploadDropzone,\n List: FileUploadList,\n Item: FileUploadItem,\n Grid: FileUploadGrid,\n});\n" + "content": "/**\n * FileUpload — pick files and photos, check them, and run their uploads.\n *\n * ```tsx\n * send(file, { signal, onProgress })}\n * >\n * \n * \n * \n * ```\n *\n * ## One component for the whole trip\n *\n * Choosing a document, choosing or taking a photo, and sending either of them\n * somewhere are one flow from the user's side: they add things, watch them go,\n * and fix the ones that failed. So the root owns all of it — the list of files,\n * the rules they have to pass, and the upload queue — and the parts are only\n * different ways of drawing that list. `Dropzone` and `Trigger` add to it,\n * `List` draws it as rows, `Grid` draws it as photo tiles, and `useFileUpload()`\n * hands the same state to anything drawn by hand.\n *\n * ## The upload belongs to the app\n *\n * The component never makes a request. `upload` is the app's function: it\n * receives one file, an `AbortSignal` and an `onProgress` callback, and\n * resolves when the server has the file. That keeps the storage provider, the\n * auth headers and the retry policy where they already live, and leaves the\n * component with the part every app repeats — running at most `concurrency`\n * at once, showing progress, and wiring cancel, retry and remove to the right\n * file. The status of every file lives in `value`, so a form can read whether\n * everything has finished without asking the component.\n *\n * ## Native modules are optional\n *\n * Each source reaches its Expo module only when it is used: `files` through\n * `expo-document-picker`, `camera` and `library` through `expo-image-picker`,\n * and resizing through `expo-image-manipulator`. An app that only takes\n * documents never installs the camera module. A source whose module is\n * missing reports an error with the install command rather than crashing.\n */\nimport {\n Children,\n cloneElement,\n createContext,\n forwardRef,\n isValidElement,\n useCallback,\n useContext,\n useEffect,\n useMemo,\n useRef,\n useState,\n type ReactElement,\n type ReactNode,\n} from 'react';\nimport {\n AccessibilityInfo,\n Image,\n ScrollView,\n View,\n type LayoutChangeEvent,\n type ViewProps,\n} from 'react-native';\nimport Animated, { useAnimatedProps, useSharedValue, withTiming } from 'react-native-reanimated';\nimport Svg, { Circle } from 'react-native-svg';\nimport { tv } from 'tailwind-variants';\nimport { useCSSVariable } from 'uniwind';\nimport {\n CameraIcon,\n CheckCircleIcon,\n FileIcon,\n FolderIcon,\n ImageIcon,\n PlusIcon,\n RotateCcwIcon,\n TrashIcon,\n UploadIcon,\n VideoIcon,\n XIcon,\n} from '@/components/ui/icons';\nimport { AnimatedPressable, type AnimatedPressableProps } from '@/components/ui/animated-pressable';\nimport { useControllableState } from '@/components/ui/controllable-state';\nimport { Text } from '@/components/ui/text';\nimport { cn } from '@/lib/cn';\nimport { Attachment, type AttachmentState } from '@/components/ui/attachment';\nimport { useFieldLabelledBy } from '@/components/ui/field';\nimport { ImageViewer } from '@/components/ui/image-viewer';\nimport { Menu } from '@/components/ui/menu';\nimport {\n createFileId,\n describeRules,\n extensionOf,\n fitWithin,\n formatFileSize,\n isActive,\n kindOf,\n libraryMediaTypes,\n nextToStart,\n partitionFiles,\n pickerTypes,\n truncateMiddle,\n type FileUploadFile,\n type FileUploadKind,\n type FileUploadRejection,\n type FileUploadSource,\n type FileUploadStatus,\n} from '@/components/ui/file-upload-core';\nimport type { ViewRef } from '@/components/ui/refs';\n\nexport {\n describeRules as describeFileUploadRules,\n formatFileSize,\n type FileUploadFile,\n type FileUploadKind,\n type FileUploadRejection,\n type FileUploadSource,\n type FileUploadStatus,\n};\n\n// ---------------------------------------------------------------------------\n// Optional dependencies\n// ---------------------------------------------------------------------------\n\ninterface PickerAsset {\n uri: string;\n width?: number;\n height?: number;\n fileName?: string | null;\n fileSize?: number | null;\n mimeType?: string | null;\n type?: string | null;\n}\n\ninterface PickerResult {\n canceled: boolean;\n assets?: PickerAsset[] | null;\n}\n\ninterface ImagePickerModule {\n requestCameraPermissionsAsync(): Promise<{ granted: boolean }>;\n launchCameraAsync(options: unknown): Promise;\n launchImageLibraryAsync(options: unknown): Promise;\n}\n\ninterface DocumentAsset {\n uri: string;\n name: string;\n size?: number | null;\n mimeType?: string | null;\n}\n\ninterface DocumentPickerModule {\n getDocumentAsync(options: unknown): Promise<{ canceled: boolean; assets?: DocumentAsset[] | null }>;\n}\n\ninterface ManipulatorContext {\n resize(size: { width?: number; height?: number }): ManipulatorContext;\n renderAsync(): Promise<{\n saveAsync(options: { format?: string; compress?: number }): Promise<{\n uri: string;\n width: number;\n height: number;\n }>;\n }>;\n}\n\ninterface ManipulatorModule {\n ImageManipulator?: { manipulate(uri: string): ManipulatorContext };\n}\n\n/*\n * Each `require` sits directly in its `try` block, which is what lets Metro\n * bundle an app that does not have the package.\n */\nfunction loadImagePicker(): ImagePickerModule | null {\n try {\n // eslint-disable-next-line @typescript-eslint/no-require-imports\n return require('expo-image-picker') as ImagePickerModule;\n } catch {\n return null;\n }\n}\n\nfunction loadDocumentPicker(): DocumentPickerModule | null {\n try {\n // eslint-disable-next-line @typescript-eslint/no-require-imports\n return require('expo-document-picker') as DocumentPickerModule;\n } catch {\n return null;\n }\n}\n\nfunction loadManipulator(): ManipulatorModule | null {\n try {\n // eslint-disable-next-line @typescript-eslint/no-require-imports\n return require('expo-image-manipulator') as ManipulatorModule;\n } catch {\n return null;\n }\n}\n\nconst MODULE_FOR: Record = {\n camera: 'expo-image-picker',\n library: 'expo-image-picker',\n files: 'expo-document-picker',\n};\n\n/** True when the module a source needs is installed. */\nexport function hasFileUploadSource(source: FileUploadSource): boolean {\n return source === 'files' ? loadDocumentPicker() !== null : loadImagePicker() !== null;\n}\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\n/** What the upload handler is given besides the file. */\nexport interface FileUploadHandlerContext {\n /** Aborted when the user cancels or removes the file. Pass it to `fetch`. */\n signal: AbortSignal;\n /** Report how far the upload is, 0–1. Calls are batched to one per frame. */\n onProgress: (progress: number) => void;\n /** Mark the bytes as sent and the server as still working on them. */\n setProcessing: () => void;\n}\n\n/**\n * Sends one file. Resolve when the server has it — the value is kept as the\n * file's `response` — and throw to mark it failed; the error's message is shown\n * on the row.\n */\nexport type FileUploadHandler = (\n file: FileUploadFile,\n context: FileUploadHandlerContext\n) => Promise;\n\n/** How photos are prepared before they are added. */\nexport interface FileUploadImageOptions {\n /** Scale photos wider than this down to it. Needs `expo-image-manipulator`. */\n maxWidth?: number;\n /** Scale photos taller than this down to it. Needs `expo-image-manipulator`. */\n maxHeight?: number;\n /** JPEG quality 0–1, for the picker and for a resized copy. Default 0.9. */\n quality?: number;\n /**\n * Open the system's crop editor after a photo is chosen or taken, at this\n * aspect ratio. Applies to a single photo only — the editor cannot run over\n * a multiple selection. iOS crops to a square whatever the ratio.\n */\n crop?: { aspect: [number, number] };\n}\n\nexport interface FileUploadProps {\n /** The files, controlled. */\n value?: FileUploadFile[];\n /** The files to start with, uncontrolled. */\n defaultValue?: FileUploadFile[];\n /** Called with the new list whenever a file is added, changes state or is removed. */\n onValueChange?: (files: FileUploadFile[]) => void;\n /**\n * Where files can come from, in the order the chooser lists them. One source\n * opens its picker straight away; more than one opens a sheet to choose from.\n * Default `['library', 'files']`.\n */\n sources?: FileUploadSource[];\n /**\n * Allowed types: MIME types (`application/pdf`), families (`image/*`) or\n * extensions (`.pdf`). Everything when left out.\n */\n accept?: string[];\n /**\n * Let the list hold more than one file. Without it the list holds one, and\n * picking again replaces it — the shape of a profile photo or a single\n * attachment field.\n */\n multiple?: boolean;\n /**\n * How many files the list can hold, with `multiple`. Pickers are limited to\n * what is left, and the add controls disable once it is reached.\n */\n maxFiles?: number;\n /**\n * Largest allowed file, in bytes. Checked against the size the picker\n * reports, after any resize; a file whose size is unknown passes.\n */\n maxSize?: number;\n /** Smallest allowed file, in bytes. */\n minSize?: number;\n /** An app-specific rule. Return a message to turn the file away. */\n validate?: (file: FileUploadFile) => string | null | undefined;\n /** Called with the files that were picked and turned away, and why. */\n onReject?: (rejections: FileUploadRejection[]) => void;\n /** Called when a picker cannot open — a missing module or a refused permission. */\n onError?: (error: Error) => void;\n /** Resize, compress or crop photos before they are added. */\n image?: FileUploadImageOptions;\n /** Sends one file. Without it files are only picked, and stay `idle`. */\n upload?: FileUploadHandler;\n /** How many uploads run at once. Default 2. */\n concurrency?: number;\n /**\n * Queue files for upload as soon as they are added. Default true. Turn it off\n * to send them later with `useFileUpload().upload()`.\n */\n autoUpload?: boolean;\n /** Called once per file when its upload resolves. */\n onUploadComplete?: (file: FileUploadFile) => void;\n /** Labels for the sources in the chooser. */\n sourceLabels?: Partial>;\n /** Nothing can be added, removed or retried. */\n disabled?: boolean;\n /** Draw the dropzone in its error state, for a form that failed validation. */\n invalid?: boolean;\n children?: ReactNode;\n}\n\ninterface FileUploadContextValue {\n files: FileUploadFile[];\n sources: FileUploadSource[];\n sourceLabels: Record;\n accept?: string[];\n maxFiles?: number;\n maxSize?: number;\n multiple: boolean;\n disabled: boolean;\n invalid: boolean;\n canAddMore: boolean;\n hasUpload: boolean;\n rejections: FileUploadRejection[];\n pick: (source: FileUploadSource) => Promise;\n remove: (id: string) => void;\n cancel: (id: string) => void;\n retry: (id: string) => void;\n upload: (id?: string) => void;\n clear: () => void;\n}\n\nconst FileUploadContext = createContext(null);\n\n/**\n * The state and actions of the nearest `FileUpload`, for drawing a part of it\n * by hand — a single avatar slot, a counter, a submit button that waits for\n * every upload.\n */\nexport function useFileUpload(): FileUploadContextValue {\n const context = useContext(FileUploadContext);\n if (!context) throw new Error('useFileUpload must be used inside .');\n return context;\n}\n\nconst DEFAULT_SOURCES: FileUploadSource[] = ['library', 'files'];\n\nconst DEFAULT_LABELS: Record = {\n camera: 'Take photo',\n library: 'Choose from library',\n files: 'Browse files',\n};\n\n/** How long the chooser sheet takes to leave before a system picker opens over it. */\nconst CHOOSER_EXIT_MS = 320;\n\n// ---------------------------------------------------------------------------\n// Root\n// ---------------------------------------------------------------------------\n\nfunction FileUploadRoot({\n value: valueProp,\n defaultValue = [],\n onValueChange,\n sources: sourcesProp = DEFAULT_SOURCES,\n accept,\n multiple = false,\n maxFiles: maxFilesProp,\n maxSize,\n minSize,\n validate,\n onReject,\n onError,\n image,\n upload: uploadHandler,\n concurrency = 2,\n autoUpload = true,\n onUploadComplete,\n sourceLabels,\n disabled = false,\n invalid = false,\n children,\n}: FileUploadProps) {\n const maxFiles = multiple ? maxFilesProp : 1;\n const { value, setValue } = useControllableState({\n value: valueProp,\n defaultValue,\n onChange: onValueChange,\n });\n\n /*\n * The list as this component last wrote it. Uploads report several changes\n * inside one tick — a file starts, the next one queues, progress arrives —\n * and a controlled owner has not re-rendered between them, so building each\n * change on `value` would drop all but the last. Every change is built on\n * this ref instead, and the ref takes the owner's value whenever it changes.\n */\n const latest = useRef(value);\n const seen = useRef(value);\n if (value !== seen.current) {\n seen.current = value;\n latest.current = value;\n }\n\n const commit = useCallback(\n (change: (files: FileUploadFile[]) => FileUploadFile[]) => {\n const next = change(latest.current);\n if (next === latest.current) return;\n latest.current = next;\n setValue(next);\n },\n [setValue]\n );\n\n const patch = useCallback(\n (id: string, fields: Partial) =>\n commit((files) =>\n files.some((file) => file.id === id)\n ? files.map((file) => (file.id === id ? { ...file, ...fields } : file))\n : files\n ),\n [commit]\n );\n\n const [rejections, setRejections] = useState([]);\n\n // Callbacks read through a ref so the queue never runs a stale handler.\n const handlers = useRef({ uploadHandler, onUploadComplete, onReject, onError, validate });\n handlers.current = { uploadHandler, onUploadComplete, onReject, onError, validate };\n\n // ---- upload queue -------------------------------------------------------\n\n const jobs = useRef(new Map());\n const pendingProgress = useRef(new Map());\n const frame = useRef(null);\n\n const flushProgress = useCallback(() => {\n frame.current = null;\n const pending = pendingProgress.current;\n if (pending.size === 0) return;\n pendingProgress.current = new Map();\n commit((files) =>\n files.map((file) =>\n pending.has(file.id) && file.status === 'uploading'\n ? { ...file, progress: pending.get(file.id) }\n : file\n )\n );\n }, [commit]);\n\n const pump = useCallback(() => {\n const handler = handlers.current.uploadHandler;\n if (!handler) return;\n for (const id of nextToStart(latest.current, concurrency)) {\n const file = latest.current.find((entry) => entry.id === id);\n if (!file) continue;\n const controller = new AbortController();\n jobs.current.set(id, controller);\n patch(id, { status: 'uploading', progress: 0, error: undefined });\n\n const context: FileUploadHandlerContext = {\n signal: controller.signal,\n onProgress: (progress) => {\n if (controller.signal.aborted) return;\n pendingProgress.current.set(id, Math.min(1, Math.max(0, progress)));\n frame.current ??= requestAnimationFrame(flushProgress);\n },\n setProcessing: () => {\n if (controller.signal.aborted) return;\n pendingProgress.current.delete(id);\n patch(id, { status: 'processing', progress: 1 });\n },\n };\n\n Promise.resolve()\n .then(() => handler({ ...file, status: 'uploading', progress: 0 }, context))\n .then(\n (response) => {\n if (controller.signal.aborted) return;\n jobs.current.delete(id);\n pendingProgress.current.delete(id);\n patch(id, { status: 'done', progress: 1, response, error: undefined });\n const done = latest.current.find((entry) => entry.id === id);\n if (done) handlers.current.onUploadComplete?.(done);\n },\n (error: unknown) => {\n if (controller.signal.aborted) return;\n jobs.current.delete(id);\n pendingProgress.current.delete(id);\n patch(id, {\n status: 'error',\n error: error instanceof Error && error.message ? error.message : 'Upload failed',\n });\n }\n );\n }\n }, [concurrency, flushProgress, patch]);\n\n // Whatever changed the list — a pick, a finish, a retry — may have freed a\n // slot or queued a file. Starting is idempotent, so running on every change\n // is the whole scheduler.\n useEffect(() => {\n pump();\n }, [value, pump, uploadHandler]);\n\n useEffect(() => {\n const running = jobs.current;\n return () => {\n for (const controller of running.values()) controller.abort();\n running.clear();\n if (frame.current !== null) cancelAnimationFrame(frame.current);\n };\n }, []);\n\n const stop = useCallback((id: string) => {\n jobs.current.get(id)?.abort();\n jobs.current.delete(id);\n pendingProgress.current.delete(id);\n }, []);\n\n const remove = useCallback(\n (id: string) => {\n stop(id);\n setRejections([]);\n commit((files) => files.filter((file) => file.id !== id));\n },\n [commit, stop]\n );\n\n const cancel = useCallback(\n (id: string) => {\n const file = latest.current.find((entry) => entry.id === id);\n if (!file || !(isActive(file.status) || file.status === 'queued')) return;\n stop(id);\n patch(id, { status: 'canceled', progress: undefined });\n },\n [patch, stop]\n );\n\n const retry = useCallback(\n (id: string) => {\n if (!handlers.current.uploadHandler) return;\n patch(id, { status: 'queued', progress: 0, error: undefined });\n },\n [patch]\n );\n\n const uploadNow = useCallback(\n (id?: string) => {\n if (!handlers.current.uploadHandler) return;\n commit((files) =>\n files.map((file) =>\n (id === undefined || file.id === id) &&\n (file.status === 'idle' || file.status === 'canceled' || file.status === 'error')\n ? { ...file, status: 'queued', progress: 0, error: undefined }\n : file\n )\n );\n },\n [commit]\n );\n\n const clear = useCallback(() => {\n for (const id of [...jobs.current.keys()]) stop(id);\n setRejections([]);\n commit(() => []);\n }, [commit, stop]);\n\n // ---- picking ------------------------------------------------------------\n\n const report = useCallback((error: Error) => {\n if (handlers.current.onError) handlers.current.onError(error);\n else if (__DEV__) console.warn(`FileUpload: ${error.message}`);\n }, []);\n\n const pick = useCallback(\n async (source: FileUploadSource) => {\n if (disabled) return;\n // A single-file upload replaces its file rather than filling up.\n const replacing = !multiple;\n const held = replacing ? 0 : latest.current.length;\n const remaining = maxFiles === undefined ? undefined : Math.max(0, maxFiles - held);\n if (remaining === 0) return;\n const many = multiple && remaining !== 1;\n const quality = image?.quality ?? 0.9;\n const crop = !many ? image?.crop : undefined;\n\n let picked: FileUploadFile[];\n try {\n if (source === 'files') {\n const picker = loadDocumentPicker();\n if (!picker) throw missing(source);\n const result = await picker.getDocumentAsync({\n type: pickerTypes(accept),\n multiple: many,\n copyToCacheDirectory: true,\n });\n if (result.canceled) return;\n picked = (result.assets ?? []).map((asset) => fromDocument(asset));\n } else {\n const picker = loadImagePicker();\n if (!picker) throw missing(source);\n const options = {\n mediaTypes: source === 'camera' ? ['images'] : libraryMediaTypes(accept),\n allowsMultipleSelection: source === 'library' && many,\n selectionLimit: many ? (remaining ?? 0) : 1,\n orderedSelection: true,\n allowsEditing: crop !== undefined,\n aspect: crop?.aspect,\n quality,\n exif: false,\n };\n let result: PickerResult;\n if (source === 'camera') {\n const permission = await picker.requestCameraPermissionsAsync();\n if (!permission.granted) {\n throw new Error('Camera permission was refused. It can be turned on in Settings.');\n }\n result = await picker.launchCameraAsync(options);\n } else {\n result = await picker.launchImageLibraryAsync(options);\n }\n if (result.canceled) return;\n picked = await Promise.all(\n (result.assets ?? []).map((asset, index) =>\n prepareImage(fromPickerAsset(asset, index), image, quality)\n )\n );\n }\n } catch (error) {\n report(error instanceof Error ? error : new Error(String(error)));\n return;\n }\n\n const { accepted, rejected } = partitionFiles(picked, held, {\n accept,\n maxSize,\n minSize,\n maxFiles,\n validate: handlers.current.validate,\n });\n setRejections(rejected);\n if (rejected.length > 0) {\n handlers.current.onReject?.(rejected);\n AccessibilityInfo.announceForAccessibility(rejected.map((entry) => entry.message).join(' '));\n }\n if (accepted.length === 0) return;\n const status: FileUploadStatus =\n handlers.current.uploadHandler && autoUpload ? 'queued' : 'idle';\n const added = accepted.map((file) => ({ ...file, status }));\n if (replacing) {\n for (const file of latest.current) stop(file.id);\n commit(() => added.slice(0, 1));\n } else {\n commit((files) => [...files, ...added]);\n }\n },\n [accept, autoUpload, commit, disabled, image, maxFiles, maxSize, minSize, multiple, report, stop]\n );\n\n // A source whose picker could never return an allowed file is left out.\n const sources = useMemo(\n () =>\n sourcesProp.filter((source) => {\n if (source === 'files') return true;\n const media = libraryMediaTypes(accept);\n return source === 'camera' ? media.includes('images') : media.length > 0;\n }),\n [accept, sourcesProp]\n );\n\n const context = useMemo(\n () => ({\n files: value,\n sources,\n sourceLabels: { ...DEFAULT_LABELS, ...sourceLabels },\n accept,\n maxFiles,\n maxSize,\n multiple,\n disabled,\n invalid,\n canAddMore: !disabled && (!multiple || maxFiles === undefined || value.length < maxFiles),\n hasUpload: uploadHandler !== undefined,\n rejections,\n pick,\n remove,\n cancel,\n retry,\n upload: uploadNow,\n clear,\n }),\n [\n accept,\n cancel,\n clear,\n disabled,\n invalid,\n maxFiles,\n maxSize,\n multiple,\n pick,\n rejections,\n remove,\n retry,\n sourceLabels,\n sources,\n uploadHandler,\n uploadNow,\n value,\n ]\n );\n\n return {children};\n}\n\nfunction missing(source: FileUploadSource): Error {\n const name = MODULE_FOR[source];\n return new Error(`Adding from \"${source}\" needs ${name}. Install it with: npx expo install ${name}`);\n}\n\nfunction fromDocument(asset: DocumentAsset): FileUploadFile {\n const file = {\n uri: asset.uri,\n name: asset.name,\n size: asset.size ?? undefined,\n mimeType: asset.mimeType ?? undefined,\n };\n return { id: createFileId(), ...file, kind: kindOf(file), status: 'idle' };\n}\n\nfunction fromPickerAsset(asset: PickerAsset, index: number): FileUploadFile {\n const video = asset.type === 'video' || asset.mimeType?.startsWith('video/');\n const fallbackExt = video ? 'mp4' : 'jpg';\n const name = asset.fileName || `${video ? 'Video' : 'Photo'} ${timestamp()}${index ? `-${index + 1}` : ''}.${fallbackExt}`;\n const mimeType = asset.mimeType ?? (video ? 'video/mp4' : extensionOf(name) === 'png' ? 'image/png' : 'image/jpeg');\n return {\n id: createFileId(),\n uri: asset.uri,\n name,\n size: asset.fileSize ?? undefined,\n mimeType,\n width: asset.width,\n height: asset.height,\n kind: video ? 'video' : 'image',\n status: 'idle',\n };\n}\n\nfunction timestamp(): string {\n const now = new Date();\n const pad = (n: number) => String(n).padStart(2, '0');\n return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}.${pad(now.getMinutes())}.${pad(now.getSeconds())}`;\n}\n\n/** Scale a photo into `maxWidth`/`maxHeight`, when it is larger and the manipulator is installed. */\nasync function prepareImage(\n file: FileUploadFile,\n options: FileUploadImageOptions | undefined,\n quality: number\n): Promise {\n if (file.kind !== 'image' || !options || !file.width || !file.height) return file;\n const target = fitWithin({ width: file.width, height: file.height }, options);\n if (!target) return file;\n const manipulator = loadManipulator()?.ImageManipulator;\n if (!manipulator) {\n if (__DEV__) {\n console.warn('FileUpload: resizing photos needs expo-image-manipulator. Install it with: npx expo install expo-image-manipulator');\n }\n return file;\n }\n const rendered = await manipulator.manipulate(file.uri).resize({ width: target.width }).renderAsync();\n const saved = await rendered.saveAsync({ format: 'jpeg', compress: quality });\n const name = file.name.replace(/\\.[^.]+$/, '') + '.jpg';\n // The resized file's size is not reported; it is smaller than the original.\n return { ...file, uri: saved.uri, width: saved.width, height: saved.height, name, size: undefined, mimeType: 'image/jpeg' };\n}\n\n// ---------------------------------------------------------------------------\n// Source chooser\n// ---------------------------------------------------------------------------\n\nconst SOURCE_ICONS: Record ReactNode> = {\n camera: (color) => ,\n library: (color) => ,\n files: (color) => ,\n};\n\n/**\n * Wraps one pressable. With one source the press opens its picker; with more,\n * a sheet of the sources opens first, and the picker waits for it to leave so\n * the two never animate over each other.\n */\nfunction SourceChooser({ children }: { children: ReactElement<{ onPress?: (...args: unknown[]) => void }> }) {\n const { sources, sourceLabels, pick, canAddMore } = useFileUpload();\n const iconColor = useCSSVariable('--color-foreground');\n const color = typeof iconColor === 'string' ? iconColor : undefined;\n\n if (sources.length <= 1) {\n const only = sources[0];\n return cloneElement(children, {\n onPress: (...args: unknown[]) => {\n children.props.onPress?.(...args);\n if (only && canAddMore) void pick(only);\n },\n });\n }\n\n if (!canAddMore) return children;\n\n const choose = (source: FileUploadSource) => {\n setTimeout(() => void pick(source), CHOOSER_EXIT_MS);\n };\n\n return (\n \n {children}\n \n {sources.map((source) => (\n choose(source)}>\n {sourceLabels[source]}\n \n ))}\n \n \n );\n}\n\n// ---------------------------------------------------------------------------\n// Trigger\n// ---------------------------------------------------------------------------\n\nexport interface FileUploadTriggerProps {\n /** One pressable — a Button, an icon button, an avatar. */\n children: ReactElement;\n}\n\n/**\n * Turns any pressable into the way files are added. With several sources it\n * opens a sheet listing them; with one it opens that picker directly.\n */\nfunction FileUploadTrigger({ children }: FileUploadTriggerProps) {\n const { disabled, canAddMore } = useFileUpload();\n const child = Children.only(children) as ReactElement<{ disabled?: boolean; onPress?: () => void }>;\n const locked = disabled || !canAddMore;\n return (\n \n {isValidElement(child) && locked ? cloneElement(child, { disabled: true }) : child}\n \n );\n}\n\n// ---------------------------------------------------------------------------\n// Dropzone\n// ---------------------------------------------------------------------------\n\nconst dropzoneVariants = tv({\n slots: {\n root: 'items-center justify-center rounded-2xl border-2 border-dashed border-border bg-muted/40 px-6 py-7 active:bg-muted',\n // The pressable animates its own opacity for press feedback, which would\n // overwrite a dimming class on it — so the contents are what dim.\n body: 'items-center gap-3',\n badge: 'h-12 w-12 items-center justify-center rounded-full border border-border bg-background',\n title: 'text-center text-base font-semibold text-foreground',\n hint: 'text-center text-sm text-muted-foreground',\n error: 'text-center text-sm text-destructive',\n },\n variants: {\n invalid: { true: { root: 'border-destructive bg-destructive-subtle' } },\n disabled: { true: { body: 'opacity-50' } },\n },\n});\n\nexport interface FileUploadDropzoneProps extends Omit {\n className?: string;\n /** Headline. Default \"Choose files\", or \"Choose a file\" for a single file. */\n title?: ReactNode;\n /** The line under it. Written from `accept`, `maxSize` and `maxFiles` when left out. */\n description?: ReactNode;\n /** Replaces the upload glyph in the badge. */\n icon?: ReactNode;\n /** Draw the inside yourself. The press still opens the sources. */\n children?: ReactNode;\n}\n\n/**\n * The large dashed area at the top of a form. Pressing anywhere on it adds\n * files, and the line under the title says what is allowed before anybody\n * picks something that is not. When a pick is turned away, the reason replaces\n * that line until the next pick.\n */\nconst FileUploadDropzone = forwardRef(\n ({ className, title, description, icon, children, ...props }, ref) => {\n const { accept, maxSize, maxFiles, multiple, disabled, invalid, canAddMore, rejections, files } =\n useFileUpload();\n const labelledBy = useFieldLabelledBy();\n const primary = useCSSVariable('--color-foreground');\n const full = multiple && maxFiles !== undefined && files.length >= maxFiles;\n const first = rejections[0];\n const rejected = first\n ? rejections.length > 1\n ? `${first.message} ${rejections.length - 1} more could not be added.`\n : first.message\n : null;\n const slots = dropzoneVariants({ invalid: invalid || rejected !== null, disabled: disabled || full });\n\n const heading = title ?? (multiple ? 'Choose files' : 'Choose a file');\n const hint = full\n ? `${files.length} of ${maxFiles} added. Remove one to add another.`\n : (description ?? describeRules({ accept, maxSize, maxFiles: multiple ? maxFiles : undefined }));\n\n return (\n \n \n \n {children ?? (\n <>\n \n {icon ?? }\n \n \n {typeof heading === 'string' ? {heading} : heading}\n {hint ? (\n typeof hint === 'string' ? {hint} : hint\n ) : null}\n {rejected ? (\n \n {rejected}\n \n ) : null}\n \n \n )}\n \n \n \n );\n }\n);\nFileUploadDropzone.displayName = 'FileUpload.Dropzone';\n\n// ---------------------------------------------------------------------------\n// Item and List\n// ---------------------------------------------------------------------------\n\nconst ATTACHMENT_STATE: Record = {\n idle: 'idle',\n queued: 'idle',\n uploading: 'uploading',\n processing: 'processing',\n done: 'done',\n error: 'error',\n canceled: 'idle',\n};\n\n/** \"PDF · 2.4 MB\", \"1.1 of 2.4 MB · 46%\", \"Waiting\", \"Upload failed\". */\nexport function describeFileStatus(file: FileUploadFile): string {\n const ext = extensionOf(file.name).toUpperCase();\n const size = file.size !== undefined ? formatFileSize(file.size) : '';\n const percent = `${Math.round((file.progress ?? 0) * 100)}%`;\n switch (file.status) {\n case 'queued':\n return ['Waiting', size].filter(Boolean).join(' · ');\n case 'uploading':\n return file.size !== undefined\n ? `${formatFileSize(file.size * (file.progress ?? 0))} of ${size} · ${percent}`\n : `Uploading · ${percent}`;\n case 'processing':\n return 'Processing…';\n case 'done':\n return [size, 'Uploaded'].filter(Boolean).join(' · ');\n case 'error':\n return file.error ?? 'Upload failed';\n case 'canceled':\n return ['Canceled', size].filter(Boolean).join(' · ');\n default:\n return [ext, size].filter(Boolean).join(' · ');\n }\n}\n\nfunction KindIcon({ kind, size = 18, color }: { kind: FileUploadKind; size?: number; color?: string }) {\n if (kind === 'image') return ;\n if (kind === 'video') return ;\n return ;\n}\n\nexport interface FileUploadItemProps {\n file: FileUploadFile;\n className?: string;\n /** Row density. */\n size?: 'default' | 'sm';\n /**\n * Longest name shown before it is shortened from the middle, keeping the\n * extension. Default 36.\n */\n nameLength?: number;\n}\n\n/**\n * One file as an Attachment row: a thumbnail or type icon, the name, a status\n * line, and the action that fits the state — cancel while it is moving, retry\n * when it failed or was stopped, remove otherwise.\n */\nfunction FileUploadItem({ file, className, size = 'default', nameLength = 36 }: FileUploadItemProps) {\n const { remove, cancel, retry, disabled, hasUpload } = useFileUpload();\n const muted = useCSSVariable('--color-muted-foreground');\n const success = useCSSVariable('--color-success');\n const mutedColor = typeof muted === 'string' ? muted : undefined;\n const moving = isActive(file.status) || file.status === 'queued';\n const failed = file.status === 'error' || file.status === 'canceled';\n const status = describeFileStatus(file);\n\n return (\n \n {file.kind === 'image' ? (\n \n \n \n ) : (\n \n \n \n )}\n \n {/* Shortened by hand: the shimmer a busy row draws cannot clip to one line. */}\n {truncateMiddle(file.name, nameLength)}\n \n {status}\n \n \n \n {file.status === 'done' ? (\n \n \n \n ) : null}\n {failed && hasUpload ? (\n retry(file.id)}\n >\n \n \n ) : null}\n (moving ? cancel(file.id) : remove(file.id))}\n >\n {moving ? : }\n \n \n \n );\n}\n\nexport interface FileUploadListProps extends ViewProps {\n className?: string;\n /** Lay the files out as one scrolling strip of fixed-width rows, for a message composer. */\n horizontal?: boolean;\n /** Row density. */\n size?: 'default' | 'sm';\n /** Draw a row yourself. `FileUpload.Item` is the default. */\n renderItem?: (file: FileUploadFile) => ReactNode;\n /** Shown when the list is empty. Nothing by default. */\n empty?: ReactNode;\n}\n\n/** Every file as a row, in the order it was added. */\nconst FileUploadList = forwardRef(\n ({ className, horizontal = false, size, renderItem, empty, ...props }, ref) => {\n const { files } = useFileUpload();\n if (files.length === 0) return empty ? <>{empty} : null;\n\n const rows = files.map((file) =>\n renderItem ? (\n {renderItem(file)}\n ) : (\n \n )\n );\n\n if (horizontal) {\n return (\n \n \n {rows}\n \n \n );\n }\n return (\n \n {rows}\n \n );\n }\n);\nFileUploadList.displayName = 'FileUpload.List';\n\n// ---------------------------------------------------------------------------\n// Grid\n// ---------------------------------------------------------------------------\n\nconst AnimatedCircle = Animated.createAnimatedComponent(Circle);\n\n/** A ring that fills with upload progress, drawn over a tile's scrim. */\nfunction ProgressRing({ progress, size = 36 }: { progress: number; size?: number }) {\n const stroke = 3;\n const radius = (size - stroke) / 2;\n const circumference = 2 * Math.PI * radius;\n const shown = useSharedValue(progress);\n\n useEffect(() => {\n shown.value = withTiming(progress, { duration: 180 });\n }, [progress, shown]);\n\n const animatedProps = useAnimatedProps(() => ({\n strokeDashoffset: circumference * (1 - shown.value),\n }));\n\n return (\n \n \n \n \n );\n}\n\nfunction GridTile({\n file,\n size,\n cover,\n radius,\n}: {\n file: FileUploadFile;\n size: number;\n cover: boolean;\n radius: number;\n}) {\n const { remove, cancel, retry, disabled, hasUpload } = useFileUpload();\n const muted = useCSSVariable('--color-muted-foreground');\n const foreground = useCSSVariable('--color-foreground');\n const moving = isActive(file.status) || file.status === 'queued';\n const failed = file.status === 'error' || file.status === 'canceled';\n const status = describeFileStatus(file);\n\n return (\n \n {file.kind === 'image' ? (\n \n \n \n ) : (\n \n \n \n {file.name}\n \n \n )}\n\n {moving || failed ? (\n \n {file.status === 'uploading' ? (\n \n ) : file.status === 'queued' || file.status === 'processing' ? (\n \n {file.status === 'queued' ? 'Waiting' : 'Processing'}\n \n ) : hasUpload ? (\n retry(file.id)}\n className=\"h-9 w-9 items-center justify-center rounded-full bg-white/20 active:bg-white/30\"\n >\n \n \n ) : null}\n \n ) : null}\n\n {cover ? (\n \n Cover\n \n ) : null}\n\n (moving ? cancel(file.id) : remove(file.id))}\n className=\"absolute right-1.5 top-1.5 h-6 w-6 items-center justify-center rounded-full border border-border bg-background active:bg-muted\"\n >\n \n \n \n );\n}\n\nexport interface FileUploadGridProps extends ViewProps {\n className?: string;\n /** Tiles per row. Default 3. Ignored when `horizontal`. */\n columns?: number;\n /** Lay the tiles out as one scrolling strip. */\n horizontal?: boolean;\n /** Tile size in points, for a horizontal strip. Default 88. */\n tileSize?: number;\n /** Space between tiles, in points. Default 8. */\n gap?: number;\n /** Corner radius of a tile, in points. Default 12. */\n radius?: number;\n /** Mark the first photo as the cover. */\n cover?: boolean;\n /** Draw empty slots for the rest of `maxFiles`, so the limit can be seen. */\n placeholders?: boolean;\n /** Label of the add tile. Default \"Add photo\" or \"Add photos\". */\n addLabel?: string;\n /** Leave the add tile out — for a grid with its own Trigger elsewhere. */\n hideAdd?: boolean;\n}\n\n/**\n * Photos as square tiles, led by a tile that adds more. Each photo opens in\n * the full-screen viewer, carries its own remove button, and shows a ring\n * while it uploads. The add tile counts against `maxFiles`, so the limit is\n * visible before anybody hits it.\n */\nconst FileUploadGrid = forwardRef(\n (\n {\n className,\n columns = 3,\n horizontal = false,\n tileSize = 88,\n gap = 8,\n radius = 12,\n cover = false,\n placeholders = false,\n addLabel,\n hideAdd = false,\n onLayout,\n ...props\n },\n ref\n ) => {\n const { files, maxFiles, multiple, canAddMore } = useFileUpload();\n const [width, setWidth] = useState(0);\n const background = useCSSVariable('--color-background');\n const muted = useCSSVariable('--color-muted-foreground');\n\n const size = horizontal\n ? tileSize\n : width > 0\n ? Math.floor((width - gap * (columns - 1)) / columns)\n : 0;\n\n const showAdd = !hideAdd && canAddMore;\n const slotCount =\n placeholders && maxFiles !== undefined\n ? Math.max(0, maxFiles - files.length - (showAdd ? 1 : 0))\n : 0;\n // Empty slots only finish the current row; a page of them is noise.\n const filled = files.length + (showAdd ? 1 : 0);\n const rowRemainder = filled % columns === 0 ? 0 : columns - (filled % columns);\n const visibleSlots = horizontal ? slotCount : Math.min(slotCount, rowRemainder);\n\n const label = addLabel ?? (multiple ? 'Add photos' : 'Add photo');\n const tiles: ReactNode[] = [];\n\n if (showAdd && size > 0) {\n tiles.push(\n \n \n \n \n \n {size >= 84 ? (\n \n {label}\n \n ) : null}\n {maxFiles !== undefined && multiple ? (\n \n {files.length}/{maxFiles}\n \n ) : null}\n \n \n );\n }\n\n if (size > 0) {\n files.forEach((file, index) =>\n tiles.push(\n \n )\n );\n for (let i = 0; i < visibleSlots; i += 1) {\n tiles.push(\n \n \n \n );\n }\n }\n\n const body = horizontal ? (\n \n {tiles}\n \n ) : (\n \n {tiles}\n \n );\n\n return (\n {\n setWidth(event.nativeEvent.layout.width);\n onLayout?.(event);\n }}\n {...props}\n >\n {body}\n \n );\n }\n);\nFileUploadGrid.displayName = 'FileUpload.Grid';\n\nFileUploadRoot.displayName = 'FileUpload';\nFileUploadTrigger.displayName = 'FileUpload.Trigger';\nFileUploadItem.displayName = 'FileUpload.Item';\n\nexport const FileUpload = Object.assign(FileUploadRoot, {\n Trigger: FileUploadTrigger,\n Dropzone: FileUploadDropzone,\n List: FileUploadList,\n Item: FileUploadItem,\n Grid: FileUploadGrid,\n});\n" } ] } diff --git a/apps/docs/public/r/flip-card.json b/apps/docs/public/r/flip-card.json index e2233b1cb..1d0069d90 100644 --- a/apps/docs/public/r/flip-card.json +++ b/apps/docs/public/r/flip-card.json @@ -3,7 +3,8 @@ "type": "registry:ui", "description": "Two faces of one card, and a turn between them.", "registryDependencies": [ - "cn" + "cn", + "refs" ], "dependencies": [ "react-native-gesture-handler", @@ -15,7 +16,7 @@ { "path": "ui/flip-card.tsx", "type": "registry:ui", - "content": "/**\n * FlipCard — two faces of one card, and a turn between them.\n *\n * ```tsx\n * \n * \n * Tap me\n * \n * \n * The other side\n * \n * \n * ```\n *\n * For content that is genuinely two-sided — a bank card and its security code,\n * a term and its definition, a photograph and what it is of. Not for\n * progressive disclosure: the back replaces the front rather than extending\n * it, so anything the reader needs to compare across the turn belongs in\n * [Collapsible](../collapsible) or a second card instead.\n *\n * ## One box, and both faces in it\n *\n * The back is laid over the front, so there is one box and the two faces fill\n * it. State the height on the root and both faces take it; leave it off and\n * the front's own content decides, with the back given whatever that came to.\n *\n * A back with more in it than the front overflows rather than growing the\n * card, and the fix is a height on the root rather than more padding on the\n * back — a card whose height changed halfway through the turn would be a card\n * that moved everything under it.\n *\n * ## Both faces are hidden the same way twice\n *\n * `backfaceVisibility` is the mechanism, and it is not reliable on every\n * Android surface — so the face that has turned away is also faded out and\n * dropped behind, on the same shared value, at the halfway point. Two\n * mechanisms for one job, because the failure of the first is a card that\n * shows both faces mirrored through each other and nothing in the tree that\n * says why.\n *\n * ## Under reduce motion\n *\n * The faces swap with no turn at all. Not a shorter turn — none. The rotation\n * is the part that moves, and moving is the part the setting is about; which\n * face is showing is the information, and it is kept.\n */\nimport {\n Children,\n createContext,\n forwardRef,\n isValidElement,\n useCallback,\n useContext,\n useEffect,\n useMemo,\n useState,\n type ReactNode,\n} from 'react';\nimport { StyleSheet, View, type ViewProps } from 'react-native';\nimport { Gesture, GestureDetector } from 'react-native-gesture-handler';\nimport Animated, {\n cancelAnimation,\n interpolate,\n runOnJS,\n useAnimatedStyle,\n useReducedMotion,\n useSharedValue,\n withSpring,\n type SharedValue,\n} from 'react-native-reanimated';\nimport { tv } from 'tailwind-variants';\nimport { cn } from '@/lib/cn';\n\n/**\n * How the card settles on a face.\n *\n * Clamped, and a little heavier than the library's other springs. A card has\n * apparent mass — it is a rectangle the size of your hand — and a turn that\n * overshoots and comes back reads as a playing card being flicked rather than\n * an object being turned over. The overshoot is also the one place the hidden\n * face can appear, since it puts the rotation past 360.\n */\nconst SPRING = {\n damping: 18,\n stiffness: 140,\n mass: 0.9,\n overshootClamping: true,\n} as const;\n\n/**\n * Distance across the card that counts as a whole turn, as a fraction of its\n * own width or height.\n *\n * Less than the full span: a drag that has crossed two thirds of the card has\n * made its point, and requiring the whole width means the last part of every\n * deliberate flip is spent travelling to an edge the finger has to reach.\n */\nconst DRAG_SPAN = 0.66;\n\n/** Velocity, in points per second, that carries a short drag over anyway. */\nconst FLICK_VELOCITY = 500;\n\nconst flipCardVariants = tv({\n slots: {\n root: 'relative',\n face: 'w-full',\n },\n});\n\n/** Which axis the card turns about. */\nexport type FlipCardDirection = 'horizontal' | 'vertical';\n\n/** Which way round it turns. */\nexport type FlipCardRotation = 'normal' | 'reverse';\n\n/** What flips the card. */\nexport type FlipCardTrigger = 'press' | 'drag' | 'none';\n\ninterface FlipCardContextValue {\n flipped: boolean;\n direction: FlipCardDirection;\n rotation: FlipCardRotation;\n perspective: number;\n progress: SharedValue;\n flip: () => void;\n}\n\nconst FlipCardContext = createContext(null);\n\nfunction useFlipCardContext(component: string) {\n const context = useContext(FlipCardContext);\n if (!context) throw new Error(`${component} must be used within a `);\n return context;\n}\n\n/**\n * The card's state, for a face that needs to know which way round it is.\n *\n * `progress` runs 0 → 1 across the turn and is a shared value, so a face can\n * drive its own animation off the same turn rather than starting a second one\n * beside it.\n */\nexport function useFlipCard() {\n const { flipped, direction, rotation, progress, flip } = useFlipCardContext('useFlipCard');\n return { flipped, direction, rotation, progress, flip };\n}\n\nexport interface FlipCardProps extends Omit {\n /** `FlipCard.Front` and `FlipCard.Back`, in either order. */\n children?: ReactNode;\n /**\n * Which axis the card turns about. `horizontal` turns it left to right about\n * its vertical axis; `vertical` turns it top over bottom.\n */\n direction?: FlipCardDirection;\n /**\n * Which way round the turn goes. `reverse` sends it the other way, for a\n * pair of cards that should not turn identically.\n */\n rotation?: FlipCardRotation;\n /**\n * Which face is showing, when the caller holds it. Leave unset to let the\n * card keep its own, and pair with `trigger=\"none\"` for a card flipped only\n * from outside.\n */\n flipped?: boolean;\n /** Which face an uncontrolled card starts on. */\n defaultFlipped?: boolean;\n /** Fires whenever the card settles on the other face, however it got there. */\n onFlippedChange?: (flipped: boolean) => void;\n /**\n * What turns the card. `press` is a tap anywhere on it; `drag` turns it with\n * the finger and springs to whichever face is nearer on release; `none`\n * leaves it to `flipped`.\n */\n trigger?: FlipCardTrigger;\n /**\n * How deep the turn looks, in points. Smaller is more dramatic — the near\n * edge swings further out — and below about 400 a full card starts to read\n * as a door rather than a card.\n */\n perspective?: number;\n /** Classes for the card's own box. The front's size is the card's size. */\n className?: string;\n}\n\nconst FlipCardRoot = forwardRef(\n (\n {\n children,\n direction = 'horizontal',\n rotation = 'normal',\n flipped: flippedProp,\n defaultFlipped = false,\n onFlippedChange,\n trigger = 'press',\n perspective = 1000,\n className,\n ...props\n },\n ref\n ) => {\n const [internal, setInternal] = useState(defaultFlipped);\n const isControlled = flippedProp !== undefined;\n const flipped = isControlled ? flippedProp : internal;\n\n const progress = useSharedValue(defaultFlipped ? 1 : 0);\n const startProgress = useSharedValue(0);\n const span = useSharedValue(0);\n const reduceMotion = useReducedMotion();\n\n const { root, face } = flipCardVariants();\n\n /*\n * The settled face is React state and the turn is a shared value, and the\n * two are kept in step from here rather than from the gesture: a drag that\n * is let go past halfway has already decided, and telling React at the\n * moment of release rather than at the end of the spring is what keeps the\n * hidden face out of the accessibility tree while it is still turning.\n */\n const settle = useCallback(\n (next: boolean) => {\n if (!isControlled) setInternal(next);\n onFlippedChange?.(next);\n },\n [isControlled, onFlippedChange]\n );\n\n useEffect(() => {\n progress.value = reduceMotion\n ? flipped\n ? 1\n : 0\n : withSpring(flipped ? 1 : 0, SPRING);\n }, [flipped, progress, reduceMotion]);\n\n useEffect(() => () => cancelAnimation(progress), [progress]);\n\n const flip = useCallback(() => settle(!flipped), [flipped, settle]);\n\n const gesture = useMemo(() => {\n const tap = Gesture.Tap()\n .enabled(trigger === 'press')\n .onEnd((_event, success) => {\n if (success) runOnJS(flip)();\n });\n\n /*\n * The span is read from the shared value rather than from a captured\n * number, so a card that is measured or resized mid-touch does not leave\n * the gesture dividing by the width it had when the finger landed.\n */\n const pan = Gesture.Pan()\n .enabled(trigger === 'drag')\n .onBegin(() => {\n startProgress.value = progress.value;\n })\n .onUpdate((event) => {\n const travel = direction === 'horizontal' ? event.translationX : event.translationY;\n const reach = Math.max(span.value * DRAG_SPAN, 1);\n const next = startProgress.value + travel / reach;\n progress.value = Math.min(Math.max(next, 0), 1);\n })\n .onEnd((event) => {\n const velocity = direction === 'horizontal' ? event.velocityX : event.velocityY;\n const committed =\n progress.value > 0.5 || (progress.value > 0.1 && velocity > FLICK_VELOCITY);\n runOnJS(settle)(committed);\n });\n\n return Gesture.Exclusive(pan, tap);\n }, [direction, flip, progress, settle, span, startProgress, trigger]);\n\n /*\n * One turn, read two ways. The front runs 0 → 180 and the back 180 → 360,\n * so at any moment exactly one of them has its face toward the reader.\n *\n * `sign` is not an RTL mirror. Turning a physical object over is a physical\n * direction, not a reading direction, and a card that spun the other way\n * in an Arabic layout would be a different card rather than the same one\n * laid out correctly.\n */\n const sign = rotation === 'reverse' ? -1 : 1;\n const axis = direction === 'horizontal' ? 'rotateY' : 'rotateX';\n\n const frontStyle = useAnimatedStyle(() => {\n const angle = interpolate(progress.value, [0, 1], [0, 180 * sign]);\n const showing = progress.value < 0.5;\n return {\n transform: [{ perspective }, { [axis]: `${angle}deg` } as never],\n opacity: showing ? 1 : 0,\n zIndex: showing ? 1 : 0,\n };\n });\n\n const backStyle = useAnimatedStyle(() => {\n const angle = interpolate(progress.value, [0, 1], [-180 * sign, 0]);\n const showing = progress.value >= 0.5;\n return {\n transform: [{ perspective }, { [axis]: `${angle}deg` } as never],\n opacity: showing ? 1 : 0,\n zIndex: showing ? 1 : 0,\n };\n });\n\n let front: ReactNode = null;\n let back: ReactNode = null;\n for (const child of Children.toArray(children)) {\n if (!isValidElement(child)) continue;\n if (child.type === FlipCardFront) front = child;\n else if (child.type === FlipCardBack) back = child;\n }\n\n const context = useMemo(\n () => ({ flipped, direction, rotation, perspective, progress, flip }),\n [direction, flip, flipped, perspective, progress, rotation]\n );\n\n /*\n * The gesture goes on the card itself rather than on a view around it.\n *\n * A wrapper is an extra box with no width of its own, and a card sized\n * `w-full` inside one is asking for a percentage of nothing — which is how\n * a two-hundred-point card ended up taller than the screen and hanging off\n * the side of it.\n *\n * The accessibility action rides here for the same reason. A drag is not\n * available to a screen reader, and a card that can only be turned by\n * dragging is a card some readers can never see the back of.\n */\n const card = (\n {\n span.value =\n direction === 'horizontal'\n ? event.nativeEvent.layout.width\n : event.nativeEvent.layout.height;\n }}\n accessible={trigger !== 'none'}\n accessibilityRole={trigger === 'none' ? undefined : 'button'}\n accessibilityState={{ expanded: flipped }}\n accessibilityActions={\n trigger === 'none' ? undefined : [{ name: 'activate', label: 'Flip the card over' }]\n }\n onAccessibilityAction={trigger === 'none' ? undefined : flip}\n {...props}\n className={root({ className })}\n >\n {/* The front is in flow and the back is laid over it, so the card is\n the front's size on both axes and the back is handed that box. */}\n \n {front}\n \n \n {back}\n \n \n );\n\n return (\n \n {/* A gesture rather than a Pressable, and no press feedback with it.\n The card already answers the touch by turning; dipping it first\n makes the turn look like it started late. */}\n {trigger === 'none' ? card : {card}}\n \n );\n }\n);\n\nexport interface FlipCardFaceProps extends ViewProps {\n className?: string;\n children?: ReactNode;\n}\n\n/** The face showing at rest. Its size is the card's size. */\nconst FlipCardFront = forwardRef(({ className, ...props }, ref) => (\n \n));\n\n/** The face revealed by the turn, laid over the front. */\nconst FlipCardBack = forwardRef(({ className, ...props }, ref) => (\n \n));\n\nFlipCardRoot.displayName = 'FlipCard';\nFlipCardFront.displayName = 'FlipCard.Front';\nFlipCardBack.displayName = 'FlipCard.Back';\n\nexport const FlipCard = Object.assign(FlipCardRoot, {\n Front: FlipCardFront,\n Back: FlipCardBack,\n});\n" + "content": "/**\n * FlipCard — two faces of one card, and a turn between them.\n *\n * ```tsx\n * \n * \n * Tap me\n * \n * \n * The other side\n * \n * \n * ```\n *\n * For content that is genuinely two-sided — a bank card and its security code,\n * a term and its definition, a photograph and what it is of. Not for\n * progressive disclosure: the back replaces the front rather than extending\n * it, so anything the reader needs to compare across the turn belongs in\n * [Collapsible](../collapsible) or a second card instead.\n *\n * ## One box, and both faces in it\n *\n * The back is laid over the front, so there is one box and the two faces fill\n * it. State the height on the root and both faces take it; leave it off and\n * the front's own content decides, with the back given whatever that came to.\n *\n * A back with more in it than the front overflows rather than growing the\n * card, and the fix is a height on the root rather than more padding on the\n * back — a card whose height changed halfway through the turn would be a card\n * that moved everything under it.\n *\n * ## Both faces are hidden the same way twice\n *\n * `backfaceVisibility` is the mechanism, and it is not reliable on every\n * Android surface — so the face that has turned away is also faded out and\n * dropped behind, on the same shared value, at the halfway point. Two\n * mechanisms for one job, because the failure of the first is a card that\n * shows both faces mirrored through each other and nothing in the tree that\n * says why.\n *\n * ## Under reduce motion\n *\n * The faces swap with no turn at all. Not a shorter turn — none. The rotation\n * is the part that moves, and moving is the part the setting is about; which\n * face is showing is the information, and it is kept.\n */\nimport {\n Children,\n createContext,\n forwardRef,\n isValidElement,\n useCallback,\n useContext,\n useEffect,\n useMemo,\n useState,\n type ReactNode,\n} from 'react';\nimport { StyleSheet, View, type ViewProps } from 'react-native';\nimport { Gesture, GestureDetector } from 'react-native-gesture-handler';\nimport Animated, {\n cancelAnimation,\n interpolate,\n runOnJS,\n useAnimatedStyle,\n useReducedMotion,\n useSharedValue,\n withSpring,\n type SharedValue,\n} from 'react-native-reanimated';\nimport { tv } from 'tailwind-variants';\nimport { cn } from '@/lib/cn';\nimport type { ViewRef } from '@/components/ui/refs';\n\n/**\n * How the card settles on a face.\n *\n * Clamped, and a little heavier than the library's other springs. A card has\n * apparent mass — it is a rectangle the size of your hand — and a turn that\n * overshoots and comes back reads as a playing card being flicked rather than\n * an object being turned over. The overshoot is also the one place the hidden\n * face can appear, since it puts the rotation past 360.\n */\nconst SPRING = {\n damping: 18,\n stiffness: 140,\n mass: 0.9,\n overshootClamping: true,\n} as const;\n\n/**\n * Distance across the card that counts as a whole turn, as a fraction of its\n * own width or height.\n *\n * Less than the full span: a drag that has crossed two thirds of the card has\n * made its point, and requiring the whole width means the last part of every\n * deliberate flip is spent travelling to an edge the finger has to reach.\n */\nconst DRAG_SPAN = 0.66;\n\n/** Velocity, in points per second, that carries a short drag over anyway. */\nconst FLICK_VELOCITY = 500;\n\nconst flipCardVariants = tv({\n slots: {\n root: 'relative',\n face: 'w-full',\n },\n});\n\n/** Which axis the card turns about. */\nexport type FlipCardDirection = 'horizontal' | 'vertical';\n\n/** Which way round it turns. */\nexport type FlipCardRotation = 'normal' | 'reverse';\n\n/** What flips the card. */\nexport type FlipCardTrigger = 'press' | 'drag' | 'none';\n\ninterface FlipCardContextValue {\n flipped: boolean;\n direction: FlipCardDirection;\n rotation: FlipCardRotation;\n perspective: number;\n progress: SharedValue;\n flip: () => void;\n}\n\nconst FlipCardContext = createContext(null);\n\nfunction useFlipCardContext(component: string) {\n const context = useContext(FlipCardContext);\n if (!context) throw new Error(`${component} must be used within a `);\n return context;\n}\n\n/**\n * The card's state, for a face that needs to know which way round it is.\n *\n * `progress` runs 0 → 1 across the turn and is a shared value, so a face can\n * drive its own animation off the same turn rather than starting a second one\n * beside it.\n */\nexport function useFlipCard() {\n const { flipped, direction, rotation, progress, flip } = useFlipCardContext('useFlipCard');\n return { flipped, direction, rotation, progress, flip };\n}\n\nexport interface FlipCardProps extends Omit {\n /** `FlipCard.Front` and `FlipCard.Back`, in either order. */\n children?: ReactNode;\n /**\n * Which axis the card turns about. `horizontal` turns it left to right about\n * its vertical axis; `vertical` turns it top over bottom.\n */\n direction?: FlipCardDirection;\n /**\n * Which way round the turn goes. `reverse` sends it the other way, for a\n * pair of cards that should not turn identically.\n */\n rotation?: FlipCardRotation;\n /**\n * Which face is showing, when the caller holds it. Leave unset to let the\n * card keep its own, and pair with `trigger=\"none\"` for a card flipped only\n * from outside.\n */\n flipped?: boolean;\n /** Which face an uncontrolled card starts on. */\n defaultFlipped?: boolean;\n /** Fires whenever the card settles on the other face, however it got there. */\n onFlippedChange?: (flipped: boolean) => void;\n /**\n * What turns the card. `press` is a tap anywhere on it; `drag` turns it with\n * the finger and springs to whichever face is nearer on release; `none`\n * leaves it to `flipped`.\n */\n trigger?: FlipCardTrigger;\n /**\n * How deep the turn looks, in points. Smaller is more dramatic — the near\n * edge swings further out — and below about 400 a full card starts to read\n * as a door rather than a card.\n */\n perspective?: number;\n /** Classes for the card's own box. The front's size is the card's size. */\n className?: string;\n}\n\nconst FlipCardRoot = forwardRef(\n (\n {\n children,\n direction = 'horizontal',\n rotation = 'normal',\n flipped: flippedProp,\n defaultFlipped = false,\n onFlippedChange,\n trigger = 'press',\n perspective = 1000,\n className,\n ...props\n },\n ref\n ) => {\n const [internal, setInternal] = useState(defaultFlipped);\n const isControlled = flippedProp !== undefined;\n const flipped = isControlled ? flippedProp : internal;\n\n const progress = useSharedValue(defaultFlipped ? 1 : 0);\n const startProgress = useSharedValue(0);\n const span = useSharedValue(0);\n const reduceMotion = useReducedMotion();\n\n const { root, face } = flipCardVariants();\n\n /*\n * The settled face is React state and the turn is a shared value, and the\n * two are kept in step from here rather than from the gesture: a drag that\n * is let go past halfway has already decided, and telling React at the\n * moment of release rather than at the end of the spring is what keeps the\n * hidden face out of the accessibility tree while it is still turning.\n */\n const settle = useCallback(\n (next: boolean) => {\n if (!isControlled) setInternal(next);\n onFlippedChange?.(next);\n },\n [isControlled, onFlippedChange]\n );\n\n useEffect(() => {\n progress.value = reduceMotion\n ? flipped\n ? 1\n : 0\n : withSpring(flipped ? 1 : 0, SPRING);\n }, [flipped, progress, reduceMotion]);\n\n useEffect(() => () => cancelAnimation(progress), [progress]);\n\n const flip = useCallback(() => settle(!flipped), [flipped, settle]);\n\n const gesture = useMemo(() => {\n const tap = Gesture.Tap()\n .enabled(trigger === 'press')\n .onEnd((_event, success) => {\n if (success) runOnJS(flip)();\n });\n\n /*\n * The span is read from the shared value rather than from a captured\n * number, so a card that is measured or resized mid-touch does not leave\n * the gesture dividing by the width it had when the finger landed.\n */\n const pan = Gesture.Pan()\n .enabled(trigger === 'drag')\n .onBegin(() => {\n startProgress.value = progress.value;\n })\n .onUpdate((event) => {\n const travel = direction === 'horizontal' ? event.translationX : event.translationY;\n const reach = Math.max(span.value * DRAG_SPAN, 1);\n const next = startProgress.value + travel / reach;\n progress.value = Math.min(Math.max(next, 0), 1);\n })\n .onEnd((event) => {\n const velocity = direction === 'horizontal' ? event.velocityX : event.velocityY;\n const committed =\n progress.value > 0.5 || (progress.value > 0.1 && velocity > FLICK_VELOCITY);\n runOnJS(settle)(committed);\n });\n\n return Gesture.Exclusive(pan, tap);\n }, [direction, flip, progress, settle, span, startProgress, trigger]);\n\n /*\n * One turn, read two ways. The front runs 0 → 180 and the back 180 → 360,\n * so at any moment exactly one of them has its face toward the reader.\n *\n * `sign` is not an RTL mirror. Turning a physical object over is a physical\n * direction, not a reading direction, and a card that spun the other way\n * in an Arabic layout would be a different card rather than the same one\n * laid out correctly.\n */\n const sign = rotation === 'reverse' ? -1 : 1;\n const axis = direction === 'horizontal' ? 'rotateY' : 'rotateX';\n\n const frontStyle = useAnimatedStyle(() => {\n const angle = interpolate(progress.value, [0, 1], [0, 180 * sign]);\n const showing = progress.value < 0.5;\n return {\n transform: [{ perspective }, { [axis]: `${angle}deg` } as never],\n opacity: showing ? 1 : 0,\n zIndex: showing ? 1 : 0,\n };\n });\n\n const backStyle = useAnimatedStyle(() => {\n const angle = interpolate(progress.value, [0, 1], [-180 * sign, 0]);\n const showing = progress.value >= 0.5;\n return {\n transform: [{ perspective }, { [axis]: `${angle}deg` } as never],\n opacity: showing ? 1 : 0,\n zIndex: showing ? 1 : 0,\n };\n });\n\n let front: ReactNode = null;\n let back: ReactNode = null;\n for (const child of Children.toArray(children)) {\n if (!isValidElement(child)) continue;\n if (child.type === FlipCardFront) front = child;\n else if (child.type === FlipCardBack) back = child;\n }\n\n const context = useMemo(\n () => ({ flipped, direction, rotation, perspective, progress, flip }),\n [direction, flip, flipped, perspective, progress, rotation]\n );\n\n /*\n * The gesture goes on the card itself rather than on a view around it.\n *\n * A wrapper is an extra box with no width of its own, and a card sized\n * `w-full` inside one is asking for a percentage of nothing — which is how\n * a two-hundred-point card ended up taller than the screen and hanging off\n * the side of it.\n *\n * The accessibility action rides here for the same reason. A drag is not\n * available to a screen reader, and a card that can only be turned by\n * dragging is a card some readers can never see the back of.\n */\n const card = (\n {\n span.value =\n direction === 'horizontal'\n ? event.nativeEvent.layout.width\n : event.nativeEvent.layout.height;\n }}\n accessible={trigger !== 'none'}\n accessibilityRole={trigger === 'none' ? undefined : 'button'}\n accessibilityState={{ expanded: flipped }}\n accessibilityActions={\n trigger === 'none' ? undefined : [{ name: 'activate', label: 'Flip the card over' }]\n }\n onAccessibilityAction={trigger === 'none' ? undefined : flip}\n {...props}\n className={root({ className })}\n >\n {/* The front is in flow and the back is laid over it, so the card is\n the front's size on both axes and the back is handed that box. */}\n \n {front}\n \n \n {back}\n \n \n );\n\n return (\n \n {/* A gesture rather than a Pressable, and no press feedback with it.\n The card already answers the touch by turning; dipping it first\n makes the turn look like it started late. */}\n {trigger === 'none' ? card : {card}}\n \n );\n }\n);\n\nexport interface FlipCardFaceProps extends ViewProps {\n className?: string;\n children?: ReactNode;\n}\n\n/** The face showing at rest. Its size is the card's size. */\nconst FlipCardFront = forwardRef(({ className, ...props }, ref) => (\n \n));\n\n/** The face revealed by the turn, laid over the front. */\nconst FlipCardBack = forwardRef(({ className, ...props }, ref) => (\n \n));\n\nFlipCardRoot.displayName = 'FlipCard';\nFlipCardFront.displayName = 'FlipCard.Front';\nFlipCardBack.displayName = 'FlipCard.Back';\n\nexport const FlipCard = Object.assign(FlipCardRoot, {\n Front: FlipCardFront,\n Back: FlipCardBack,\n});\n" } ] } diff --git a/apps/docs/public/r/frame.json b/apps/docs/public/r/frame.json index e176110ba..97f5927a7 100644 --- a/apps/docs/public/r/frame.json +++ b/apps/docs/public/r/frame.json @@ -5,6 +5,7 @@ "registryDependencies": [ "cn", "icons", + "refs", "text" ], "dependencies": [ @@ -15,7 +16,7 @@ { "path": "ui/frame.tsx", "type": "registry:ui", - "content": "/**\n * Frame — a widget shell: a card of rows sitting in a tray, with the tray's\n * one exposed strip along the top carrying the title.\n *\n * The two surfaces are nested rather than stacked, and only one edge of the\n * outer one is ever visible. The panel is flush to the shell's left, right and\n * bottom, so the shell reads as something the card is *sitting in* rather than\n * as a border around it — and the strip left at the top is the header, which\n * is why the header needs no rule under it and no background of its own.\n *\n * The shell's radius is the larger of the two, and the panel's top corners are\n * tighter. That is the reverse of the usual nested-radius rule, and it is\n * deliberate: with only the top corners free, matching them would make the two\n * surfaces read as one misdrawn shape. The panel's bottom corners are not set\n * at all — the shell clips them, so they take its radius exactly.\n *\n * That clip follows the shell's *border box*, not the box inside its border.\n * Along the straight edges the panel is held off by the border width and the\n * edge shows through, but at the corner arcs the panel's square corner is\n * clipped to the outer radius and paints across the border. At the default\n * hairline that is a sliver nobody sees. Give the shell a thicker border and\n * the corners visibly eat it, so a Frame with `border-2` or more needs the\n * panel told where to stop:\n *\n * ```tsx\n * \n * …\n * \n * ```\n *\n * The radius to use is the shell's less its border width. It is on the caller\n * because both arrive as `className` strings, which the component cannot read.\n *\n * ```tsx\n * \n * \n * Agent monitor\n * All agents under 25% token limit\n * \n * \n * \n * \n * \n * opus-4.6\n * Indexing the repository\n * \n * Running\n * \n * \n * \n * ```\n *\n * `inset` is the other way to nest the two. The panel floats clear of the\n * shell on all four sides rather than sitting flush against three, and the band\n * left around it carries `Frame.Footer`. The band is a recess: the shell is the\n * popover surface with `--color-inset` laid over it rather than a colour of its\n * own, so it always comes out darker than the panel it holds. The surface\n * ladder cannot do that job — it runs darker in a light theme and lighter in a\n * dark one, and a recess has to read the same way in both.\n *\n * There is no shadow under it. A recessed band and a drop shadow are opposite\n * claims about where a surface sits, and this one is set into the page.\n *\n * The panel draws the hairlines between its own rows. React Native has no\n * `:first-child`, so the alternative is every caller writing\n * `divided={index > 0}` on every row and getting it wrong once.\n *\n * A row is three slots, and they exist because of one React Native detail:\n * Yoga defaults `flexShrink` to `0`, the opposite of the web. A child that is\n * not told to shrink never does, so a fourth thing in a row pushes the others\n * past the edge — where the frame's `overflow-hidden` silently cuts them off\n * rather than wrapping or truncating. `Frame.Media` and `Frame.Actions` hold\n * their size, `Frame.Content` takes what is left and is allowed to shrink to\n * nothing, and the row fits at any width without the caller measuring anything.\n */\nimport {\n Children,\n cloneElement,\n createContext,\n forwardRef,\n isValidElement,\n useContext,\n type ReactNode,\n} from 'react';\nimport {\n Pressable,\n View,\n type PressableProps,\n type Text as RNText,\n type ViewProps,\n} from 'react-native';\nimport { tv } from 'tailwind-variants';\nimport { ChevronRightIcon } from '@/components/ui/icons';\nimport { Text, type TextProps, textChildren } from '@/components/ui/text';\nimport { cn } from '@/lib/cn';\n\n/**\n * The `inset` shell's geometry. Numbers rather than classes, because an\n * arbitrary Tailwind value a running dev server has not already compiled turns\n * into nothing at all — no error, no warning, the corner simply squares off.\n *\n * The panel's radius is the shell's less the shell's padding, so the two curves\n * are concentric. A panel radius that ignored the band would leave a crescent\n * of shell thicker at the corners than along the sides.\n */\nconst SHELL_PADDING = 8;\nconst SHELL_RADIUS = 38;\nconst PANEL_RADIUS = SHELL_RADIUS - SHELL_PADDING;\nconst FOOTER_INSET = 26;\nconst FOOTER_GAP = 16;\n\n/** What the band does to an action put in it: equal width, and a full pill. */\nconst FOOTER_ACTION = 'h-11 flex-1 rounded-full';\n\nconst frameVariants = tv({\n slots: {\n root: '',\n recess: 'absolute inset-0 bg-inset',\n panel: 'overflow-hidden bg-card',\n header: 'flex-row items-center justify-between gap-3 px-4 pb-3 pt-2.5',\n footer: 'flex-row items-center gap-2 px-4 pb-3.5 pt-3',\n },\n variants: {\n variant: {\n // `overflow-hidden` is load-bearing — it is what makes the panel's\n // bottom corners take the shell's radius instead of squaring off\n // against it.\n //\n // The panel is flush left, right and bottom: no side or bottom border,\n // because the shell's own edge is already there, and no bottom radius,\n // because the shell clips it. Only the top corners and the rule under\n // the header are the panel's to draw.\n default: {\n root: 'overflow-hidden rounded-3xl border border-border bg-surface',\n panel: 'rounded-t-2xl border-t border-border',\n },\n // No shell: the panel is the whole widget. For a Frame nested inside a\n // card that already draws a border, where the shell's own edge sitting\n // just inside it reads as a double line.\n plain: {\n root: '',\n panel: 'rounded-3xl border border-border',\n // Nothing to be held in from — the footer lines up with the panel.\n footer: 'px-0',\n },\n // The panel floats inside the shell on all four sides instead of sitting\n // flush against three of them, and the band around it is a recess rather\n // than a lighter tray.\n inset: {\n root: 'overflow-hidden bg-popover',\n panel: 'bg-popover',\n header: 'px-3 pb-2.5 pt-1',\n footer: 'gap-3.5 p-0',\n },\n },\n },\n defaultVariants: {\n variant: 'default',\n },\n});\n\nexport type FrameVariant = 'default' | 'plain' | 'inset';\n\n/**\n * True inside a `Frame.Content`. The header's caption and a row's title are the\n * same part in two places and only the placement decides the weight: a header\n * labels the tray the card sits in, so it stays quiet, while a row title is the\n * row's subject and has to carry it.\n */\nconst FrameSlotContext = createContext(false);\n\n/**\n * The root's variant, for the parts that draw differently under each one. The\n * panel, the header and the footer all need it, and none of them can be told\n * directly — a caller writes `` once and expects the\n * shape to follow.\n */\nconst FrameVariantContext = createContext('default');\n\nexport interface FrameProps extends ViewProps {\n className?: string;\n}\n\nexport interface FrameRootProps extends FrameProps {\n /**\n * `plain` drops the outer shell so the panel is the widget — for a Frame\n * inside a container that already draws its own border. `inset` sets the\n * panel into a recessed band on all four sides instead, and gives\n * `Frame.Footer` somewhere to sit.\n */\n variant?: FrameVariant;\n}\n\nconst FrameRoot = forwardRef(\n ({ className, variant = 'default', children, style, ...props }, ref) => {\n const slots = frameVariants({ variant });\n const inset = variant === 'inset';\n return (\n \n {inset ? : null}\n \n {children}\n \n \n );\n }\n);\nFrameRoot.displayName = 'Frame';\n\nexport interface FrameHeaderProps extends FrameProps {\n children?: ReactNode;\n}\n\n/**\n * The header row — the strip of shell left exposed above the panel. Title on\n * the left, `Frame.Action` on the right. Add `className=\"flex-col items-start\"`\n * when you want a description underneath.\n */\nconst FrameHeader = forwardRef(\n ({ className, ...props }, ref) => {\n // `min-w-0` on nothing here — the title itself takes the flexible side, so\n // a long one truncates instead of shoving the action off the edge.\n //\n // The padding follows the variant: under `inset` the shell already holds\n // everything in by its own padding, so repeating the full inset here would\n // set the title further from the edge than the panel below it.\n const variant = useContext(FrameVariantContext);\n return (\n \n );\n }\n);\nFrameHeader.displayName = 'Frame.Header';\n\n/**\n * Muted in a header, where it is a caption on the tray the card sits in. Inside\n * a `Frame.Content` it is the row's subject instead, so it takes the foreground\n * colour and medium weight, and truncates to one line rather than pushing the\n * row's trailing slot out of view. Pass `numberOfLines` to override either way.\n */\nconst FrameTitle = forwardRef(({ className, ...props }, ref) => {\n const inRow = useContext(FrameSlotContext);\n\n if (inRow) {\n return (\n \n );\n }\n\n return (\n \n );\n});\nFrameTitle.displayName = 'Frame.Title';\n\nexport interface FrameActionProps extends FrameProps {\n children?: ReactNode;\n}\n\n/**\n * Trailing slot on the header row — a column label, a count, a button, a badge.\n * Plain strings render as muted text; anything else renders as-is.\n */\nconst FrameAction = forwardRef(\n ({ className, children, ...props }, ref) => (\n \n {textChildren(children, (text) => (\n \n {text}\n \n ))}\n \n )\n);\nFrameAction.displayName = 'Frame.Action';\n\n/**\n * Secondary line under the title — in a column-wrapped header, or under a row's\n * title inside `Frame.Content`, where it drops a size and wraps to two lines.\n */\nconst FrameDescription = forwardRef(\n ({ className, ...props }, ref) => {\n const inRow = useContext(FrameSlotContext);\n\n return (\n \n );\n }\n);\nFrameDescription.displayName = 'Frame.Description';\n\nexport interface FrameMediaProps extends FrameProps {\n children?: ReactNode;\n}\n\n/**\n * Leading slot on a row — an icon, an avatar, a status dot. Holds its size, so\n * whatever it holds is never squeezed by the text beside it.\n */\nconst FrameMedia = forwardRef(\n ({ className, ...props }, ref) => (\n \n )\n);\nFrameMedia.displayName = 'Frame.Media';\n\nexport interface FrameContentProps extends FrameProps {\n children?: ReactNode;\n}\n\n/**\n * The flexible middle of a row — usually a `Frame.Title` over a\n * `Frame.Description`. It takes whatever the media and actions leave and is\n * allowed to shrink past its content, which is what keeps a long line from\n * pushing the rest of the row off the edge.\n */\nconst FrameContent = forwardRef(\n ({ className, children, ...props }, ref) => (\n // `min-w-0` is the whole trick: a flex child's minimum size is its content\n // unless told otherwise, so `flex-1` alone still refuses to go narrower\n // than the longest word in it.\n \n {children}\n \n )\n);\nFrameContent.displayName = 'Frame.Content';\n\nexport interface FrameActionsProps extends FrameProps {\n children?: ReactNode;\n}\n\n/**\n * Trailing slot on a row — a chip, a value, a switch, a small button. Holds its\n * size, so it stays readable however long the content beside it runs.\n */\nconst FrameActions = forwardRef(\n ({ className, ...props }, ref) => (\n \n )\n);\nFrameActions.displayName = 'Frame.Actions';\n\n/**\n * Marks the parts that take part in the panel's own divider bookkeeping —\n * a Row draws a line above itself, a Section draws one above its heading.\n * Anything else the panel is given is left alone.\n */\ninterface Dividable {\n divided?: boolean;\n}\n\n/**\n * Draws the hairline above every child but the first. An explicit `divided`\n * on a child wins, so a row can still opt out or force one.\n */\nfunction divideChildren(children: ReactNode) {\n let seen = 0;\n return Children.map(children, (child) => {\n if (!isValidElement(child)) return child;\n if (!DIVIDABLE.has(child.type)) return child;\n\n const index = seen++;\n if (child.props.divided !== undefined) return child;\n return cloneElement(child, { divided: index > 0 });\n });\n}\n\nexport interface FramePanelProps extends FrameProps {\n /**\n * Set false to place the hairlines by hand instead — for a panel whose rows\n * are generated somewhere the divider order is not obvious.\n */\n dividers?: boolean;\n children?: ReactNode;\n}\n\n/**\n * The card holding the frame's content — flush to the shell on three sides,\n * with the header strip above it. Under `inset` it floats clear of all four\n * instead, at the radius that keeps it concentric with the shell.\n */\nconst FramePanel = forwardRef(\n ({ className, dividers = true, children, style, ...props }, ref) => {\n const variant = useContext(FrameVariantContext);\n return (\n \n {dividers ? divideChildren(children) : children}\n \n );\n }\n);\nFramePanel.displayName = 'Frame.Panel';\n\nexport interface FrameRowProps extends Omit, Dividable {\n className?: string;\n /**\n * Draw a hairline above this row. `Frame.Panel` sets it for you; pass it\n * explicitly to override the panel's decision either way.\n */\n divided?: boolean;\n /** Trailing chevron marking the row as leading somewhere. */\n chevron?: boolean;\n /**\n * Let the row run onto a second line instead of holding one. For a cluster\n * of chips or tags, where the alternative is the last ones being clipped.\n */\n wrap?: boolean;\n /**\n * Where the row's slots sit against each other. `start` for a row two or\n * three lines tall, where centring an icon against a tall text column leaves\n * it floating in the middle.\n */\n align?: 'center' | 'start';\n children?: ReactNode;\n}\n\n/**\n * A row inside a Frame.Panel. Give it an `onPress` and it becomes a real\n * pressable — press feedback, a button role — rather than a View with a\n * handler bolted on.\n */\nconst FrameRow = forwardRef(\n (\n { className, divided, chevron, wrap, align = 'center', children, onPress, ...props },\n ref\n ) => {\n const classes = cn(\n 'flex-row gap-3 px-4 py-3.5',\n align === 'start' ? 'items-start' : 'items-center',\n wrap && 'flex-wrap',\n divided && 'border-t border-border',\n onPress && 'active:bg-muted',\n className\n );\n\n const body = (\n <>\n {textChildren(children)}\n {chevron ? : null}\n \n );\n\n if (!onPress) {\n return (\n \n {body}\n \n );\n }\n\n return (\n \n {body}\n \n );\n }\n);\nFrameRow.displayName = 'Frame.Row';\n\nexport interface FrameSectionProps extends FrameProps, Dividable {\n /** Heading above the rows. Strings are wrapped for you. */\n title?: ReactNode;\n divided?: boolean;\n children?: ReactNode;\n}\n\n/**\n * A labelled cluster of rows inside a Panel, for a widget holding more than\n * one group. It divides its own rows the way the panel does, so the two nest\n * without either having to know about the other.\n */\nconst FrameSection = forwardRef(\n ({ className, title, divided, children, ...props }, ref) => (\n \n {title ? (\n \n {typeof title === 'string' ? (\n \n {title}\n \n ) : (\n title\n )}\n \n ) : null}\n {divideChildren(children)}\n \n )\n);\nFrameSection.displayName = 'Frame.Section';\n\nexport interface FrameFooterProps extends FrameProps {\n children?: ReactNode;\n}\n\n/**\n * Shapes an `inset` footer's actions into the band's pills.\n *\n * The band is a row of equal decisions, so the actions are equal widths and\n * full circles rather than whatever radius each one arrived with. Doing it\n * here rather than asking every caller for three classes is the difference\n * between a variant that looks a certain way and one that can be made to.\n *\n * The caller's own `className` is merged last, so any of it can still be\n * overridden — a trailing icon button that should stay square, say.\n */\nfunction pillChildren(children: ReactNode) {\n return Children.map(children, (child) => {\n if (!isValidElement<{ className?: string }>(child)) return child;\n return cloneElement(child, {\n className: cn(FOOTER_ACTION, child.props.className),\n });\n });\n}\n\n/**\n * The row of actions under the panel — what somebody does with the widget,\n * rather than more of what it says.\n *\n * Under `inset` it sits in the band, held further in than the panel is. A row\n * running the full width of the shell reads as another edge of it rather than\n * as things to press. Under the other two variants the panel stops being flush\n * at the bottom, which is what having a footer means there.\n */\nconst FrameFooter = forwardRef(\n ({ className, style, children, ...props }, ref) => {\n const variant = useContext(FrameVariantContext);\n const inset = variant === 'inset';\n return (\n \n {inset ? pillChildren(children) : children}\n \n );\n }\n);\nFrameFooter.displayName = 'Frame.Footer';\n\n/** Parts the panel divides. Declared after them, since it holds references. */\nconst DIVIDABLE = new Set([FrameRow, FrameSection]);\n\nexport const Frame = Object.assign(FrameRoot, {\n Header: FrameHeader,\n Title: FrameTitle,\n Action: FrameAction,\n Description: FrameDescription,\n Panel: FramePanel,\n Footer: FrameFooter,\n Section: FrameSection,\n Row: FrameRow,\n Media: FrameMedia,\n Content: FrameContent,\n Actions: FrameActions,\n});\n" + "content": "/**\n * Frame — a widget shell: a card of rows sitting in a tray, with the tray's\n * one exposed strip along the top carrying the title.\n *\n * The two surfaces are nested rather than stacked, and only one edge of the\n * outer one is ever visible. The panel is flush to the shell's left, right and\n * bottom, so the shell reads as something the card is *sitting in* rather than\n * as a border around it — and the strip left at the top is the header, which\n * is why the header needs no rule under it and no background of its own.\n *\n * The shell's radius is the larger of the two, and the panel's top corners are\n * tighter. That is the reverse of the usual nested-radius rule, and it is\n * deliberate: with only the top corners free, matching them would make the two\n * surfaces read as one misdrawn shape. The panel's bottom corners are not set\n * at all — the shell clips them, so they take its radius exactly.\n *\n * That clip follows the shell's *border box*, not the box inside its border.\n * Along the straight edges the panel is held off by the border width and the\n * edge shows through, but at the corner arcs the panel's square corner is\n * clipped to the outer radius and paints across the border. At the default\n * hairline that is a sliver nobody sees. Give the shell a thicker border and\n * the corners visibly eat it, so a Frame with `border-2` or more needs the\n * panel told where to stop:\n *\n * ```tsx\n * \n * …\n * \n * ```\n *\n * The radius to use is the shell's less its border width. It is on the caller\n * because both arrive as `className` strings, which the component cannot read.\n *\n * ```tsx\n * \n * \n * Agent monitor\n * All agents under 25% token limit\n * \n * \n * \n * \n * \n * opus-4.6\n * Indexing the repository\n * \n * Running\n * \n * \n * \n * ```\n *\n * `inset` is the other way to nest the two. The panel floats clear of the\n * shell on all four sides rather than sitting flush against three, and the band\n * left around it carries `Frame.Footer`. The band is a recess: the shell is the\n * popover surface with `--color-inset` laid over it rather than a colour of its\n * own, so it always comes out darker than the panel it holds. The surface\n * ladder cannot do that job — it runs darker in a light theme and lighter in a\n * dark one, and a recess has to read the same way in both.\n *\n * There is no shadow under it. A recessed band and a drop shadow are opposite\n * claims about where a surface sits, and this one is set into the page.\n *\n * The panel draws the hairlines between its own rows. React Native has no\n * `:first-child`, so the alternative is every caller writing\n * `divided={index > 0}` on every row and getting it wrong once.\n *\n * A row is three slots, and they exist because of one React Native detail:\n * Yoga defaults `flexShrink` to `0`, the opposite of the web. A child that is\n * not told to shrink never does, so a fourth thing in a row pushes the others\n * past the edge — where the frame's `overflow-hidden` silently cuts them off\n * rather than wrapping or truncating. `Frame.Media` and `Frame.Actions` hold\n * their size, `Frame.Content` takes what is left and is allowed to shrink to\n * nothing, and the row fits at any width without the caller measuring anything.\n */\nimport {\n Children,\n cloneElement,\n createContext,\n forwardRef,\n isValidElement,\n useContext,\n type ReactNode,\n} from 'react';\nimport {\n Pressable,\n View,\n type PressableProps,\n type Text as RNText,\n type ViewProps,\n} from 'react-native';\nimport { tv } from 'tailwind-variants';\nimport { ChevronRightIcon } from '@/components/ui/icons';\nimport { Text, type TextProps, textChildren } from '@/components/ui/text';\nimport { cn } from '@/lib/cn';\nimport type { TextRef, ViewRef } from '@/components/ui/refs';\n\n/**\n * The `inset` shell's geometry. Numbers rather than classes, because an\n * arbitrary Tailwind value a running dev server has not already compiled turns\n * into nothing at all — no error, no warning, the corner simply squares off.\n *\n * The panel's radius is the shell's less the shell's padding, so the two curves\n * are concentric. A panel radius that ignored the band would leave a crescent\n * of shell thicker at the corners than along the sides.\n */\nconst SHELL_PADDING = 8;\nconst SHELL_RADIUS = 38;\nconst PANEL_RADIUS = SHELL_RADIUS - SHELL_PADDING;\nconst FOOTER_INSET = 26;\nconst FOOTER_GAP = 16;\n\n/** What the band does to an action put in it: equal width, and a full pill. */\nconst FOOTER_ACTION = 'h-11 flex-1 rounded-full';\n\nconst frameVariants = tv({\n slots: {\n root: '',\n recess: 'absolute inset-0 bg-inset',\n panel: 'overflow-hidden bg-card',\n header: 'flex-row items-center justify-between gap-3 px-4 pb-3 pt-2.5',\n footer: 'flex-row items-center gap-2 px-4 pb-3.5 pt-3',\n },\n variants: {\n variant: {\n // `overflow-hidden` is load-bearing — it is what makes the panel's\n // bottom corners take the shell's radius instead of squaring off\n // against it.\n //\n // The panel is flush left, right and bottom: no side or bottom border,\n // because the shell's own edge is already there, and no bottom radius,\n // because the shell clips it. Only the top corners and the rule under\n // the header are the panel's to draw.\n default: {\n root: 'overflow-hidden rounded-3xl border border-border bg-surface',\n panel: 'rounded-t-2xl border-t border-border',\n },\n // No shell: the panel is the whole widget. For a Frame nested inside a\n // card that already draws a border, where the shell's own edge sitting\n // just inside it reads as a double line.\n plain: {\n root: '',\n panel: 'rounded-3xl border border-border',\n // Nothing to be held in from — the footer lines up with the panel.\n footer: 'px-0',\n },\n // The panel floats inside the shell on all four sides instead of sitting\n // flush against three of them, and the band around it is a recess rather\n // than a lighter tray.\n inset: {\n root: 'overflow-hidden bg-popover',\n panel: 'bg-popover',\n header: 'px-3 pb-2.5 pt-1',\n footer: 'gap-3.5 p-0',\n },\n },\n },\n defaultVariants: {\n variant: 'default',\n },\n});\n\nexport type FrameVariant = 'default' | 'plain' | 'inset';\n\n/**\n * True inside a `Frame.Content`. The header's caption and a row's title are the\n * same part in two places and only the placement decides the weight: a header\n * labels the tray the card sits in, so it stays quiet, while a row title is the\n * row's subject and has to carry it.\n */\nconst FrameSlotContext = createContext(false);\n\n/**\n * The root's variant, for the parts that draw differently under each one. The\n * panel, the header and the footer all need it, and none of them can be told\n * directly — a caller writes `` once and expects the\n * shape to follow.\n */\nconst FrameVariantContext = createContext('default');\n\nexport interface FrameProps extends ViewProps {\n className?: string;\n}\n\nexport interface FrameRootProps extends FrameProps {\n /**\n * `plain` drops the outer shell so the panel is the widget — for a Frame\n * inside a container that already draws its own border. `inset` sets the\n * panel into a recessed band on all four sides instead, and gives\n * `Frame.Footer` somewhere to sit.\n */\n variant?: FrameVariant;\n}\n\nconst FrameRoot = forwardRef(\n ({ className, variant = 'default', children, style, ...props }, ref) => {\n const slots = frameVariants({ variant });\n const inset = variant === 'inset';\n return (\n \n {inset ? : null}\n \n {children}\n \n \n );\n }\n);\nFrameRoot.displayName = 'Frame';\n\nexport interface FrameHeaderProps extends FrameProps {\n children?: ReactNode;\n}\n\n/**\n * The header row — the strip of shell left exposed above the panel. Title on\n * the left, `Frame.Action` on the right. Add `className=\"flex-col items-start\"`\n * when you want a description underneath.\n */\nconst FrameHeader = forwardRef(\n ({ className, ...props }, ref) => {\n // `min-w-0` on nothing here — the title itself takes the flexible side, so\n // a long one truncates instead of shoving the action off the edge.\n //\n // The padding follows the variant: under `inset` the shell already holds\n // everything in by its own padding, so repeating the full inset here would\n // set the title further from the edge than the panel below it.\n const variant = useContext(FrameVariantContext);\n return (\n \n );\n }\n);\nFrameHeader.displayName = 'Frame.Header';\n\n/**\n * Muted in a header, where it is a caption on the tray the card sits in. Inside\n * a `Frame.Content` it is the row's subject instead, so it takes the foreground\n * colour and medium weight, and truncates to one line rather than pushing the\n * row's trailing slot out of view. Pass `numberOfLines` to override either way.\n */\nconst FrameTitle = forwardRef(({ className, ...props }, ref) => {\n const inRow = useContext(FrameSlotContext);\n\n if (inRow) {\n return (\n \n );\n }\n\n return (\n \n );\n});\nFrameTitle.displayName = 'Frame.Title';\n\nexport interface FrameActionProps extends FrameProps {\n children?: ReactNode;\n}\n\n/**\n * Trailing slot on the header row — a column label, a count, a button, a badge.\n * Plain strings render as muted text; anything else renders as-is.\n */\nconst FrameAction = forwardRef(\n ({ className, children, ...props }, ref) => (\n \n {textChildren(children, (text) => (\n \n {text}\n \n ))}\n \n )\n);\nFrameAction.displayName = 'Frame.Action';\n\n/**\n * Secondary line under the title — in a column-wrapped header, or under a row's\n * title inside `Frame.Content`, where it drops a size and wraps to two lines.\n */\nconst FrameDescription = forwardRef(\n ({ className, ...props }, ref) => {\n const inRow = useContext(FrameSlotContext);\n\n return (\n \n );\n }\n);\nFrameDescription.displayName = 'Frame.Description';\n\nexport interface FrameMediaProps extends FrameProps {\n children?: ReactNode;\n}\n\n/**\n * Leading slot on a row — an icon, an avatar, a status dot. Holds its size, so\n * whatever it holds is never squeezed by the text beside it.\n */\nconst FrameMedia = forwardRef(\n ({ className, ...props }, ref) => (\n \n )\n);\nFrameMedia.displayName = 'Frame.Media';\n\nexport interface FrameContentProps extends FrameProps {\n children?: ReactNode;\n}\n\n/**\n * The flexible middle of a row — usually a `Frame.Title` over a\n * `Frame.Description`. It takes whatever the media and actions leave and is\n * allowed to shrink past its content, which is what keeps a long line from\n * pushing the rest of the row off the edge.\n */\nconst FrameContent = forwardRef(\n ({ className, children, ...props }, ref) => (\n // `min-w-0` is the whole trick: a flex child's minimum size is its content\n // unless told otherwise, so `flex-1` alone still refuses to go narrower\n // than the longest word in it.\n \n {children}\n \n )\n);\nFrameContent.displayName = 'Frame.Content';\n\nexport interface FrameActionsProps extends FrameProps {\n children?: ReactNode;\n}\n\n/**\n * Trailing slot on a row — a chip, a value, a switch, a small button. Holds its\n * size, so it stays readable however long the content beside it runs.\n */\nconst FrameActions = forwardRef(\n ({ className, ...props }, ref) => (\n \n )\n);\nFrameActions.displayName = 'Frame.Actions';\n\n/**\n * Marks the parts that take part in the panel's own divider bookkeeping —\n * a Row draws a line above itself, a Section draws one above its heading.\n * Anything else the panel is given is left alone.\n */\ninterface Dividable {\n divided?: boolean;\n}\n\n/**\n * Draws the hairline above every child but the first. An explicit `divided`\n * on a child wins, so a row can still opt out or force one.\n */\nfunction divideChildren(children: ReactNode) {\n let seen = 0;\n return Children.map(children, (child) => {\n if (!isValidElement(child)) return child;\n if (!DIVIDABLE.has(child.type)) return child;\n\n const index = seen++;\n if (child.props.divided !== undefined) return child;\n return cloneElement(child, { divided: index > 0 });\n });\n}\n\nexport interface FramePanelProps extends FrameProps {\n /**\n * Set false to place the hairlines by hand instead — for a panel whose rows\n * are generated somewhere the divider order is not obvious.\n */\n dividers?: boolean;\n children?: ReactNode;\n}\n\n/**\n * The card holding the frame's content — flush to the shell on three sides,\n * with the header strip above it. Under `inset` it floats clear of all four\n * instead, at the radius that keeps it concentric with the shell.\n */\nconst FramePanel = forwardRef(\n ({ className, dividers = true, children, style, ...props }, ref) => {\n const variant = useContext(FrameVariantContext);\n return (\n \n {dividers ? divideChildren(children) : children}\n \n );\n }\n);\nFramePanel.displayName = 'Frame.Panel';\n\nexport interface FrameRowProps extends Omit, Dividable {\n className?: string;\n /**\n * Draw a hairline above this row. `Frame.Panel` sets it for you; pass it\n * explicitly to override the panel's decision either way.\n */\n divided?: boolean;\n /** Trailing chevron marking the row as leading somewhere. */\n chevron?: boolean;\n /**\n * Let the row run onto a second line instead of holding one. For a cluster\n * of chips or tags, where the alternative is the last ones being clipped.\n */\n wrap?: boolean;\n /**\n * Where the row's slots sit against each other. `start` for a row two or\n * three lines tall, where centring an icon against a tall text column leaves\n * it floating in the middle.\n */\n align?: 'center' | 'start';\n children?: ReactNode;\n}\n\n/**\n * A row inside a Frame.Panel. Give it an `onPress` and it becomes a real\n * pressable — press feedback, a button role — rather than a View with a\n * handler bolted on.\n */\nconst FrameRow = forwardRef(\n (\n { className, divided, chevron, wrap, align = 'center', children, onPress, ...props },\n ref\n ) => {\n const classes = cn(\n 'flex-row gap-3 px-4 py-3.5',\n align === 'start' ? 'items-start' : 'items-center',\n wrap && 'flex-wrap',\n divided && 'border-t border-border',\n onPress && 'active:bg-muted',\n className\n );\n\n const body = (\n <>\n {textChildren(children)}\n {chevron ? : null}\n \n );\n\n if (!onPress) {\n return (\n \n {body}\n \n );\n }\n\n return (\n \n {body}\n \n );\n }\n);\nFrameRow.displayName = 'Frame.Row';\n\nexport interface FrameSectionProps extends FrameProps, Dividable {\n /** Heading above the rows. Strings are wrapped for you. */\n title?: ReactNode;\n divided?: boolean;\n children?: ReactNode;\n}\n\n/**\n * A labelled cluster of rows inside a Panel, for a widget holding more than\n * one group. It divides its own rows the way the panel does, so the two nest\n * without either having to know about the other.\n */\nconst FrameSection = forwardRef(\n ({ className, title, divided, children, ...props }, ref) => (\n \n {title ? (\n \n {typeof title === 'string' ? (\n \n {title}\n \n ) : (\n title\n )}\n \n ) : null}\n {divideChildren(children)}\n \n )\n);\nFrameSection.displayName = 'Frame.Section';\n\nexport interface FrameFooterProps extends FrameProps {\n children?: ReactNode;\n}\n\n/**\n * Shapes an `inset` footer's actions into the band's pills.\n *\n * The band is a row of equal decisions, so the actions are equal widths and\n * full circles rather than whatever radius each one arrived with. Doing it\n * here rather than asking every caller for three classes is the difference\n * between a variant that looks a certain way and one that can be made to.\n *\n * The caller's own `className` is merged last, so any of it can still be\n * overridden — a trailing icon button that should stay square, say.\n */\nfunction pillChildren(children: ReactNode) {\n return Children.map(children, (child) => {\n if (!isValidElement<{ className?: string }>(child)) return child;\n return cloneElement(child, {\n className: cn(FOOTER_ACTION, child.props.className),\n });\n });\n}\n\n/**\n * The row of actions under the panel — what somebody does with the widget,\n * rather than more of what it says.\n *\n * Under `inset` it sits in the band, held further in than the panel is. A row\n * running the full width of the shell reads as another edge of it rather than\n * as things to press. Under the other two variants the panel stops being flush\n * at the bottom, which is what having a footer means there.\n */\nconst FrameFooter = forwardRef(\n ({ className, style, children, ...props }, ref) => {\n const variant = useContext(FrameVariantContext);\n const inset = variant === 'inset';\n return (\n \n {inset ? pillChildren(children) : children}\n \n );\n }\n);\nFrameFooter.displayName = 'Frame.Footer';\n\n/** Parts the panel divides. Declared after them, since it holds references. */\nconst DIVIDABLE = new Set([FrameRow, FrameSection]);\n\nexport const Frame = Object.assign(FrameRoot, {\n Header: FrameHeader,\n Title: FrameTitle,\n Action: FrameAction,\n Description: FrameDescription,\n Panel: FramePanel,\n Footer: FrameFooter,\n Section: FrameSection,\n Row: FrameRow,\n Media: FrameMedia,\n Content: FrameContent,\n Actions: FrameActions,\n});\n" } ] } diff --git a/apps/docs/public/r/glass.json b/apps/docs/public/r/glass.json index 5034a118e..843e865c0 100644 --- a/apps/docs/public/r/glass.json +++ b/apps/docs/public/r/glass.json @@ -4,6 +4,7 @@ "description": "The iOS system material, drawn where it exists and a solid surface everywhere else.", "registryDependencies": [ "cn", + "refs", "scrim", "use-theme" ], @@ -15,7 +16,7 @@ { "path": "ui/glass.tsx", "type": "registry:ui", - "content": "/**\n * Glass — the material iOS draws its own floating controls in.\n *\n * A control that floats over content needs to read as *over* it, and a flat\n * fill cannot say that: it either hides what is behind it or disappears into\n * it. The system material does both jobs at once — it refracts what is behind,\n * lifts its own edge, and stays legible over anything.\n *\n * ```tsx\n * \n * Over whatever is behind it.\n * \n * ```\n *\n * ## Where it is real, and where it is not\n *\n * The material exists on iOS 26 and above. Below that, on Android, on web, and\n * for anyone who has switched Reduce Transparency on, this draws\n * `fallbackClassName` instead — a solid token surface. That is deliberate\n * rather than a gap: a hand-drawn approximation of a system material is a\n * near-miss on the one platform that has the real thing, and on Android it is\n * an iOS look pasted onto a platform that never asked for it.\n *\n * So `Glass` is not a promise that the screen will be glass. It is a promise\n * that the container will be drawn correctly either way, and callers should\n * pick a `fallbackClassName` that stands on its own.\n *\n * ## The material is a layer, not the box\n *\n * The native view is rendered as a non-interactive layer filling this\n * container, with the content above it, for the same reason `Scrim` layers a\n * blur: it keeps every layout class, every token and every touch target on an\n * ordinary `View` that behaves the way the rest of the library does.\n *\n * `radius` is a number rather than a class because the material has to round\n * its own corners — clipping a square one to a rounded parent throws away the\n * lit edge that makes it read as glass.\n *\n * `interactive` is the one exception to the layer: the platform only animates\n * the glass under a touch it can see, so with it on the material is the box\n * and the children are hosted inside it, in normal flow. Reach for it when\n * the glass *is* the button.\n *\n * ## Do not fade it\n *\n * Setting `opacity` to `0` on the material or on anything above it stops it\n * rendering at all, and it does not come back when the opacity does. Move it,\n * or unmount it; never animate it out.\n */\nimport { forwardRef, type ComponentType, type ReactNode } from 'react';\nimport { Platform, StyleSheet, View, type StyleProp, type ViewProps, type ViewStyle } from 'react-native';\nimport { useThemeMode } from '@/hooks/use-theme';\nimport { cn } from '@/lib/cn';\nimport { useReduceTransparency } from '@/components/ui/scrim';\n\n/** How the material treats what is behind it. */\nexport type GlassVariant = 'regular' | 'clear';\n\n/**\n * Corner radius in points — one number for all four, or a top and a bottom.\n *\n * The two-sided form is for a surface with an edge that is not a real edge: a\n * sheet docked to the bottom of the screen rounds its top and leaves its\n * bottom square, because the screen edge is where it ends.\n */\nexport type GlassRadius = number | { top?: number; bottom?: number };\n\nfunction shapeOf(radius: GlassRadius | undefined) {\n if (radius === undefined) return null;\n if (typeof radius === 'number') return { borderRadius: radius };\n return {\n borderTopLeftRadius: radius.top ?? 0,\n borderTopRightRadius: radius.top ?? 0,\n borderBottomLeftRadius: radius.bottom ?? 0,\n borderBottomRightRadius: radius.bottom ?? 0,\n };\n}\n\ninterface GlassViewProps extends ViewProps {\n glassEffectStyle?: GlassVariant | 'none';\n tintColor?: string;\n isInteractive?: boolean;\n colorScheme?: 'auto' | 'light' | 'dark';\n style?: StyleProp;\n children?: ReactNode;\n}\n\ninterface GlassContainerViewProps extends ViewProps {\n spacing?: number;\n ref?: React.Ref;\n}\n\ninterface GlassModule {\n GlassView: ComponentType;\n GlassContainer: ComponentType;\n}\n\n/**\n * `expo-glass-effect`, or null when the material cannot be drawn here.\n *\n * Resolved once at module load, behind three gates that all have to pass. The\n * package is optional, so the require can fail; the API is missing from some\n * iOS 26 builds, and reaching for it there crashes; and the design itself is\n * only present when the app was compiled for it. Asking all three once is\n * cheaper than a try/catch on every render, and there is no answer that can\n * change while the process is alive.\n */\nconst glassModule: GlassModule | null = (() => {\n if (Platform.OS !== 'ios') return null;\n try {\n // eslint-disable-next-line @typescript-eslint/no-require-imports\n const mod = require('expo-glass-effect');\n if (typeof mod?.isGlassEffectAPIAvailable === 'function' && !mod.isGlassEffectAPIAvailable()) {\n return null;\n }\n if (typeof mod?.isLiquidGlassAvailable === 'function' && !mod.isLiquidGlassAvailable()) {\n return null;\n }\n return mod?.GlassView ? (mod as GlassModule) : null;\n } catch {\n return null;\n }\n})();\n\nconst GlassView = glassModule?.GlassView ?? null;\nconst GlassContainerView = glassModule?.GlassContainer ?? null;\n\n/**\n * True when the real material can be drawn — for a caller that wants to know\n * before it commits to a look. It says nothing about Reduce Transparency, which\n * is a live preference and belongs to the hook.\n */\nexport const hasGlass = GlassView !== null;\n\n/**\n * Whether the material will actually be drawn right now: the API is present\n * *and* the user has not switched Reduce Transparency on.\n *\n * For a component that changes shape around the material — dropping a fill, a\n * border or a shadow the glass replaces — so that it makes the same decision\n * `Glass` makes and never strips the fill while leaving nothing behind it.\n */\nexport function useGlassMaterial(): boolean {\n const reduceTransparency = useReduceTransparency();\n // Not knowing yet counts as \"do not draw it\": the material arriving a frame\n // late is invisible, and one flashing at somebody who opted out is not.\n return hasGlass && reduceTransparency === false;\n}\n\nexport interface GlassProps extends ViewProps {\n /**\n * How much of what is behind shows through. `regular` is the everyday\n * material; `clear` is thinner, for something over its own artwork.\n */\n variant?: GlassVariant;\n /** Tints the material. Takes a colour, not a token name. */\n tint?: string;\n /**\n * Corner radius in points. The material rounds itself to this, so give it\n * the same shape the container is drawn with — clipping a square material\n * to a rounded parent throws away the lit edge that makes it read as glass.\n */\n radius?: GlassRadius;\n /**\n * Let the material answer touch the way the platform's own controls do:\n * it brightens and swells under the finger and the highlight follows it.\n *\n * For a material that *is* a button. The platform only tracks touches that\n * land inside the glass view, so with this on the content is hosted inside\n * the material rather than above it — a pressable written as a child still\n * gets its press, and the glass reacts to the same touch.\n */\n interactive?: boolean;\n /** Applied only when the material cannot be drawn. Give it a real surface. */\n fallbackClassName?: string;\n className?: string;\n}\n\nexport function Glass({\n variant = 'regular',\n tint,\n radius,\n interactive = false,\n fallbackClassName = 'bg-card',\n className,\n children,\n style,\n ...props\n}: GlassProps) {\n /*\n * Which appearance the material is drawn in, from the app's theme rather\n * than the phone's.\n *\n * The material's own default follows the system, so an app running a dark\n * theme on a phone set to light draws light glass over dark content — and a\n * theme changed at runtime leaves it where it was, because the system\n * appearance never moved.\n */\n const { mode } = useThemeMode();\n const material = useGlassMaterial();\n const shape = shapeOf(radius);\n\n /*\n * Interactive, the material is the box rather than a layer in it. The\n * platform only tracks a touch that lands inside the glass view, so the\n * content has to be hosted in it — and hosted in normal flow, so that a\n * box sized by its content still is. A layer pinned to the box's edges\n * could not size it, and a button as wide as its label would collapse to\n * its minimum. The classes go on a view inside, because the native view is\n * not a styled one; only the positional style stays on the outside.\n */\n if (material && GlassView && interactive) {\n return (\n \n {children}\n \n );\n }\n\n return (\n \n {material && GlassView ? (\n \n ) : null}\n {children}\n \n );\n}\n\nGlass.displayName = 'Glass';\n\nexport interface GlassContainerProps extends ViewProps {\n /**\n * How close two pieces of glass have to be before they merge, in points.\n * Inside it their edges flow into one another; a piece moving past\n * another blends with it and pulls free as it leaves.\n */\n spacing?: number;\n className?: string;\n}\n\n/**\n * Lets the glass inside it merge.\n *\n * On its own each piece of the material is a separate object with its own\n * lit edge. Inside a container, pieces within `spacing` of each other flow\n * together — which is what makes a button that opens into other buttons look\n * like one thing dividing rather than several things arriving. A plain view\n * wherever the material is not drawn, so it can be written unconditionally.\n */\nexport const GlassContainer = forwardRef(\n ({ spacing, className, style, children, ...props }, ref) => {\n const material = useGlassMaterial();\n if (material && GlassContainerView) {\n // The native container is not a styled view, so the classes go on a\n // view inside it and only the positional style stays on the outside.\n return (\n \n {children}\n \n );\n }\n return (\n \n {children}\n \n );\n }\n);\n\nGlassContainer.displayName = 'GlassContainer';\n" + "content": "/**\n * Glass — the material iOS draws its own floating controls in.\n *\n * A control that floats over content needs to read as *over* it, and a flat\n * fill cannot say that: it either hides what is behind it or disappears into\n * it. The system material does both jobs at once — it refracts what is behind,\n * lifts its own edge, and stays legible over anything.\n *\n * ```tsx\n * \n * Over whatever is behind it.\n * \n * ```\n *\n * ## Where it is real, and where it is not\n *\n * The material exists on iOS 26 and above. Below that, on Android, on web, and\n * for anyone who has switched Reduce Transparency on, this draws\n * `fallbackClassName` instead — a solid token surface. That is deliberate\n * rather than a gap: a hand-drawn approximation of a system material is a\n * near-miss on the one platform that has the real thing, and on Android it is\n * an iOS look pasted onto a platform that never asked for it.\n *\n * So `Glass` is not a promise that the screen will be glass. It is a promise\n * that the container will be drawn correctly either way, and callers should\n * pick a `fallbackClassName` that stands on its own.\n *\n * ## The material is a layer, not the box\n *\n * The native view is rendered as a non-interactive layer filling this\n * container, with the content above it, for the same reason `Scrim` layers a\n * blur: it keeps every layout class, every token and every touch target on an\n * ordinary `View` that behaves the way the rest of the library does.\n *\n * `radius` is a number rather than a class because the material has to round\n * its own corners — clipping a square one to a rounded parent throws away the\n * lit edge that makes it read as glass.\n *\n * `interactive` is the one exception to the layer: the platform only animates\n * the glass under a touch it can see, so with it on the material is the box\n * and the children are hosted inside it, in normal flow. Reach for it when\n * the glass *is* the button.\n *\n * ## Do not fade it\n *\n * Setting `opacity` to `0` on the material or on anything above it stops it\n * rendering at all, and it does not come back when the opacity does. Move it,\n * or unmount it; never animate it out.\n */\nimport { forwardRef, type ComponentType, type ReactNode } from 'react';\nimport { Platform, StyleSheet, View, type StyleProp, type ViewProps, type ViewStyle } from 'react-native';\nimport { useThemeMode } from '@/hooks/use-theme';\nimport { cn } from '@/lib/cn';\nimport { useReduceTransparency } from '@/components/ui/scrim';\nimport type { ViewRef } from '@/components/ui/refs';\n\n/** How the material treats what is behind it. */\nexport type GlassVariant = 'regular' | 'clear';\n\n/**\n * Corner radius in points — one number for all four, or a top and a bottom.\n *\n * The two-sided form is for a surface with an edge that is not a real edge: a\n * sheet docked to the bottom of the screen rounds its top and leaves its\n * bottom square, because the screen edge is where it ends.\n */\nexport type GlassRadius = number | { top?: number; bottom?: number };\n\nfunction shapeOf(radius: GlassRadius | undefined) {\n if (radius === undefined) return null;\n if (typeof radius === 'number') return { borderRadius: radius };\n return {\n borderTopLeftRadius: radius.top ?? 0,\n borderTopRightRadius: radius.top ?? 0,\n borderBottomLeftRadius: radius.bottom ?? 0,\n borderBottomRightRadius: radius.bottom ?? 0,\n };\n}\n\ninterface GlassViewProps extends ViewProps {\n glassEffectStyle?: GlassVariant | 'none';\n tintColor?: string;\n isInteractive?: boolean;\n colorScheme?: 'auto' | 'light' | 'dark';\n style?: StyleProp;\n children?: ReactNode;\n}\n\ninterface GlassContainerViewProps extends ViewProps {\n spacing?: number;\n ref?: React.Ref;\n}\n\ninterface GlassModule {\n GlassView: ComponentType;\n GlassContainer: ComponentType;\n}\n\n/**\n * `expo-glass-effect`, or null when the material cannot be drawn here.\n *\n * Resolved once at module load, behind three gates that all have to pass. The\n * package is optional, so the require can fail; the API is missing from some\n * iOS 26 builds, and reaching for it there crashes; and the design itself is\n * only present when the app was compiled for it. Asking all three once is\n * cheaper than a try/catch on every render, and there is no answer that can\n * change while the process is alive.\n */\nconst glassModule: GlassModule | null = (() => {\n if (Platform.OS !== 'ios') return null;\n try {\n // eslint-disable-next-line @typescript-eslint/no-require-imports\n const mod = require('expo-glass-effect');\n if (typeof mod?.isGlassEffectAPIAvailable === 'function' && !mod.isGlassEffectAPIAvailable()) {\n return null;\n }\n if (typeof mod?.isLiquidGlassAvailable === 'function' && !mod.isLiquidGlassAvailable()) {\n return null;\n }\n return mod?.GlassView ? (mod as GlassModule) : null;\n } catch {\n return null;\n }\n})();\n\nconst GlassView = glassModule?.GlassView ?? null;\nconst GlassContainerView = glassModule?.GlassContainer ?? null;\n\n/**\n * True when the real material can be drawn — for a caller that wants to know\n * before it commits to a look. It says nothing about Reduce Transparency, which\n * is a live preference and belongs to the hook.\n */\nexport const hasGlass = GlassView !== null;\n\n/**\n * Whether the material will actually be drawn right now: the API is present\n * *and* the user has not switched Reduce Transparency on.\n *\n * For a component that changes shape around the material — dropping a fill, a\n * border or a shadow the glass replaces — so that it makes the same decision\n * `Glass` makes and never strips the fill while leaving nothing behind it.\n */\nexport function useGlassMaterial(): boolean {\n const reduceTransparency = useReduceTransparency();\n // Not knowing yet counts as \"do not draw it\": the material arriving a frame\n // late is invisible, and one flashing at somebody who opted out is not.\n return hasGlass && reduceTransparency === false;\n}\n\nexport interface GlassProps extends ViewProps {\n /**\n * How much of what is behind shows through. `regular` is the everyday\n * material; `clear` is thinner, for something over its own artwork.\n */\n variant?: GlassVariant;\n /** Tints the material. Takes a colour, not a token name. */\n tint?: string;\n /**\n * Corner radius in points. The material rounds itself to this, so give it\n * the same shape the container is drawn with — clipping a square material\n * to a rounded parent throws away the lit edge that makes it read as glass.\n */\n radius?: GlassRadius;\n /**\n * Let the material answer touch the way the platform's own controls do:\n * it brightens and swells under the finger and the highlight follows it.\n *\n * For a material that *is* a button. The platform only tracks touches that\n * land inside the glass view, so with this on the content is hosted inside\n * the material rather than above it — a pressable written as a child still\n * gets its press, and the glass reacts to the same touch.\n */\n interactive?: boolean;\n /** Applied only when the material cannot be drawn. Give it a real surface. */\n fallbackClassName?: string;\n className?: string;\n}\n\nexport function Glass({\n variant = 'regular',\n tint,\n radius,\n interactive = false,\n fallbackClassName = 'bg-card',\n className,\n children,\n style,\n ...props\n}: GlassProps) {\n /*\n * Which appearance the material is drawn in, from the app's theme rather\n * than the phone's.\n *\n * The material's own default follows the system, so an app running a dark\n * theme on a phone set to light draws light glass over dark content — and a\n * theme changed at runtime leaves it where it was, because the system\n * appearance never moved.\n */\n const { mode } = useThemeMode();\n const material = useGlassMaterial();\n const shape = shapeOf(radius);\n\n /*\n * Interactive, the material is the box rather than a layer in it. The\n * platform only tracks a touch that lands inside the glass view, so the\n * content has to be hosted in it — and hosted in normal flow, so that a\n * box sized by its content still is. A layer pinned to the box's edges\n * could not size it, and a button as wide as its label would collapse to\n * its minimum. The classes go on a view inside, because the native view is\n * not a styled one; only the positional style stays on the outside.\n */\n if (material && GlassView && interactive) {\n return (\n \n {children}\n \n );\n }\n\n return (\n \n {material && GlassView ? (\n \n ) : null}\n {children}\n \n );\n}\n\nGlass.displayName = 'Glass';\n\nexport interface GlassContainerProps extends ViewProps {\n /**\n * How close two pieces of glass have to be before they merge, in points.\n * Inside it their edges flow into one another; a piece moving past\n * another blends with it and pulls free as it leaves.\n */\n spacing?: number;\n className?: string;\n}\n\n/**\n * Lets the glass inside it merge.\n *\n * On its own each piece of the material is a separate object with its own\n * lit edge. Inside a container, pieces within `spacing` of each other flow\n * together — which is what makes a button that opens into other buttons look\n * like one thing dividing rather than several things arriving. A plain view\n * wherever the material is not drawn, so it can be written unconditionally.\n */\nexport const GlassContainer = forwardRef(\n ({ spacing, className, style, children, ...props }, ref) => {\n const material = useGlassMaterial();\n if (material && GlassContainerView) {\n // The native container is not a styled view, so the classes go on a\n // view inside it and only the positional style stays on the outside.\n return (\n \n {children}\n \n );\n }\n return (\n \n {children}\n \n );\n }\n);\n\nGlassContainer.displayName = 'GlassContainer';\n" } ] } diff --git a/apps/docs/public/r/grid-item.json b/apps/docs/public/r/grid-item.json index 4cd308127..abc997447 100644 --- a/apps/docs/public/r/grid-item.json +++ b/apps/docs/public/r/grid-item.json @@ -5,6 +5,7 @@ "registryDependencies": [ "animated-pressable", "cn", + "refs", "text" ], "dependencies": [ @@ -15,7 +16,7 @@ { "path": "ui/grid-item.tsx", "type": "registry:ui", - "content": "/**\n * GridItem — a tile in a bento grid, and the grid that lays it out.\n *\n * ```tsx\n * \n * \n * \n * Deploys\n * 1,284\n * this week\n * \n * …\n * …\n * \n * ```\n *\n * ## Why the group places the tiles\n *\n * `Item` next door is a row, and a list of rows is a column of views — the\n * layout falls out of the flexbox and nothing has to be measured. A bento is\n * not that. Its whole idea is that tiles are different sizes and the grid still\n * lines up: a wide tile and the two square ones beside it share a left edge, a\n * tall one runs past the tile next to it and the next tile fills in underneath.\n *\n * A wrapping flex row cannot do the last of those. Wrapping puts everything\n * that did not fit on a *new line*, so nothing ever tucks under a tall tile,\n * and `rowSpan` in a wrapping row is a prop that quietly does nothing.\n *\n * So the group measures itself, walks its children into the first free cell\n * that fits each one — row by row, the way a grid places anything it is not\n * told where to put — and positions them absolutely. Every tile is a whole\n * number of cells, which is what makes the edges line up, and the group's own\n * height is the number of rows it ended up needing.\n *\n * The consequence worth knowing: **a tile's height is its cells, not its\n * content**. That is the right way round for a bento — a grid of boxes that\n * each grew to fit its own text is not a grid — but it does mean long text\n * needs `numberOfLines`, a smaller `size`, or a taller `rowSpan`.\n *\n * ## Nesting\n *\n * A group inside a tile is a group like any other, and it measures the cell it\n * was put in. That is how a bento gets a sub-rhythm — two small tiles stacked\n * inside one cell of the outer grid — without the outer grid needing a notion\n * of half a row.\n */\nimport {\n Children,\n createContext,\n forwardRef,\n isValidElement,\n useContext,\n useMemo,\n useState,\n type ReactNode,\n} from 'react';\nimport { View, type LayoutChangeEvent, type ViewProps } from 'react-native';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport {\n AnimatedPressable,\n type AnimatedPressableProps,\n} from '@/components/ui/animated-pressable';\nimport { Text, type TextProps, textChildren } from '@/components/ui/text';\nimport { cn } from '@/lib/cn';\n\ntype GridItemSize = 'default' | 'sm';\n\nconst gridItemVariants = tv({\n slots: {\n // Full-bleed inside its cell: the group has already decided how big the\n // tile is, and a tile that sized itself would break the grid it is in.\n root: 'h-full w-full overflow-hidden rounded-2xl',\n title: 'font-medium text-foreground',\n value: 'font-semibold text-foreground',\n description: 'text-muted-foreground',\n },\n variants: {\n variant: {\n /** A card in a tray — the tile that reads as a tile. */\n default: { root: 'border border-border bg-card' },\n /** Outline only, for a grid over a coloured or patterned page. */\n outline: { root: 'border border-border' },\n /** Filled and unbordered, for a quieter tile among louder ones. */\n muted: { root: 'bg-muted' },\n /** Nothing at all: the tile is whatever is put inside it. */\n plain: { root: '' },\n },\n size: {\n default: { root: 'gap-2 p-4', title: 'text-sm', value: 'text-2xl', description: 'text-xs' },\n sm: { root: 'gap-1.5 p-3', title: 'text-xs', value: 'text-xl', description: 'text-xs' },\n },\n disabled: {\n true: { root: 'opacity-[0.64]' },\n },\n },\n defaultVariants: {\n variant: 'default',\n size: 'default',\n },\n});\n\nconst mediaVariants = tv({\n base: 'shrink-0 items-center justify-center',\n variants: {\n variant: {\n /** No box — for an Avatar or anything that styles itself. */\n default: '',\n /** Rounded square tile sized for an icon. */\n icon: 'rounded-lg border border-border bg-muted',\n /** Clipped frame for an image or thumbnail. */\n image: 'overflow-hidden rounded-lg bg-muted',\n },\n size: {\n default: '',\n sm: '',\n },\n },\n compoundVariants: [\n { variant: 'icon', size: 'default', class: 'h-9 w-9' },\n { variant: 'icon', size: 'sm', class: 'h-7 w-7' },\n { variant: 'image', size: 'default', class: 'h-11 w-11' },\n { variant: 'image', size: 'sm', class: 'h-9 w-9' },\n ],\n defaultVariants: {\n variant: 'default',\n size: 'default',\n },\n});\n\n/** Density, set once on the group and read by every part inside a tile. */\nconst GridItemContext = createContext<{ size: GridItemSize }>({ size: 'default' });\n\nexport interface GridItemGroupProps extends ViewProps {\n className?: string;\n /** How many tracks wide the grid is. */\n columns?: number;\n /** Gutter between tiles, in points — both ways. */\n gap?: number;\n /**\n * The shape of one cell, as width ÷ height. `1` is square; below one the\n * cells are taller than they are wide. Ignored when `rowHeight` is given.\n */\n aspect?: number;\n /** Cell height in points, when the grid should not be driven by its width. */\n rowHeight?: number;\n /** Density for every tile in the grid. Set here rather than on each one. */\n size?: GridItemSize;\n children?: ReactNode;\n}\n\n/** One tile's place in the grid, in cells. */\ninterface Placement {\n row: number;\n column: number;\n colSpan: number;\n rowSpan: number;\n}\n\n/**\n * The first cell a tile of this size fits in, scanning row by row.\n *\n * The same rule a grid uses for anything it is not told where to put: take the\n * earliest place it fits, never move anything already placed, and open a new\n * row when nothing on the existing ones will do. Row-major, so the order the\n * tiles are written in is the order they are read in — which is the order a\n * screen reader will walk them in too, since that is the child order.\n */\nfunction findSpot(\n occupied: boolean[][],\n columns: number,\n colSpan: number,\n rowSpan: number\n): { row: number; column: number } {\n for (let row = 0; ; row += 1) {\n for (let column = 0; column + colSpan <= columns; column += 1) {\n let free = true;\n for (let y = row; y < row + rowSpan && free; y += 1) {\n for (let x = column; x < column + colSpan; x += 1) {\n if (occupied[y]?.[x]) {\n free = false;\n break;\n }\n }\n }\n if (free) return { row, column };\n }\n }\n}\n\n/**\n * The grid. Measures its own width, places its tiles, and stands as tall as the\n * rows they ended up needing.\n */\nconst GridItemGroup = forwardRef(\n (\n {\n className,\n columns = 2,\n gap = 12,\n aspect = 1,\n rowHeight,\n size = 'default',\n children,\n ...props\n },\n ref\n ) => {\n const [width, setWidth] = useState(0);\n\n const tracks = Math.max(1, Math.floor(columns));\n const track = (width - gap * (tracks - 1)) / tracks;\n const cell = rowHeight ?? track / (aspect || 1);\n\n const { placements, rows } = useMemo(() => {\n const occupied: boolean[][] = [];\n const result: Placement[] = [];\n let used = 0;\n\n Children.forEach(children, (child) => {\n if (!isValidElement(child)) return;\n const spans = child.props as { colSpan?: number; rowSpan?: number };\n const colSpan = Math.max(1, Math.min(Math.floor(spans.colSpan ?? 1), tracks));\n const rowSpan = Math.max(1, Math.floor(spans.rowSpan ?? 1));\n\n const { row, column } = findSpot(occupied, tracks, colSpan, rowSpan);\n for (let y = row; y < row + rowSpan; y += 1) {\n const line = (occupied[y] ??= []);\n for (let x = column; x < column + colSpan; x += 1) line[x] = true;\n }\n\n result.push({ row, column, colSpan, rowSpan });\n used = Math.max(used, row + rowSpan);\n });\n\n return { placements: result, rows: used };\n }, [children, tracks]);\n\n const onLayout = (event: LayoutChangeEvent) => {\n const next = event.nativeEvent.layout.width;\n if (Math.abs(next - width) > 0.5) setWidth(next);\n props.onLayout?.(event);\n };\n\n const height = rows > 0 ? rows * cell + gap * (rows - 1) : 0;\n\n let index = -1;\n\n return (\n \n 0 ? { height } : undefined}\n className={cn('w-full', className)}\n {...props}\n >\n {/* Nothing is drawn until the width is known: every tile is placed\n from it, and one frame of tiles piled on the origin is worse than\n one frame of an empty box. */}\n {width > 0\n ? Children.map(children, (child) => {\n if (!isValidElement(child)) return null;\n index += 1;\n const place = placements[index];\n if (!place) return null;\n return (\n \n {child}\n \n );\n })\n : null}\n \n \n );\n }\n);\nGridItemGroup.displayName = 'GridItem.Group';\n\nexport interface GridItemProps\n extends Omit,\n Omit, 'disabled' | 'size'> {\n className?: string;\n disabled?: boolean;\n /**\n * How many tracks wide the tile is. Clamped to the group's column count, so\n * a tile asking for three columns of a two-column grid is two wide rather\n * than overflowing it.\n *\n * Read by `GridItem.Group`, which does the placing — a tile does not size\n * itself, because a tile that sized itself would not be in a grid.\n */\n colSpan?: number;\n /** How many rows tall it is. Also read by the group. */\n rowSpan?: number;\n children?: ReactNode;\n}\n\n/**\n * One tile.\n *\n * Renders as a pressable when given `onPress`, and as a plain view otherwise,\n * so a tile that is only showing a number does not announce itself as a button.\n */\nconst GridItemRoot = forwardRef(\n (\n {\n className,\n variant,\n disabled,\n children,\n onPress,\n // Declared so they are part of the tile's API and typed at the call site,\n // read by the group, and deliberately not applied here.\n colSpan: _colSpan,\n rowSpan: _rowSpan,\n ...props\n },\n ref\n ) => {\n const { size } = useContext(GridItemContext);\n const { root } = gridItemVariants({ variant, size, disabled: !!disabled });\n\n if (!onPress) {\n return (\n \n {textChildren(children)}\n \n );\n }\n\n return (\n \n {textChildren(children)}\n \n );\n }\n);\nGridItemRoot.displayName = 'GridItem';\n\nexport interface GridItemBackgroundProps extends ViewProps {\n className?: string;\n children?: ReactNode;\n}\n\n/**\n * The layer behind the tile's content — a chart, a gradient, an image, a\n * pattern.\n *\n * Absolutely filling the tile and taking no touches, so it can be written\n * anywhere among the children without moving anything. It is what makes a bento\n * grid read as a bento grid rather than as a wall of stat cards, and it is the\n * one part that is meant to be cropped: the tile clips it.\n */\nconst GridItemBackground = forwardRef(\n ({ className, children, ...props }, ref) => (\n \n {textChildren(children)}\n \n )\n);\nGridItemBackground.displayName = 'GridItem.Background';\n\nexport interface GridItemMediaProps\n extends ViewProps,\n VariantProps {\n className?: string;\n children?: ReactNode;\n}\n\n/** Leading slot: an icon tile, a thumbnail, or an avatar passed through. */\nconst GridItemMedia = forwardRef(\n ({ className, variant, size, children, ...props }, ref) => {\n const grid = useContext(GridItemContext);\n\n return (\n \n {textChildren(children)}\n \n );\n }\n);\nGridItemMedia.displayName = 'GridItem.Media';\n\nexport interface GridItemTitleProps extends TextProps {\n className?: string;\n}\n\n/** What the tile is of. Quiet, because the number under it is the message. */\nconst GridItemTitle = forwardRef, GridItemTitleProps>(\n ({ className, ...props }, ref) => {\n const { size } = useContext(GridItemContext);\n const { title } = gridItemVariants({ size });\n\n return ;\n }\n);\nGridItemTitle.displayName = 'GridItem.Title';\n\nexport interface GridItemValueProps extends TextProps {\n className?: string;\n}\n\n/** The figure. The largest thing on the tile, and the reason it is there. */\nconst GridItemValue = forwardRef, GridItemValueProps>(\n ({ className, ...props }, ref) => {\n const { size } = useContext(GridItemContext);\n const { value } = gridItemVariants({ size });\n\n return ;\n }\n);\nGridItemValue.displayName = 'GridItem.Value';\n\nexport interface GridItemDescriptionProps extends TextProps {\n className?: string;\n}\n\nconst GridItemDescription = forwardRef<\n React.ElementRef,\n GridItemDescriptionProps\n>(({ className, ...props }, ref) => {\n const { size } = useContext(GridItemContext);\n const { description } = gridItemVariants({ size });\n\n return ;\n});\nGridItemDescription.displayName = 'GridItem.Description';\n\nexport interface GridItemFooterProps extends ViewProps {\n className?: string;\n children?: ReactNode;\n}\n\n/**\n * The strip along the bottom of the tile.\n *\n * `mt-auto` rather than `absolute`: a tile's height is fixed by its cells, so\n * pushing the footer down with the space left over pins it to the bottom\n * without taking it out of the layout the rest of the tile is sharing.\n */\nconst GridItemFooter = forwardRef(\n ({ className, children, ...props }, ref) => (\n \n {textChildren(children)}\n \n )\n);\nGridItemFooter.displayName = 'GridItem.Footer';\n\nexport interface GridItemActionsProps extends ViewProps {\n className?: string;\n children?: ReactNode;\n}\n\n/** Trailing slot: buttons, a chip, a chevron. */\nconst GridItemActions = forwardRef(\n ({ className, children, ...props }, ref) => (\n \n {textChildren(children)}\n \n )\n);\nGridItemActions.displayName = 'GridItem.Actions';\n\nexport const GridItem = Object.assign(GridItemRoot, {\n Group: GridItemGroup,\n Background: GridItemBackground,\n Media: GridItemMedia,\n Title: GridItemTitle,\n Value: GridItemValue,\n Description: GridItemDescription,\n Footer: GridItemFooter,\n Actions: GridItemActions,\n});\n" + "content": "/**\n * GridItem — a tile in a bento grid, and the grid that lays it out.\n *\n * ```tsx\n * \n * \n * \n * Deploys\n * 1,284\n * this week\n * \n * …\n * …\n * \n * ```\n *\n * ## Why the group places the tiles\n *\n * `Item` next door is a row, and a list of rows is a column of views — the\n * layout falls out of the flexbox and nothing has to be measured. A bento is\n * not that. Its whole idea is that tiles are different sizes and the grid still\n * lines up: a wide tile and the two square ones beside it share a left edge, a\n * tall one runs past the tile next to it and the next tile fills in underneath.\n *\n * A wrapping flex row cannot do the last of those. Wrapping puts everything\n * that did not fit on a *new line*, so nothing ever tucks under a tall tile,\n * and `rowSpan` in a wrapping row is a prop that quietly does nothing.\n *\n * So the group measures itself, walks its children into the first free cell\n * that fits each one — row by row, the way a grid places anything it is not\n * told where to put — and positions them absolutely. Every tile is a whole\n * number of cells, which is what makes the edges line up, and the group's own\n * height is the number of rows it ended up needing.\n *\n * The consequence worth knowing: **a tile's height is its cells, not its\n * content**. That is the right way round for a bento — a grid of boxes that\n * each grew to fit its own text is not a grid — but it does mean long text\n * needs `numberOfLines`, a smaller `size`, or a taller `rowSpan`.\n *\n * ## Nesting\n *\n * A group inside a tile is a group like any other, and it measures the cell it\n * was put in. That is how a bento gets a sub-rhythm — two small tiles stacked\n * inside one cell of the outer grid — without the outer grid needing a notion\n * of half a row.\n */\nimport {\n Children,\n createContext,\n forwardRef,\n isValidElement,\n useContext,\n useMemo,\n useState,\n type ReactNode,\n} from 'react';\nimport { View, type LayoutChangeEvent, type ViewProps } from 'react-native';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport {\n AnimatedPressable,\n type AnimatedPressableProps,\n} from '@/components/ui/animated-pressable';\nimport { Text, type TextProps, textChildren } from '@/components/ui/text';\nimport { cn } from '@/lib/cn';\nimport type { TextRef, ViewRef } from '@/components/ui/refs';\n\ntype GridItemSize = 'default' | 'sm';\n\nconst gridItemVariants = tv({\n slots: {\n // Full-bleed inside its cell: the group has already decided how big the\n // tile is, and a tile that sized itself would break the grid it is in.\n root: 'h-full w-full overflow-hidden rounded-2xl',\n title: 'font-medium text-foreground',\n value: 'font-semibold text-foreground',\n description: 'text-muted-foreground',\n },\n variants: {\n variant: {\n /** A card in a tray — the tile that reads as a tile. */\n default: { root: 'border border-border bg-card' },\n /** Outline only, for a grid over a coloured or patterned page. */\n outline: { root: 'border border-border' },\n /** Filled and unbordered, for a quieter tile among louder ones. */\n muted: { root: 'bg-muted' },\n /** Nothing at all: the tile is whatever is put inside it. */\n plain: { root: '' },\n },\n size: {\n default: { root: 'gap-2 p-4', title: 'text-sm', value: 'text-2xl', description: 'text-xs' },\n sm: { root: 'gap-1.5 p-3', title: 'text-xs', value: 'text-xl', description: 'text-xs' },\n },\n disabled: {\n true: { root: 'opacity-[0.64]' },\n },\n },\n defaultVariants: {\n variant: 'default',\n size: 'default',\n },\n});\n\nconst mediaVariants = tv({\n base: 'shrink-0 items-center justify-center',\n variants: {\n variant: {\n /** No box — for an Avatar or anything that styles itself. */\n default: '',\n /** Rounded square tile sized for an icon. */\n icon: 'rounded-lg border border-border bg-muted',\n /** Clipped frame for an image or thumbnail. */\n image: 'overflow-hidden rounded-lg bg-muted',\n },\n size: {\n default: '',\n sm: '',\n },\n },\n compoundVariants: [\n { variant: 'icon', size: 'default', class: 'h-9 w-9' },\n { variant: 'icon', size: 'sm', class: 'h-7 w-7' },\n { variant: 'image', size: 'default', class: 'h-11 w-11' },\n { variant: 'image', size: 'sm', class: 'h-9 w-9' },\n ],\n defaultVariants: {\n variant: 'default',\n size: 'default',\n },\n});\n\n/** Density, set once on the group and read by every part inside a tile. */\nconst GridItemContext = createContext<{ size: GridItemSize }>({ size: 'default' });\n\nexport interface GridItemGroupProps extends ViewProps {\n className?: string;\n /** How many tracks wide the grid is. */\n columns?: number;\n /** Gutter between tiles, in points — both ways. */\n gap?: number;\n /**\n * The shape of one cell, as width ÷ height. `1` is square; below one the\n * cells are taller than they are wide. Ignored when `rowHeight` is given.\n */\n aspect?: number;\n /** Cell height in points, when the grid should not be driven by its width. */\n rowHeight?: number;\n /** Density for every tile in the grid. Set here rather than on each one. */\n size?: GridItemSize;\n children?: ReactNode;\n}\n\n/** One tile's place in the grid, in cells. */\ninterface Placement {\n row: number;\n column: number;\n colSpan: number;\n rowSpan: number;\n}\n\n/**\n * The first cell a tile of this size fits in, scanning row by row.\n *\n * The same rule a grid uses for anything it is not told where to put: take the\n * earliest place it fits, never move anything already placed, and open a new\n * row when nothing on the existing ones will do. Row-major, so the order the\n * tiles are written in is the order they are read in — which is the order a\n * screen reader will walk them in too, since that is the child order.\n */\nfunction findSpot(\n occupied: boolean[][],\n columns: number,\n colSpan: number,\n rowSpan: number\n): { row: number; column: number } {\n for (let row = 0; ; row += 1) {\n for (let column = 0; column + colSpan <= columns; column += 1) {\n let free = true;\n for (let y = row; y < row + rowSpan && free; y += 1) {\n for (let x = column; x < column + colSpan; x += 1) {\n if (occupied[y]?.[x]) {\n free = false;\n break;\n }\n }\n }\n if (free) return { row, column };\n }\n }\n}\n\n/**\n * The grid. Measures its own width, places its tiles, and stands as tall as the\n * rows they ended up needing.\n */\nconst GridItemGroup = forwardRef(\n (\n {\n className,\n columns = 2,\n gap = 12,\n aspect = 1,\n rowHeight,\n size = 'default',\n children,\n ...props\n },\n ref\n ) => {\n const [width, setWidth] = useState(0);\n\n const tracks = Math.max(1, Math.floor(columns));\n const track = (width - gap * (tracks - 1)) / tracks;\n const cell = rowHeight ?? track / (aspect || 1);\n\n const { placements, rows } = useMemo(() => {\n const occupied: boolean[][] = [];\n const result: Placement[] = [];\n let used = 0;\n\n Children.forEach(children, (child) => {\n if (!isValidElement(child)) return;\n const spans = child.props as { colSpan?: number; rowSpan?: number };\n const colSpan = Math.max(1, Math.min(Math.floor(spans.colSpan ?? 1), tracks));\n const rowSpan = Math.max(1, Math.floor(spans.rowSpan ?? 1));\n\n const { row, column } = findSpot(occupied, tracks, colSpan, rowSpan);\n for (let y = row; y < row + rowSpan; y += 1) {\n const line = (occupied[y] ??= []);\n for (let x = column; x < column + colSpan; x += 1) line[x] = true;\n }\n\n result.push({ row, column, colSpan, rowSpan });\n used = Math.max(used, row + rowSpan);\n });\n\n return { placements: result, rows: used };\n }, [children, tracks]);\n\n const onLayout = (event: LayoutChangeEvent) => {\n const next = event.nativeEvent.layout.width;\n if (Math.abs(next - width) > 0.5) setWidth(next);\n props.onLayout?.(event);\n };\n\n const height = rows > 0 ? rows * cell + gap * (rows - 1) : 0;\n\n let index = -1;\n\n return (\n \n 0 ? { height } : undefined}\n className={cn('w-full', className)}\n {...props}\n >\n {/* Nothing is drawn until the width is known: every tile is placed\n from it, and one frame of tiles piled on the origin is worse than\n one frame of an empty box. */}\n {width > 0\n ? Children.map(children, (child) => {\n if (!isValidElement(child)) return null;\n index += 1;\n const place = placements[index];\n if (!place) return null;\n return (\n \n {child}\n \n );\n })\n : null}\n \n \n );\n }\n);\nGridItemGroup.displayName = 'GridItem.Group';\n\nexport interface GridItemProps\n extends Omit,\n Omit, 'disabled' | 'size'> {\n className?: string;\n disabled?: boolean;\n /**\n * How many tracks wide the tile is. Clamped to the group's column count, so\n * a tile asking for three columns of a two-column grid is two wide rather\n * than overflowing it.\n *\n * Read by `GridItem.Group`, which does the placing — a tile does not size\n * itself, because a tile that sized itself would not be in a grid.\n */\n colSpan?: number;\n /** How many rows tall it is. Also read by the group. */\n rowSpan?: number;\n children?: ReactNode;\n}\n\n/**\n * One tile.\n *\n * Renders as a pressable when given `onPress`, and as a plain view otherwise,\n * so a tile that is only showing a number does not announce itself as a button.\n */\nconst GridItemRoot = forwardRef(\n (\n {\n className,\n variant,\n disabled,\n children,\n onPress,\n // Declared so they are part of the tile's API and typed at the call site,\n // read by the group, and deliberately not applied here.\n colSpan: _colSpan,\n rowSpan: _rowSpan,\n ...props\n },\n ref\n ) => {\n const { size } = useContext(GridItemContext);\n const { root } = gridItemVariants({ variant, size, disabled: !!disabled });\n\n if (!onPress) {\n return (\n \n {textChildren(children)}\n \n );\n }\n\n return (\n \n {textChildren(children)}\n \n );\n }\n);\nGridItemRoot.displayName = 'GridItem';\n\nexport interface GridItemBackgroundProps extends ViewProps {\n className?: string;\n children?: ReactNode;\n}\n\n/**\n * The layer behind the tile's content — a chart, a gradient, an image, a\n * pattern.\n *\n * Absolutely filling the tile and taking no touches, so it can be written\n * anywhere among the children without moving anything. It is what makes a bento\n * grid read as a bento grid rather than as a wall of stat cards, and it is the\n * one part that is meant to be cropped: the tile clips it.\n */\nconst GridItemBackground = forwardRef(\n ({ className, children, ...props }, ref) => (\n \n {textChildren(children)}\n \n )\n);\nGridItemBackground.displayName = 'GridItem.Background';\n\nexport interface GridItemMediaProps\n extends ViewProps,\n VariantProps {\n className?: string;\n children?: ReactNode;\n}\n\n/** Leading slot: an icon tile, a thumbnail, or an avatar passed through. */\nconst GridItemMedia = forwardRef(\n ({ className, variant, size, children, ...props }, ref) => {\n const grid = useContext(GridItemContext);\n\n return (\n \n {textChildren(children)}\n \n );\n }\n);\nGridItemMedia.displayName = 'GridItem.Media';\n\nexport interface GridItemTitleProps extends TextProps {\n className?: string;\n}\n\n/** What the tile is of. Quiet, because the number under it is the message. */\nconst GridItemTitle = forwardRef(\n ({ className, ...props }, ref) => {\n const { size } = useContext(GridItemContext);\n const { title } = gridItemVariants({ size });\n\n return ;\n }\n);\nGridItemTitle.displayName = 'GridItem.Title';\n\nexport interface GridItemValueProps extends TextProps {\n className?: string;\n}\n\n/** The figure. The largest thing on the tile, and the reason it is there. */\nconst GridItemValue = forwardRef(\n ({ className, ...props }, ref) => {\n const { size } = useContext(GridItemContext);\n const { value } = gridItemVariants({ size });\n\n return ;\n }\n);\nGridItemValue.displayName = 'GridItem.Value';\n\nexport interface GridItemDescriptionProps extends TextProps {\n className?: string;\n}\n\nconst GridItemDescription = forwardRef<\n TextRef,\n GridItemDescriptionProps\n>(({ className, ...props }, ref) => {\n const { size } = useContext(GridItemContext);\n const { description } = gridItemVariants({ size });\n\n return ;\n});\nGridItemDescription.displayName = 'GridItem.Description';\n\nexport interface GridItemFooterProps extends ViewProps {\n className?: string;\n children?: ReactNode;\n}\n\n/**\n * The strip along the bottom of the tile.\n *\n * `mt-auto` rather than `absolute`: a tile's height is fixed by its cells, so\n * pushing the footer down with the space left over pins it to the bottom\n * without taking it out of the layout the rest of the tile is sharing.\n */\nconst GridItemFooter = forwardRef(\n ({ className, children, ...props }, ref) => (\n \n {textChildren(children)}\n \n )\n);\nGridItemFooter.displayName = 'GridItem.Footer';\n\nexport interface GridItemActionsProps extends ViewProps {\n className?: string;\n children?: ReactNode;\n}\n\n/** Trailing slot: buttons, a chip, a chevron. */\nconst GridItemActions = forwardRef(\n ({ className, children, ...props }, ref) => (\n \n {textChildren(children)}\n \n )\n);\nGridItemActions.displayName = 'GridItem.Actions';\n\nexport const GridItem = Object.assign(GridItemRoot, {\n Group: GridItemGroup,\n Background: GridItemBackground,\n Media: GridItemMedia,\n Title: GridItemTitle,\n Value: GridItemValue,\n Description: GridItemDescription,\n Footer: GridItemFooter,\n Actions: GridItemActions,\n});\n" } ] } diff --git a/apps/docs/public/r/heatmap-chart.json b/apps/docs/public/r/heatmap-chart.json index d143f574f..2ec178468 100644 --- a/apps/docs/public/r/heatmap-chart.json +++ b/apps/docs/public/r/heatmap-chart.json @@ -6,6 +6,7 @@ "chart-accessibility", "cn", "date", + "refs", "text" ], "dependencies": [ @@ -19,7 +20,7 @@ { "path": "ui/heatmap-chart.tsx", "type": "registry:ui", - "content": "/**\n * HeatmapChart — a calendar of bins, shaded by how much happened in each.\n *\n * The contribution grid: one column per period (usually a week), one row per\n * bin inside it (usually a weekday), and a colour ramp carrying the count. It\n * answers \"when was this busy\" at a glance, which no line can — a year of daily\n * numbers plotted as a series is a hairball, and as a grid it is a pattern.\n *\n * Composed rather than configured, so a chart that wants no axis simply does\n * not have one:\n *\n * ```tsx\n * \n * \n * \n * \n * \n * \n * \n * \n * ```\n *\n * The parts sort themselves into a real layout rather than stacking over the\n * plot. That is the difference from a line chart, where the axis floats over\n * the drawing: here the labels and the legend sit *beside* and *below* the\n * grid, so they take up room, and the grid is sized with them accounted for.\n * Only the cells and the rules between them are SVG; every label is a React\n * Native view, because SVG text ignores the platform's text scaling and the\n * theme's font.\n *\n * The ramp is one colour at five opacities rather than five colours. A heatmap\n * reads as *more* and *less* of one thing, and five distinct hues read as five\n * different things — which is what the `--color-chart-*` tokens are for, and\n * why they are not used here. The base is `--color-chart-1`, so the ramp\n * follows the theme, and `levelColors` replaces it outright when a brand needs\n * its own.\n */\nimport {\n Children,\n createContext,\n forwardRef,\n isValidElement,\n useContext,\n useEffect,\n useId,\n useMemo,\n useRef,\n useState,\n type ReactNode,\n} from 'react';\nimport {\n StyleSheet,\n useWindowDimensions,\n View,\n type LayoutChangeEvent,\n type ViewProps,\n} from 'react-native';\nimport { Gesture, GestureDetector } from 'react-native-gesture-handler';\nimport Animated, {\n Easing,\n runOnJS,\n useAnimatedProps,\n useAnimatedStyle,\n useReducedMotion,\n useSharedValue,\n withTiming,\n} from 'react-native-reanimated';\nimport Svg, { G, Line as SvgLine, Rect } from 'react-native-svg';\nimport { useCSSVariable } from 'uniwind';\nimport { Text } from '@/components/ui/text';\nimport {\n CHART_AXIS_MAX_FONT_SCALE,\n ChartAccessibilityData,\n type ChartAccessibilityProps,\n} from '@/components/ui/chart-accessibility';\nimport { cn } from '@/lib/cn';\nimport { normalizeWeekStart, startOfDay } from '@/lib/date';\n\n\n/**\n * Rows in a week — the default, and what the calendar helper and the weekday\n * labels assume. The grid itself takes a `rows` prop, so a grid whose bins are\n * hours rather than days is the same component with a different number.\n */\nconst DAYS_IN_WEEK = 7;\n\n/** Opacity of the base colour at each activity level. Index 0 is \"nothing\". */\nconst LEVEL_OPACITY = [1, 0.28, 0.5, 0.74, 1] as const;\n\n/**\n * A theme token rather than a colour. Tokens are named, not written, so the\n * leading `--` tells the two apart without the caller having to say which\n * kind they passed.\n */\nfunction isToken(value: string | undefined): value is string {\n return typeof value === 'string' && value.startsWith('--');\n}\n\n/** Room left for the weekday labels when the y-axis does not ask for its own. */\nconst DEFAULT_AXIS_WIDTH = 26;\n\n/** Height of the month row at the default text size: one line of `xs` text. */\nconst X_AXIS_HEIGHT = 16;\n\n/**\n * How much larger than default the axis labels are drawn, from 1 up to the\n * axis cap. The heatmap's gutters are only just wide enough for a weekday at\n * the default size, so they grow by the same factor or a larger label wraps\n * and runs into the cells. Below the default size they stay put.\n */\nfunction useAxisLabelScale() {\n const { fontScale } = useWindowDimensions();\n return Math.min(Math.max(fontScale, 1), CHART_AXIS_MAX_FONT_SCALE);\n}\n\n/**\n * Height assumed for the readout when placing it above a cell. It is a single\n * line of `xs` text in a padded box, so it does not vary — and measuring it\n * would put the tooltip a frame behind the finger.\n */\nconst TOOLTIP_HEIGHT = 26;\n\n/**\n * How long the readout waits before claiming the touch, in milliseconds.\n *\n * Long enough that a swipe scrolls the chart instead of reading it, short\n * enough that a deliberate press does not feel like it was ignored.\n */\nconst DEFAULT_HOLD = 180;\n\n/** Weekday names, indexed from Sunday, as the `Date` API numbers them. */\nconst WEEKDAYS = [\n 'Sunday',\n 'Monday',\n 'Tuesday',\n 'Wednesday',\n 'Thursday',\n 'Friday',\n 'Saturday',\n] as const;\n\nconst MONTHS = [\n 'Jan',\n 'Feb',\n 'Mar',\n 'Apr',\n 'May',\n 'Jun',\n 'Jul',\n 'Aug',\n 'Sep',\n 'Oct',\n 'Nov',\n 'Dec',\n] as const;\n\n/**\n * Where a part belongs in the layout. Read off the component itself, so\n * composition stays a flat list of children instead of four nested slots the\n * caller has to remember the order of.\n */\ntype Slot = 'cells' | 'x-axis' | 'y-axis' | 'rules' | 'tooltip' | 'legend' | 'header';\n\nexport type HeatmapLayout = 'fluid' | 'fill';\n\n/** One bin inside a column — usually a single day. */\nexport interface HeatmapBin {\n /** Row index within the column, `0` to `6`. */\n bin: number;\n /** How much happened. The ramp is derived from these across the whole chart. */\n count: number;\n /** The day this bin stands for. Used by the axis labels and the tooltip. */\n date?: Date;\n}\n\n/** One column — usually a week. Missing bins are drawn as empty cells. */\nexport interface HeatmapColumn {\n /** Column index across the chart. */\n bin: number;\n bins: HeatmapBin[];\n}\n\n/** A cell resolved to its place in the grid, as the tooltip receives it. */\nexport interface HeatmapCell {\n column: number;\n row: number;\n count: number;\n level: number;\n date?: Date;\n}\n\ninterface Grid {\n /** Side of one cell, in pixels. */\n size: number;\n gap: number;\n columns: number;\n rows: number;\n width: number;\n height: number;\n}\n\ninterface HeatmapContextValue {\n data: HeatmapColumn[];\n grid: Grid;\n /** `[level0, level1, …level4]`, already resolved to colours. */\n ramp: string[];\n /** Opacity to paint each level's colour at. All ones for a supplied ramp. */\n opacities: number[];\n levelOf: (count: number) => number;\n cellAt: (column: number, row: number) => HeatmapCell;\n cornerRadius: number;\n inactiveOpacity: number;\n weekStartDay: number;\n activeCell: HeatmapCell | null;\n setActiveCell: (cell: HeatmapCell | null) => void;\n}\n\nconst HeatmapContext = createContext(null);\n\nfunction useHeatmap(component: string): HeatmapContextValue {\n const context = useContext(HeatmapContext);\n if (!context) {\n throw new Error(`${component} must be used within a `);\n }\n return context;\n}\n\n/**\n * The cell under the finger, for something rendered *inside* the chart. A\n * readout in the card's header is outside this provider — use\n * `onActiveCellChange` for that.\n */\nexport function useHeatmapChart() {\n const { activeCell } = useHeatmap('useHeatmapChart');\n return { activeCell };\n}\n\n/* -------------------------------------------------------------------------- */\n/* Data helpers */\n/* -------------------------------------------------------------------------- */\n\n/**\n * Build a year of columns from a flat list of dated counts.\n *\n * Every heatmap starts as \"I have some dates and some numbers\", and the\n * bucketing into weeks is the same arithmetic every time — including the two\n * parts that are easy to get wrong: the leading blanks before the first day of\n * the first week, and days with no entry at all, which must still be drawn as\n * empty cells or the calendar develops holes.\n */\nexport function buildHeatmapCalendar(\n entries: { date: Date; count: number }[],\n options: { start?: Date; end?: Date; weekStartDay?: number } = {}\n): HeatmapColumn[] {\n const weekStartDay = normalizeWeekStart(options.weekStartDay ?? 0);\n if (!entries.length && !options.start) return [];\n\n const times = entries.map((entry) => entry.date.getTime());\n // Both bounds at local midnight, and both the same way. Comparing a\n // normalised lower bound against a raw upper one drops the last day whenever\n // the caller's `end` carries a time earlier than the cell being tested.\n const start = startOfDay(options.start ?? new Date(Math.min(...times)));\n const end = startOfDay(options.end ?? new Date(Math.max(...times)));\n\n const byDay = new Map();\n for (const entry of entries) {\n const key = dayKey(entry.date);\n byDay.set(key, (byDay.get(key) ?? 0) + entry.count);\n }\n\n // Back up to the first day of the week the range starts in, so column 0 is a\n // whole week and every row lines up with a weekday for the rest of the chart.\n const cursor = startOfDay(start);\n cursor.setDate(cursor.getDate() - ((cursor.getDay() - weekStartDay + 7) % 7));\n\n const columns: HeatmapColumn[] = [];\n let column: HeatmapBin[] = [];\n\n while (cursor <= end || column.length) {\n const row = (cursor.getDay() - weekStartDay + 7) % 7;\n const date = new Date(cursor);\n // Before the range starts and after it ends the cell exists but has no\n // reading — `count: 0` and no date, so the tooltip stays quiet on it.\n const inRange = date >= start && date <= end;\n column.push({\n bin: row,\n count: inRange ? (byDay.get(dayKey(date)) ?? 0) : 0,\n date: inRange ? date : undefined,\n });\n\n if (row === DAYS_IN_WEEK - 1) {\n columns.push({ bin: columns.length, bins: column });\n column = [];\n if (cursor > end) break;\n }\n cursor.setDate(cursor.getDate() + 1);\n }\n\n return columns;\n}\n\nfunction dayKey(date: Date) {\n return `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`;\n}\n\n/**\n * Four thresholds from the data, so a chart of single-digit counts and a chart\n * of thousands both use the whole ramp. Quartiles of the *non-zero* counts:\n * zero is its own level, and counting it would drag every threshold down to\n * nothing on a sparse chart.\n */\nfunction deriveLevels(data: HeatmapColumn[]): number[] {\n const counts: number[] = [];\n for (const column of data) {\n for (const bin of column.bins) {\n if (bin.count > 0) counts.push(bin.count);\n }\n }\n if (!counts.length) return [1, 2, 3, 4];\n counts.sort((a, b) => a - b);\n const at = (fraction: number) =>\n counts[Math.min(counts.length - 1, Math.floor(counts.length * fraction))]!;\n const thresholds = [1, at(0.25), at(0.5), at(0.75)];\n // Ties collapse the ramp — nudge each threshold past the one below it so\n // four distinct levels stay four distinct levels.\n for (let i = 1; i < thresholds.length; i++) {\n if (thresholds[i]! <= thresholds[i - 1]!) thresholds[i] = thresholds[i - 1]! + 1;\n }\n return thresholds;\n}\n\n/* -------------------------------------------------------------------------- */\n/* Root */\n/* -------------------------------------------------------------------------- */\n\nexport interface HeatmapChartProps extends ViewProps, ChartAccessibilityProps {\n className?: string;\n /** One column per period, with its row bins inside. */\n data: HeatmapColumn[];\n /**\n * `fluid` draws cells at `binSize` and lets the grid be as wide as it needs\n * to be — put it in a horizontal `ScrollView` for a full year. `fill`\n * divides the available width between the columns instead.\n */\n layout?: HeatmapLayout;\n /** Side of one cell in `fluid` layout, in pixels. */\n binSize?: number;\n /** Space between cells, in pixels. */\n gap?: number;\n /** Corner radius of a cell. */\n cornerRadius?: number;\n /** Which weekday is the top row. `0` is Sunday. Labels follow it. */\n weekStartDay?: number;\n /**\n * Rows per column. Seven for a calendar; use another number when the bins\n * are not weekdays — twenty-four for a grid of hours.\n */\n rows?: number;\n /**\n * The four counts at which the ramp steps up. Derived from the data's own\n * quartiles when omitted, so a chart of single digits and a chart of\n * thousands both use the whole ramp.\n */\n levels?: number[];\n /**\n * Five colours — empty, then the four activity levels. Replaces the derived\n * ramp outright. Omit it and the ramp is `--color-chart-1` at five\n * opacities, which follows the theme.\n */\n levelColors?: string[];\n /**\n * Base colour for the derived ramp — the colour the busiest cells are drawn\n * in, with the quieter levels the same colour at lower opacity.\n *\n * Takes a theme token by name as well as a literal, so `\"--color-chart-3\"`\n * recolours the chart and keeps following the theme through light and dark.\n * Defaults to `--color-chart-1`.\n */\n color?: string;\n /**\n * Colour of a cell with nothing in it. Takes a token name too. Defaults to\n * `--color-muted`, which is the right weight for \"measured, and empty\" —\n * override it for a chart that should read as denser or fainter than that.\n */\n emptyColor?: string;\n /**\n * Opacity of the base colour at each of the five levels, quietest first.\n * The way to retune the ramp's contrast without having to name five colours.\n * Ignored when `levelColors` is given, which sets the colours outright.\n */\n levelOpacity?: number[];\n /** Milliseconds for the reveal on mount. */\n animationDuration?: number;\n /** Opacity of every cell that is not the one under the finger. */\n inactiveOpacity?: number;\n /**\n * The cell under the finger as it moves, and `null` when it lifts. This is\n * how a readout above the chart gets its value — that readout is outside the\n * chart, so it cannot use `useHeatmapChart`.\n */\n onActiveCellChange?: (cell: HeatmapCell | null) => void;\n children?: ReactNode;\n}\n\nconst HeatmapChartRoot = forwardRef(function HeatmapChartRoot(\n {\n className,\n data,\n layout = 'fluid',\n binSize = 12,\n gap = 3,\n cornerRadius = 2,\n weekStartDay = 0,\n rows = DAYS_IN_WEEK,\n levels,\n levelColors,\n color,\n emptyColor,\n levelOpacity,\n animationDuration = 900,\n inactiveOpacity = 1,\n onActiveCellChange,\n accessible,\n accessibilityLabel,\n accessibilityHint,\n accessibilityLabelForDatum,\n onAccessibilityDatumPress,\n children,\n ...props\n },\n ref\n) {\n const [width, setWidth] = useState(0);\n const [activeCell, setActiveCellState] = useState(null);\n\n const reveal = useSharedValue(0);\n const reducedMotion = useReducedMotion();\n\n /*\n * `color` and `emptyColor` take a token name as readily as a literal. A\n * literal is a colour frozen at the moment it was written — it cannot follow\n * the theme into dark mode — so naming the token is almost always what the\n * caller meant, and having to resolve it themselves is the reason they did\n * not. The hooks run unconditionally; which of the two answers is used is\n * decided after.\n */\n const baseToken = useCSSVariable(isToken(color) ? color : '--color-chart-1');\n const emptyToken = useCSSVariable(\n isToken(emptyColor) ? emptyColor : '--color-muted'\n );\n const base =\n (isToken(color) ? undefined : color) ??\n (typeof baseToken === 'string' ? baseToken : '#262626');\n const empty =\n (isToken(emptyColor) ? undefined : emptyColor) ??\n (typeof emptyToken === 'string' ? emptyToken : 'rgba(128,128,128,0.16)');\n\n const parts = useMemo(() => splitParts(children), [children]);\n const labelScale = useAxisLabelScale();\n // The y-axis is laid out beside the grid rather than over it, so its width\n // has to come out of the grid's before the cells are sized.\n const axisWidth = parts.y\n ? (parts.y.props.width ?? Math.ceil(DEFAULT_AXIS_WIDTH * labelScale))\n : 0;\n\n const grid = useMemo(() => {\n const count = data.length;\n if (!count) return { size: 0, gap, columns: 0, rows, width: 0, height: 0 };\n\n const available = Math.max(width - axisWidth, 0);\n const size =\n layout === 'fill' && available > 0\n ? Math.max((available - (count - 1) * gap) / count, 1)\n : binSize;\n\n return {\n size,\n gap,\n columns: count,\n rows,\n width: count * size + (count - 1) * gap,\n height: rows * size + (rows - 1) * gap,\n };\n }, [data.length, width, axisWidth, layout, binSize, gap, rows]);\n\n const thresholds = useMemo(() => levels ?? deriveLevels(data), [levels, data]);\n\n /*\n * One colour at five opacities, unless the caller supplied five colours — in\n * which case the opacities are dropped, since dimming a colour someone chose\n * on purpose is not a ramp, it is a bug.\n */\n const { ramp, opacities } = useMemo(\n () =>\n levelColors\n ? { ramp: levelColors, opacities: levelColors.map(() => 1) }\n : {\n ramp: [empty, base, base, base, base],\n opacities: levelOpacity ?? [...LEVEL_OPACITY],\n },\n [levelColors, levelOpacity, empty, base]\n );\n\n const levelOf = useMemo(\n () => (count: number) => {\n if (count <= 0) return 0;\n let level = 1;\n for (let i = 1; i < thresholds.length; i++) {\n if (count >= thresholds[i]!) level = i + 1;\n }\n return Math.min(level, 4);\n },\n [thresholds]\n );\n\n const cellAt = useMemo(\n () => (columnIndex: number, row: number): HeatmapCell => {\n const bin = data[columnIndex]?.bins.find((candidate) => candidate.bin === row);\n const count = bin?.count ?? 0;\n return { column: columnIndex, row, count, level: levelOf(count), date: bin?.date };\n },\n [data, levelOf]\n );\n\n const accessibilityCells = useMemo(\n () =>\n data.flatMap((column) =>\n column.bins.map((bin) => ({\n column: column.bin,\n row: bin.bin,\n count: bin.count,\n level: levelOf(bin.count),\n date: bin.date,\n }))\n ),\n [data, levelOf]\n );\n\n const setActiveCell = useMemo(\n () => (cell: HeatmapCell | null) => {\n setActiveCellState(cell);\n onActiveCellChange?.(cell);\n },\n [onActiveCellChange]\n );\n\n // Plays once, when there is both a grid to reveal and data to reveal in it.\n const revealed = useRef(false);\n useEffect(() => {\n if (revealed.current || grid.width <= 0) return;\n revealed.current = true;\n if (reducedMotion) {\n reveal.value = 1;\n return;\n }\n reveal.value = withTiming(1, {\n duration: animationDuration,\n easing: Easing.out(Easing.cubic),\n });\n }, [grid.width, reducedMotion, animationDuration, reveal]);\n\n const onLayout = (event: LayoutChangeEvent) => {\n const next = event.nativeEvent.layout.width;\n setWidth((current) => (Math.abs(current - next) < 1 ? current : next));\n props.onLayout?.(event);\n };\n\n const context = useMemo(\n () => ({\n data,\n grid,\n ramp,\n opacities,\n levelOf,\n cellAt,\n cornerRadius,\n inactiveOpacity,\n weekStartDay,\n activeCell,\n setActiveCell,\n }),\n [\n data,\n grid,\n ramp,\n opacities,\n levelOf,\n cellAt,\n cornerRadius,\n inactiveOpacity,\n weekStartDay,\n activeCell,\n setActiveCell,\n ]\n );\n\n /*\n * One clip wiping left to right, rather than an animation per column. The\n * effect is the same — columns arriving in order — and it costs one animated\n * value instead of one per week, which for a year is fifty-two.\n */\n const revealStyle = useAnimatedStyle(() => ({ width: grid.width * reveal.value }));\n\n return (\n \n \n {/* Above the axis gutter as well as the grid: the header is about the\n whole chart, so it starts at the chart's edge, not the grid's. */}\n {parts.header}\n\n [\n ['date', cell.date],\n ['column', cell.column],\n ['row', cell.row],\n ['count', cell.count],\n ]}\n />\n\n {parts.x ? (\n \n {parts.x}\n \n ) : null}\n\n \n {parts.y}\n \n {grid.width > 0 ? (\n <>\n {/*\n * The reveal is a view that grows, not an SVG clip path.\n *\n * It used to be an animated `` inside ``, and on\n * Android those animated props never reach the native clip, so\n * the grid drew complete and the reveal did not play at all. A\n * view with `overflow: 'hidden'` is clipped by the platform\n * itself, which both platforms agree on.\n *\n * Animating `width` is normally a layout pass per frame. This\n * view is absolutely positioned and its only child is an\n * `` with an explicit width and height, so it is one node\n * and nothing around it moves.\n */}\n \n \n {parts.rules}\n {parts.cells}\n \n \n {parts.tooltip}\n \n ) : null}\n \n \n\n {parts.legend}\n \n \n );\n});\n\ninterface Parts {\n cells: ReactNode[];\n rules: ReactNode[];\n x: ReactNode | null;\n y: React.ReactElement<{ width?: number }> | null;\n tooltip: ReactNode[];\n legend: ReactNode[];\n header: ReactNode[];\n columns: number;\n}\n\n/** Sorts the children into the places the layout has for them. */\nfunction splitParts(children: ReactNode): Parts {\n const parts: Parts = {\n cells: [],\n rules: [],\n x: null,\n y: null,\n tooltip: [],\n legend: [],\n header: [],\n columns: 0,\n };\n\n Children.forEach(children, (child, index) => {\n if (!isValidElement(child)) return;\n const slot = (child.type as { slot?: Slot }).slot ?? 'cells';\n const keyed = {child};\n\n if (slot === 'x-axis') parts.x = keyed;\n else if (slot === 'y-axis') parts.y = child as React.ReactElement<{ width?: number }>;\n else if (slot === 'rules') parts.rules.push(keyed);\n else if (slot === 'tooltip') parts.tooltip.push(keyed);\n else if (slot === 'legend') parts.legend.push(keyed);\n else if (slot === 'header') parts.header.push(keyed);\n else parts.cells.push(keyed);\n });\n\n return parts;\n}\n\n/** Identity wrapper, purely so the partitioned arrays can carry keys. */\nfunction ChildSlot({ children }: { children: ReactNode }) {\n return <>{children};\n}\n\n/* -------------------------------------------------------------------------- */\n/* Cells */\n/* -------------------------------------------------------------------------- */\n\nexport interface HeatmapCellsProps {\n /** Corner radius of a cell. Falls back to the chart's. */\n cornerRadius?: number;\n}\n\n/**\n * The grid itself. Every row of every column is drawn, including the ones with\n * nothing in them — a calendar with holes in it stops being a calendar.\n */\nfunction HeatmapCells({ cornerRadius }: HeatmapCellsProps) {\n const chart = useHeatmap('HeatmapChart.Cells');\n const { grid, data, ramp, opacities, levelOf, activeCell, inactiveOpacity } = chart;\n const radius = cornerRadius ?? chart.cornerRadius;\n const step = grid.size + grid.gap;\n\n return (\n \n {data.map((column, columnIndex) =>\n Array.from({ length: grid.rows }, (_unused, row) => {\n const bin = column.bins.find((candidate) => candidate.bin === row);\n const level = levelOf(bin?.count ?? 0);\n const dimmed =\n activeCell !== null &&\n !(activeCell.column === columnIndex && activeCell.row === row);\n\n return (\n \n );\n })\n )}\n \n );\n}\nHeatmapCells.slot = 'cells' as Slot;\n\n/* -------------------------------------------------------------------------- */\n/* Rules */\n/* -------------------------------------------------------------------------- */\n\nexport interface HeatmapSeparatorProps {\n /**\n * `quarter` draws a rule every thirteen columns; a number draws one every\n * that many columns.\n */\n every?: 'quarter' | number;\n color?: string;\n /** Dash pattern, e.g. `\"2,4\"`. Omit for a solid rule. */\n dashArray?: string;\n}\n\n/** Vertical rules grouping the columns — quarters, months, sprints. */\nfunction HeatmapSeparator({ every = 'quarter', color, dashArray }: HeatmapSeparatorProps) {\n const { grid } = useHeatmap('HeatmapChart.Separator');\n const token = useCSSVariable('--color-border');\n const stroke = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.3)');\n const interval = every === 'quarter' ? 13 : every;\n const step = grid.size + grid.gap;\n\n if (interval <= 0) return null;\n\n const lines: number[] = [];\n for (let column = interval; column < grid.columns; column += interval) {\n lines.push(column);\n }\n\n return (\n \n {lines.map((column) => {\n const x = column * step - grid.gap / 2;\n return (\n \n );\n })}\n \n );\n}\nHeatmapSeparator.slot = 'rules' as Slot;\n\n/* -------------------------------------------------------------------------- */\n/* Axes */\n/* -------------------------------------------------------------------------- */\n\nexport interface HeatmapXAxisProps {\n className?: string;\n /**\n * Label a column. Given the first dated bin in it, so a month name can be\n * derived. Return an empty string to leave the column unlabelled.\n */\n formatLabel?: (date: Date, column: number) => string;\n /**\n * Column labels, left to right. Overrides the month names — for a grid whose\n * columns are not weeks, where there is no month to change and so nothing to\n * emit a label on.\n */\n labels?: string[];\n}\n\n/**\n * Month labels above the grid.\n *\n * A label is emitted where the month changes rather than at a fixed interval,\n * because months are not the same length — spacing them evenly puts \"Mar\" over\n * a week in February. A grid whose columns are not weeks has no such signal, so\n * it passes `labels` and gets one over every column.\n */\nfunction HeatmapXAxis({ className, formatLabel, labels: given }: HeatmapXAxisProps) {\n const { data, grid } = useHeatmap('HeatmapChart.XAxis');\n const step = grid.size + grid.gap;\n const scale = useAxisLabelScale();\n\n const labels = useMemo(() => {\n if (given) {\n return given\n .slice(0, data.length)\n .map((label, column) => ({ column, label }))\n .filter((entry) => entry.label);\n }\n\n const out: { column: number; label: string }[] = [];\n let lastMonth = -1;\n\n data.forEach((column, index) => {\n const date = column.bins.find((bin) => bin.date)?.date;\n if (!date) return;\n const month = date.getMonth();\n if (month === lastMonth) return;\n lastMonth = month;\n const label = formatLabel ? formatLabel(date, index) : MONTHS[month]!;\n if (label) out.push({ column: index, label });\n });\n\n // The first month is usually a stub of a week or two, and a label over it\n // collides with the next one. Drop it when it has no room.\n if (out.length > 1 && out[1]!.column - out[0]!.column < 3) out.shift();\n return out;\n }, [data, formatLabel, given]);\n\n return (\n 1 && { height: Math.ceil(X_AXIS_HEIGHT * scale) }]}\n >\n {labels.map(({ column, label }) => (\n \n {label}\n \n ))}\n \n );\n}\nHeatmapXAxis.slot = 'x-axis' as Slot;\n\nexport interface HeatmapYAxisProps {\n className?: string;\n /**\n * Width reserved for the labels. The grid is sized around it. Defaults to 26,\n * and widens with the system text size so a larger weekday does not wrap.\n */\n width?: number;\n /** Which rows get a label. Every other row is the usual choice. */\n tickFilter?: 'all' | 'odd' | 'even';\n /** `initial` is the single letter; `full` is the abbreviated name. */\n labelFormat?: 'initial' | 'full';\n /**\n * Row labels, top to bottom. Overrides the weekday names — for a grid whose\n * rows are not days.\n */\n labels?: string[];\n}\n\n/** Weekday labels down the left of the grid. */\nfunction HeatmapYAxis({\n className,\n width: widthProp,\n tickFilter = 'odd',\n labelFormat = 'full',\n labels,\n}: HeatmapYAxisProps) {\n const { grid, weekStartDay } = useHeatmap('HeatmapChart.YAxis');\n const step = grid.size + grid.gap;\n const scale = useAxisLabelScale();\n const width = widthProp ?? Math.ceil(DEFAULT_AXIS_WIDTH * scale);\n\n return (\n \n {Array.from({ length: grid.rows }, (_unused, row) => {\n if (tickFilter === 'odd' && row % 2 === 0) return null;\n if (tickFilter === 'even' && row % 2 === 1) return null;\n\n const name = labels\n ? (labels[row] ?? '')\n : WEEKDAYS[(row + weekStartDay) % DAYS_IN_WEEK]!;\n return (\n \n {labels\n ? name\n : labelFormat === 'initial'\n ? name.slice(0, 1)\n : name.slice(0, 3)}\n \n );\n })}\n \n );\n}\nHeatmapYAxis.slot = 'y-axis' as Slot;\n\n/* -------------------------------------------------------------------------- */\n/* Tooltip */\n/* -------------------------------------------------------------------------- */\n\nexport interface HeatmapTooltipProps {\n className?: string;\n /** The line shown for a cell. Defaults to the count and the date. */\n formatLabel?: (cell: HeatmapCell) => string;\n /**\n * How long a press has to be held before the readout takes over, in\n * milliseconds.\n *\n * It is not zero, and cannot be: a full year of columns lives inside a\n * horizontal scroller, and a readout that claims the touch on the first pixel\n * of movement means the chart can never be scrolled. Holding first is what\n * separates \"I am moving the chart\" from \"I am reading it\". Set `0` only for\n * a chart that is not inside a scroll view at all.\n */\n activateAfterLongPress?: number;\n}\n\n/**\n * A readout following the finger across the grid.\n *\n * It lives in the view layer, over the SVG: a gesture handler cannot be\n * attached to an SVG node, and SVG text ignores the platform's text scaling.\n * The cell under the finger is resolved on the UI thread and only crosses back\n * into JS when it changes, so a drag across a year costs a handful of\n * re-renders rather than one per frame.\n */\nfunction HeatmapTooltip({\n className,\n formatLabel,\n activateAfterLongPress = DEFAULT_HOLD,\n}: HeatmapTooltipProps) {\n const { grid, activeCell, setActiveCell, cellAt } = useHeatmap(\n 'HeatmapChart.Tooltip'\n );\n const step = grid.size + grid.gap;\n\n // Mirrored for the worklet, which cannot read the JS closure's latest value.\n const lastKey = useSharedValue(-1);\n\n const resolve = useMemo(\n () => (column: number, row: number) => setActiveCell(cellAt(column, row)),\n [cellAt, setActiveCell]\n );\n const clear = useMemo(() => () => setActiveCell(null), [setActiveCell]);\n\n /*\n * The cell is resolved here, on the UI thread, and only crosses into JS when\n * it *changes*. Sending every frame's coordinates over and picking the cell\n * in React would cost a re-render per frame for a value that changes maybe\n * fifty times across a whole drag.\n */\n const pick = (x: number, y: number) => {\n 'worklet';\n const column = Math.floor(x / step);\n const row = Math.floor(y / step);\n if (column < 0 || column >= grid.columns || row < 0 || row >= grid.rows) return;\n const key = column * grid.rows + row;\n if (key === lastKey.value) return;\n lastKey.value = key;\n runOnJS(resolve)(column, row);\n };\n\n /*\n * The readout takes over only once the press has been held.\n *\n * `onStart` rather than `onBegin` is the other half of it: `onBegin` fires on\n * touch-down whatever happens next, so picking there would light a cell and\n * dim the rest of the grid for the first moment of every scroll swipe — the\n * gesture would yield correctly and still leave a flicker behind it.\n */\n const pan = Gesture.Pan()\n .minDistance(0)\n .activateAfterLongPress(activateAfterLongPress)\n .onStart((event) => {\n 'worklet';\n pick(event.x, event.y);\n })\n .onUpdate((event) => {\n 'worklet';\n pick(event.x, event.y);\n })\n .onFinalize(() => {\n 'worklet';\n // Fires whether or not the gesture ever activated, so a swipe that only\n // scrolled must not report a cell change it never made.\n if (lastKey.value === -1) return;\n lastKey.value = -1;\n runOnJS(clear)();\n });\n\n const label = activeCell\n ? formatLabel\n ? formatLabel(activeCell)\n : defaultTooltipLabel(activeCell)\n : null;\n\n // Flipped to the left of the finger past the halfway mark, so the readout\n // never runs off the edge it is closest to — and dropped below the cell in\n // the top two rows, where there is no room above it inside the chart.\n const anchorX = activeCell ? activeCell.column * step + grid.size / 2 : 0;\n const flipped = anchorX > grid.width / 2;\n const below = (activeCell?.row ?? 0) < 2;\n const anchorY = activeCell\n ? below\n ? activeCell.row * step + grid.size + 6\n : activeCell.row * step - TOOLTIP_HEIGHT - 6\n : 0;\n\n return (\n <>\n \n \n \n\n {activeCell && label ? (\n \n \n {label}\n \n \n ) : null}\n \n );\n}\nHeatmapTooltip.slot = 'tooltip' as Slot;\n\n/*\n * A cell with no date is not a cell with no data — it is a cell in a grid that\n * is not a calendar, and it has a count like any other. Saying \"No data\" over\n * it reports a hole in the data that is not there. Without a date there is\n * simply nothing to say after the number, so the number is the whole label,\n * and a grid that wants a noun for it passes `formatLabel`.\n */\nfunction defaultTooltipLabel(cell: HeatmapCell) {\n if (!cell.date) return `${cell.count}`;\n const count = `${cell.count} ${cell.count === 1 ? 'contribution' : 'contributions'}`;\n const date = `${MONTHS[cell.date.getMonth()]} ${cell.date.getDate()}`;\n return `${count} on ${date}`;\n}\n\n/* -------------------------------------------------------------------------- */\n/* Legend */\n/* -------------------------------------------------------------------------- */\n\nexport interface HeatmapLegendProps {\n className?: string;\n /** Text at the low end of the ramp. */\n lessLabel?: string;\n /** Text at the high end. */\n moreLabel?: string;\n /** Side of a swatch, in pixels. Defaults to the chart's cell size. */\n swatchSize?: number;\n}\n\n/** The `Less ▢▢▢▢▢ More` key, under the grid. */\nfunction HeatmapLegend({\n className,\n lessLabel = 'Less',\n moreLabel = 'More',\n swatchSize,\n}: HeatmapLegendProps) {\n const { ramp, opacities, grid, cornerRadius } = useHeatmap('HeatmapChart.Legend');\n const size = swatchSize ?? Math.max(grid.size, 10);\n\n return (\n \n \n \n {lessLabel}\n \n {ramp.map((fill, level) => (\n \n \n \n ))}\n \n {moreLabel}\n \n \n );\n}\nHeatmapLegend.slot = 'legend' as Slot;\n\n/* -------------------------------------------------------------------------- */\n/* Header */\n/* -------------------------------------------------------------------------- */\n\nexport interface HeatmapHeaderProps extends ViewProps {\n className?: string;\n /** Small line above the value — what the grid is of. */\n title?: string;\n /** The readout. The largest thing on the card, and the first thing read. */\n value?: string;\n /** One muted line under the value — a period, a total, the held cell. */\n caption?: string;\n /**\n * Draw the ramp along the trailing edge, `Less ▢▢▢▢▢ More`. The key for a\n * grid that scrolls sideways, where `HeatmapChart.Legend` under the cells\n * would scroll away with them.\n */\n legend?: boolean;\n /** Text at the low end of the ramp, when `legend` is set. */\n lessLabel?: string;\n /** Text at the high end. */\n moreLabel?: string;\n /** Trailing slot — a control, a badge, a range picker. Wins over `legend`. */\n children?: ReactNode;\n}\n\n/**\n * The strip above the grid: what the chart is of, what it currently reads, and\n * what the shading means.\n *\n * It belongs to the chart rather than to the card around it because it is about\n * the *grid* — the number changes as a finger moves across the cells, and the\n * ramp is the scale the chart itself derived. The card's header is a caption on\n * the tray the chart sits in; this is the chart introducing itself.\n *\n * The value is not derived here. Take it from `onActiveCellChange` and pass the\n * formatted string down, so one header can show a total when nothing is held\n * and a day's own count when something is.\n */\nfunction HeatmapHeader({\n className,\n title,\n value,\n caption,\n legend = false,\n lessLabel = 'Less',\n moreLabel = 'More',\n children,\n ...props\n}: HeatmapHeaderProps) {\n const { ramp, opacities, grid, cornerRadius } = useHeatmap('HeatmapChart.Header');\n // Small enough to sit on one line beside the text, whatever the cells are.\n const size = Math.max(Math.min(grid.size, 12), 8);\n\n const trailing =\n children ??\n (legend ? (\n \n \n {lessLabel}\n \n {ramp.map((fill, level) => (\n \n \n \n ))}\n \n {moreLabel}\n \n \n ) : null);\n\n return (\n \n \n {title ? (\n \n {title}\n \n ) : null}\n {value ? (\n \n {value}\n \n ) : null}\n {caption ? (\n \n {caption}\n \n ) : null}\n \n {/* Shrinkable, unlike a view's default in React Native — held rigid, the\n ramp takes the width it wants and the caption wraps around it. */}\n {trailing ? {trailing} : null}\n \n );\n}\nHeatmapHeader.displayName = 'HeatmapChart.Header';\nHeatmapHeader.slot = 'header' as Slot;\n\nexport const HeatmapChart = Object.assign(HeatmapChartRoot, {\n Header: HeatmapHeader,\n Cells: HeatmapCells,\n Separator: HeatmapSeparator,\n XAxis: HeatmapXAxis,\n YAxis: HeatmapYAxis,\n Tooltip: HeatmapTooltip,\n Legend: HeatmapLegend,\n});\n" + "content": "/**\n * HeatmapChart — a calendar of bins, shaded by how much happened in each.\n *\n * The contribution grid: one column per period (usually a week), one row per\n * bin inside it (usually a weekday), and a colour ramp carrying the count. It\n * answers \"when was this busy\" at a glance, which no line can — a year of daily\n * numbers plotted as a series is a hairball, and as a grid it is a pattern.\n *\n * Composed rather than configured, so a chart that wants no axis simply does\n * not have one:\n *\n * ```tsx\n * \n * \n * \n * \n * \n * \n * \n * \n * ```\n *\n * The parts sort themselves into a real layout rather than stacking over the\n * plot. That is the difference from a line chart, where the axis floats over\n * the drawing: here the labels and the legend sit *beside* and *below* the\n * grid, so they take up room, and the grid is sized with them accounted for.\n * Only the cells and the rules between them are SVG; every label is a React\n * Native view, because SVG text ignores the platform's text scaling and the\n * theme's font.\n *\n * The ramp is one colour at five opacities rather than five colours. A heatmap\n * reads as *more* and *less* of one thing, and five distinct hues read as five\n * different things — which is what the `--color-chart-*` tokens are for, and\n * why they are not used here. The base is `--color-chart-1`, so the ramp\n * follows the theme, and `levelColors` replaces it outright when a brand needs\n * its own.\n */\nimport {\n Children,\n createContext,\n forwardRef,\n isValidElement,\n useContext,\n useEffect,\n useId,\n useMemo,\n useRef,\n useState,\n type ReactNode,\n} from 'react';\nimport {\n StyleSheet,\n useWindowDimensions,\n View,\n type LayoutChangeEvent,\n type ViewProps,\n} from 'react-native';\nimport { Gesture, GestureDetector } from 'react-native-gesture-handler';\nimport Animated, {\n Easing,\n runOnJS,\n useAnimatedProps,\n useAnimatedStyle,\n useReducedMotion,\n useSharedValue,\n withTiming,\n} from 'react-native-reanimated';\nimport Svg, { G, Line as SvgLine, Rect } from 'react-native-svg';\nimport { useCSSVariable } from 'uniwind';\nimport { Text } from '@/components/ui/text';\nimport {\n CHART_AXIS_MAX_FONT_SCALE,\n ChartAccessibilityData,\n type ChartAccessibilityProps,\n} from '@/components/ui/chart-accessibility';\nimport { cn } from '@/lib/cn';\nimport { normalizeWeekStart, startOfDay } from '@/lib/date';\nimport type { ViewRef } from '@/components/ui/refs';\n\n\n/**\n * Rows in a week — the default, and what the calendar helper and the weekday\n * labels assume. The grid itself takes a `rows` prop, so a grid whose bins are\n * hours rather than days is the same component with a different number.\n */\nconst DAYS_IN_WEEK = 7;\n\n/** Opacity of the base colour at each activity level. Index 0 is \"nothing\". */\nconst LEVEL_OPACITY = [1, 0.28, 0.5, 0.74, 1] as const;\n\n/**\n * A theme token rather than a colour. Tokens are named, not written, so the\n * leading `--` tells the two apart without the caller having to say which\n * kind they passed.\n */\nfunction isToken(value: string | undefined): value is string {\n return typeof value === 'string' && value.startsWith('--');\n}\n\n/** Room left for the weekday labels when the y-axis does not ask for its own. */\nconst DEFAULT_AXIS_WIDTH = 26;\n\n/** Height of the month row at the default text size: one line of `xs` text. */\nconst X_AXIS_HEIGHT = 16;\n\n/**\n * How much larger than default the axis labels are drawn, from 1 up to the\n * axis cap. The heatmap's gutters are only just wide enough for a weekday at\n * the default size, so they grow by the same factor or a larger label wraps\n * and runs into the cells. Below the default size they stay put.\n */\nfunction useAxisLabelScale() {\n const { fontScale } = useWindowDimensions();\n return Math.min(Math.max(fontScale, 1), CHART_AXIS_MAX_FONT_SCALE);\n}\n\n/**\n * Height assumed for the readout when placing it above a cell. It is a single\n * line of `xs` text in a padded box, so it does not vary — and measuring it\n * would put the tooltip a frame behind the finger.\n */\nconst TOOLTIP_HEIGHT = 26;\n\n/**\n * How long the readout waits before claiming the touch, in milliseconds.\n *\n * Long enough that a swipe scrolls the chart instead of reading it, short\n * enough that a deliberate press does not feel like it was ignored.\n */\nconst DEFAULT_HOLD = 180;\n\n/** Weekday names, indexed from Sunday, as the `Date` API numbers them. */\nconst WEEKDAYS = [\n 'Sunday',\n 'Monday',\n 'Tuesday',\n 'Wednesday',\n 'Thursday',\n 'Friday',\n 'Saturday',\n] as const;\n\nconst MONTHS = [\n 'Jan',\n 'Feb',\n 'Mar',\n 'Apr',\n 'May',\n 'Jun',\n 'Jul',\n 'Aug',\n 'Sep',\n 'Oct',\n 'Nov',\n 'Dec',\n] as const;\n\n/**\n * Where a part belongs in the layout. Read off the component itself, so\n * composition stays a flat list of children instead of four nested slots the\n * caller has to remember the order of.\n */\ntype Slot = 'cells' | 'x-axis' | 'y-axis' | 'rules' | 'tooltip' | 'legend' | 'header';\n\nexport type HeatmapLayout = 'fluid' | 'fill';\n\n/** One bin inside a column — usually a single day. */\nexport interface HeatmapBin {\n /** Row index within the column, `0` to `6`. */\n bin: number;\n /** How much happened. The ramp is derived from these across the whole chart. */\n count: number;\n /** The day this bin stands for. Used by the axis labels and the tooltip. */\n date?: Date;\n}\n\n/** One column — usually a week. Missing bins are drawn as empty cells. */\nexport interface HeatmapColumn {\n /** Column index across the chart. */\n bin: number;\n bins: HeatmapBin[];\n}\n\n/** A cell resolved to its place in the grid, as the tooltip receives it. */\nexport interface HeatmapCell {\n column: number;\n row: number;\n count: number;\n level: number;\n date?: Date;\n}\n\ninterface Grid {\n /** Side of one cell, in pixels. */\n size: number;\n gap: number;\n columns: number;\n rows: number;\n width: number;\n height: number;\n}\n\ninterface HeatmapContextValue {\n data: HeatmapColumn[];\n grid: Grid;\n /** `[level0, level1, …level4]`, already resolved to colours. */\n ramp: string[];\n /** Opacity to paint each level's colour at. All ones for a supplied ramp. */\n opacities: number[];\n levelOf: (count: number) => number;\n cellAt: (column: number, row: number) => HeatmapCell;\n cornerRadius: number;\n inactiveOpacity: number;\n weekStartDay: number;\n activeCell: HeatmapCell | null;\n setActiveCell: (cell: HeatmapCell | null) => void;\n}\n\nconst HeatmapContext = createContext(null);\n\nfunction useHeatmap(component: string): HeatmapContextValue {\n const context = useContext(HeatmapContext);\n if (!context) {\n throw new Error(`${component} must be used within a `);\n }\n return context;\n}\n\n/**\n * The cell under the finger, for something rendered *inside* the chart. A\n * readout in the card's header is outside this provider — use\n * `onActiveCellChange` for that.\n */\nexport function useHeatmapChart() {\n const { activeCell } = useHeatmap('useHeatmapChart');\n return { activeCell };\n}\n\n/* -------------------------------------------------------------------------- */\n/* Data helpers */\n/* -------------------------------------------------------------------------- */\n\n/**\n * Build a year of columns from a flat list of dated counts.\n *\n * Every heatmap starts as \"I have some dates and some numbers\", and the\n * bucketing into weeks is the same arithmetic every time — including the two\n * parts that are easy to get wrong: the leading blanks before the first day of\n * the first week, and days with no entry at all, which must still be drawn as\n * empty cells or the calendar develops holes.\n */\nexport function buildHeatmapCalendar(\n entries: { date: Date; count: number }[],\n options: { start?: Date; end?: Date; weekStartDay?: number } = {}\n): HeatmapColumn[] {\n const weekStartDay = normalizeWeekStart(options.weekStartDay ?? 0);\n if (!entries.length && !options.start) return [];\n\n const times = entries.map((entry) => entry.date.getTime());\n // Both bounds at local midnight, and both the same way. Comparing a\n // normalised lower bound against a raw upper one drops the last day whenever\n // the caller's `end` carries a time earlier than the cell being tested.\n const start = startOfDay(options.start ?? new Date(Math.min(...times)));\n const end = startOfDay(options.end ?? new Date(Math.max(...times)));\n\n const byDay = new Map();\n for (const entry of entries) {\n const key = dayKey(entry.date);\n byDay.set(key, (byDay.get(key) ?? 0) + entry.count);\n }\n\n // Back up to the first day of the week the range starts in, so column 0 is a\n // whole week and every row lines up with a weekday for the rest of the chart.\n const cursor = startOfDay(start);\n cursor.setDate(cursor.getDate() - ((cursor.getDay() - weekStartDay + 7) % 7));\n\n const columns: HeatmapColumn[] = [];\n let column: HeatmapBin[] = [];\n\n while (cursor <= end || column.length) {\n const row = (cursor.getDay() - weekStartDay + 7) % 7;\n const date = new Date(cursor);\n // Before the range starts and after it ends the cell exists but has no\n // reading — `count: 0` and no date, so the tooltip stays quiet on it.\n const inRange = date >= start && date <= end;\n column.push({\n bin: row,\n count: inRange ? (byDay.get(dayKey(date)) ?? 0) : 0,\n date: inRange ? date : undefined,\n });\n\n if (row === DAYS_IN_WEEK - 1) {\n columns.push({ bin: columns.length, bins: column });\n column = [];\n if (cursor > end) break;\n }\n cursor.setDate(cursor.getDate() + 1);\n }\n\n return columns;\n}\n\nfunction dayKey(date: Date) {\n return `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`;\n}\n\n/**\n * Four thresholds from the data, so a chart of single-digit counts and a chart\n * of thousands both use the whole ramp. Quartiles of the *non-zero* counts:\n * zero is its own level, and counting it would drag every threshold down to\n * nothing on a sparse chart.\n */\nfunction deriveLevels(data: HeatmapColumn[]): number[] {\n const counts: number[] = [];\n for (const column of data) {\n for (const bin of column.bins) {\n if (bin.count > 0) counts.push(bin.count);\n }\n }\n if (!counts.length) return [1, 2, 3, 4];\n counts.sort((a, b) => a - b);\n const at = (fraction: number) =>\n counts[Math.min(counts.length - 1, Math.floor(counts.length * fraction))]!;\n const thresholds = [1, at(0.25), at(0.5), at(0.75)];\n // Ties collapse the ramp — nudge each threshold past the one below it so\n // four distinct levels stay four distinct levels.\n for (let i = 1; i < thresholds.length; i++) {\n if (thresholds[i]! <= thresholds[i - 1]!) thresholds[i] = thresholds[i - 1]! + 1;\n }\n return thresholds;\n}\n\n/* -------------------------------------------------------------------------- */\n/* Root */\n/* -------------------------------------------------------------------------- */\n\nexport interface HeatmapChartProps extends ViewProps, ChartAccessibilityProps {\n className?: string;\n /** One column per period, with its row bins inside. */\n data: HeatmapColumn[];\n /**\n * `fluid` draws cells at `binSize` and lets the grid be as wide as it needs\n * to be — put it in a horizontal `ScrollView` for a full year. `fill`\n * divides the available width between the columns instead.\n */\n layout?: HeatmapLayout;\n /** Side of one cell in `fluid` layout, in pixels. */\n binSize?: number;\n /** Space between cells, in pixels. */\n gap?: number;\n /** Corner radius of a cell. */\n cornerRadius?: number;\n /** Which weekday is the top row. `0` is Sunday. Labels follow it. */\n weekStartDay?: number;\n /**\n * Rows per column. Seven for a calendar; use another number when the bins\n * are not weekdays — twenty-four for a grid of hours.\n */\n rows?: number;\n /**\n * The four counts at which the ramp steps up. Derived from the data's own\n * quartiles when omitted, so a chart of single digits and a chart of\n * thousands both use the whole ramp.\n */\n levels?: number[];\n /**\n * Five colours — empty, then the four activity levels. Replaces the derived\n * ramp outright. Omit it and the ramp is `--color-chart-1` at five\n * opacities, which follows the theme.\n */\n levelColors?: string[];\n /**\n * Base colour for the derived ramp — the colour the busiest cells are drawn\n * in, with the quieter levels the same colour at lower opacity.\n *\n * Takes a theme token by name as well as a literal, so `\"--color-chart-3\"`\n * recolours the chart and keeps following the theme through light and dark.\n * Defaults to `--color-chart-1`.\n */\n color?: string;\n /**\n * Colour of a cell with nothing in it. Takes a token name too. Defaults to\n * `--color-muted`, which is the right weight for \"measured, and empty\" —\n * override it for a chart that should read as denser or fainter than that.\n */\n emptyColor?: string;\n /**\n * Opacity of the base colour at each of the five levels, quietest first.\n * The way to retune the ramp's contrast without having to name five colours.\n * Ignored when `levelColors` is given, which sets the colours outright.\n */\n levelOpacity?: number[];\n /** Milliseconds for the reveal on mount. */\n animationDuration?: number;\n /** Opacity of every cell that is not the one under the finger. */\n inactiveOpacity?: number;\n /**\n * The cell under the finger as it moves, and `null` when it lifts. This is\n * how a readout above the chart gets its value — that readout is outside the\n * chart, so it cannot use `useHeatmapChart`.\n */\n onActiveCellChange?: (cell: HeatmapCell | null) => void;\n children?: ReactNode;\n}\n\nconst HeatmapChartRoot = forwardRef(function HeatmapChartRoot(\n {\n className,\n data,\n layout = 'fluid',\n binSize = 12,\n gap = 3,\n cornerRadius = 2,\n weekStartDay = 0,\n rows = DAYS_IN_WEEK,\n levels,\n levelColors,\n color,\n emptyColor,\n levelOpacity,\n animationDuration = 900,\n inactiveOpacity = 1,\n onActiveCellChange,\n accessible,\n accessibilityLabel,\n accessibilityHint,\n accessibilityLabelForDatum,\n onAccessibilityDatumPress,\n children,\n ...props\n },\n ref\n) {\n const [width, setWidth] = useState(0);\n const [activeCell, setActiveCellState] = useState(null);\n\n const reveal = useSharedValue(0);\n const reducedMotion = useReducedMotion();\n\n /*\n * `color` and `emptyColor` take a token name as readily as a literal. A\n * literal is a colour frozen at the moment it was written — it cannot follow\n * the theme into dark mode — so naming the token is almost always what the\n * caller meant, and having to resolve it themselves is the reason they did\n * not. The hooks run unconditionally; which of the two answers is used is\n * decided after.\n */\n const baseToken = useCSSVariable(isToken(color) ? color : '--color-chart-1');\n const emptyToken = useCSSVariable(\n isToken(emptyColor) ? emptyColor : '--color-muted'\n );\n const base =\n (isToken(color) ? undefined : color) ??\n (typeof baseToken === 'string' ? baseToken : '#262626');\n const empty =\n (isToken(emptyColor) ? undefined : emptyColor) ??\n (typeof emptyToken === 'string' ? emptyToken : 'rgba(128,128,128,0.16)');\n\n const parts = useMemo(() => splitParts(children), [children]);\n const labelScale = useAxisLabelScale();\n // The y-axis is laid out beside the grid rather than over it, so its width\n // has to come out of the grid's before the cells are sized.\n const axisWidth = parts.y\n ? (parts.y.props.width ?? Math.ceil(DEFAULT_AXIS_WIDTH * labelScale))\n : 0;\n\n const grid = useMemo(() => {\n const count = data.length;\n if (!count) return { size: 0, gap, columns: 0, rows, width: 0, height: 0 };\n\n const available = Math.max(width - axisWidth, 0);\n const size =\n layout === 'fill' && available > 0\n ? Math.max((available - (count - 1) * gap) / count, 1)\n : binSize;\n\n return {\n size,\n gap,\n columns: count,\n rows,\n width: count * size + (count - 1) * gap,\n height: rows * size + (rows - 1) * gap,\n };\n }, [data.length, width, axisWidth, layout, binSize, gap, rows]);\n\n const thresholds = useMemo(() => levels ?? deriveLevels(data), [levels, data]);\n\n /*\n * One colour at five opacities, unless the caller supplied five colours — in\n * which case the opacities are dropped, since dimming a colour someone chose\n * on purpose is not a ramp, it is a bug.\n */\n const { ramp, opacities } = useMemo(\n () =>\n levelColors\n ? { ramp: levelColors, opacities: levelColors.map(() => 1) }\n : {\n ramp: [empty, base, base, base, base],\n opacities: levelOpacity ?? [...LEVEL_OPACITY],\n },\n [levelColors, levelOpacity, empty, base]\n );\n\n const levelOf = useMemo(\n () => (count: number) => {\n if (count <= 0) return 0;\n let level = 1;\n for (let i = 1; i < thresholds.length; i++) {\n if (count >= thresholds[i]!) level = i + 1;\n }\n return Math.min(level, 4);\n },\n [thresholds]\n );\n\n const cellAt = useMemo(\n () => (columnIndex: number, row: number): HeatmapCell => {\n const bin = data[columnIndex]?.bins.find((candidate) => candidate.bin === row);\n const count = bin?.count ?? 0;\n return { column: columnIndex, row, count, level: levelOf(count), date: bin?.date };\n },\n [data, levelOf]\n );\n\n const accessibilityCells = useMemo(\n () =>\n data.flatMap((column) =>\n column.bins.map((bin) => ({\n column: column.bin,\n row: bin.bin,\n count: bin.count,\n level: levelOf(bin.count),\n date: bin.date,\n }))\n ),\n [data, levelOf]\n );\n\n const setActiveCell = useMemo(\n () => (cell: HeatmapCell | null) => {\n setActiveCellState(cell);\n onActiveCellChange?.(cell);\n },\n [onActiveCellChange]\n );\n\n // Plays once, when there is both a grid to reveal and data to reveal in it.\n const revealed = useRef(false);\n useEffect(() => {\n if (revealed.current || grid.width <= 0) return;\n revealed.current = true;\n if (reducedMotion) {\n reveal.value = 1;\n return;\n }\n reveal.value = withTiming(1, {\n duration: animationDuration,\n easing: Easing.out(Easing.cubic),\n });\n }, [grid.width, reducedMotion, animationDuration, reveal]);\n\n const onLayout = (event: LayoutChangeEvent) => {\n const next = event.nativeEvent.layout.width;\n setWidth((current) => (Math.abs(current - next) < 1 ? current : next));\n props.onLayout?.(event);\n };\n\n const context = useMemo(\n () => ({\n data,\n grid,\n ramp,\n opacities,\n levelOf,\n cellAt,\n cornerRadius,\n inactiveOpacity,\n weekStartDay,\n activeCell,\n setActiveCell,\n }),\n [\n data,\n grid,\n ramp,\n opacities,\n levelOf,\n cellAt,\n cornerRadius,\n inactiveOpacity,\n weekStartDay,\n activeCell,\n setActiveCell,\n ]\n );\n\n /*\n * One clip wiping left to right, rather than an animation per column. The\n * effect is the same — columns arriving in order — and it costs one animated\n * value instead of one per week, which for a year is fifty-two.\n */\n const revealStyle = useAnimatedStyle(() => ({ width: grid.width * reveal.value }));\n\n return (\n \n \n {/* Above the axis gutter as well as the grid: the header is about the\n whole chart, so it starts at the chart's edge, not the grid's. */}\n {parts.header}\n\n [\n ['date', cell.date],\n ['column', cell.column],\n ['row', cell.row],\n ['count', cell.count],\n ]}\n />\n\n {parts.x ? (\n \n {parts.x}\n \n ) : null}\n\n \n {parts.y}\n \n {grid.width > 0 ? (\n <>\n {/*\n * The reveal is a view that grows, not an SVG clip path.\n *\n * It used to be an animated `` inside ``, and on\n * Android those animated props never reach the native clip, so\n * the grid drew complete and the reveal did not play at all. A\n * view with `overflow: 'hidden'` is clipped by the platform\n * itself, which both platforms agree on.\n *\n * Animating `width` is normally a layout pass per frame. This\n * view is absolutely positioned and its only child is an\n * `` with an explicit width and height, so it is one node\n * and nothing around it moves.\n */}\n \n \n {parts.rules}\n {parts.cells}\n \n \n {parts.tooltip}\n \n ) : null}\n \n \n\n {parts.legend}\n \n \n );\n});\n\ninterface Parts {\n cells: ReactNode[];\n rules: ReactNode[];\n x: ReactNode | null;\n y: React.ReactElement<{ width?: number }> | null;\n tooltip: ReactNode[];\n legend: ReactNode[];\n header: ReactNode[];\n columns: number;\n}\n\n/** Sorts the children into the places the layout has for them. */\nfunction splitParts(children: ReactNode): Parts {\n const parts: Parts = {\n cells: [],\n rules: [],\n x: null,\n y: null,\n tooltip: [],\n legend: [],\n header: [],\n columns: 0,\n };\n\n Children.forEach(children, (child, index) => {\n if (!isValidElement(child)) return;\n const slot = (child.type as { slot?: Slot }).slot ?? 'cells';\n const keyed = {child};\n\n if (slot === 'x-axis') parts.x = keyed;\n else if (slot === 'y-axis') parts.y = child as React.ReactElement<{ width?: number }>;\n else if (slot === 'rules') parts.rules.push(keyed);\n else if (slot === 'tooltip') parts.tooltip.push(keyed);\n else if (slot === 'legend') parts.legend.push(keyed);\n else if (slot === 'header') parts.header.push(keyed);\n else parts.cells.push(keyed);\n });\n\n return parts;\n}\n\n/** Identity wrapper, purely so the partitioned arrays can carry keys. */\nfunction ChildSlot({ children }: { children: ReactNode }) {\n return <>{children};\n}\n\n/* -------------------------------------------------------------------------- */\n/* Cells */\n/* -------------------------------------------------------------------------- */\n\nexport interface HeatmapCellsProps {\n /** Corner radius of a cell. Falls back to the chart's. */\n cornerRadius?: number;\n}\n\n/**\n * The grid itself. Every row of every column is drawn, including the ones with\n * nothing in them — a calendar with holes in it stops being a calendar.\n */\nfunction HeatmapCells({ cornerRadius }: HeatmapCellsProps) {\n const chart = useHeatmap('HeatmapChart.Cells');\n const { grid, data, ramp, opacities, levelOf, activeCell, inactiveOpacity } = chart;\n const radius = cornerRadius ?? chart.cornerRadius;\n const step = grid.size + grid.gap;\n\n return (\n \n {data.map((column, columnIndex) =>\n Array.from({ length: grid.rows }, (_unused, row) => {\n const bin = column.bins.find((candidate) => candidate.bin === row);\n const level = levelOf(bin?.count ?? 0);\n const dimmed =\n activeCell !== null &&\n !(activeCell.column === columnIndex && activeCell.row === row);\n\n return (\n \n );\n })\n )}\n \n );\n}\nHeatmapCells.slot = 'cells' as Slot;\n\n/* -------------------------------------------------------------------------- */\n/* Rules */\n/* -------------------------------------------------------------------------- */\n\nexport interface HeatmapSeparatorProps {\n /**\n * `quarter` draws a rule every thirteen columns; a number draws one every\n * that many columns.\n */\n every?: 'quarter' | number;\n color?: string;\n /** Dash pattern, e.g. `\"2,4\"`. Omit for a solid rule. */\n dashArray?: string;\n}\n\n/** Vertical rules grouping the columns — quarters, months, sprints. */\nfunction HeatmapSeparator({ every = 'quarter', color, dashArray }: HeatmapSeparatorProps) {\n const { grid } = useHeatmap('HeatmapChart.Separator');\n const token = useCSSVariable('--color-border');\n const stroke = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.3)');\n const interval = every === 'quarter' ? 13 : every;\n const step = grid.size + grid.gap;\n\n if (interval <= 0) return null;\n\n const lines: number[] = [];\n for (let column = interval; column < grid.columns; column += interval) {\n lines.push(column);\n }\n\n return (\n \n {lines.map((column) => {\n const x = column * step - grid.gap / 2;\n return (\n \n );\n })}\n \n );\n}\nHeatmapSeparator.slot = 'rules' as Slot;\n\n/* -------------------------------------------------------------------------- */\n/* Axes */\n/* -------------------------------------------------------------------------- */\n\nexport interface HeatmapXAxisProps {\n className?: string;\n /**\n * Label a column. Given the first dated bin in it, so a month name can be\n * derived. Return an empty string to leave the column unlabelled.\n */\n formatLabel?: (date: Date, column: number) => string;\n /**\n * Column labels, left to right. Overrides the month names — for a grid whose\n * columns are not weeks, where there is no month to change and so nothing to\n * emit a label on.\n */\n labels?: string[];\n}\n\n/**\n * Month labels above the grid.\n *\n * A label is emitted where the month changes rather than at a fixed interval,\n * because months are not the same length — spacing them evenly puts \"Mar\" over\n * a week in February. A grid whose columns are not weeks has no such signal, so\n * it passes `labels` and gets one over every column.\n */\nfunction HeatmapXAxis({ className, formatLabel, labels: given }: HeatmapXAxisProps) {\n const { data, grid } = useHeatmap('HeatmapChart.XAxis');\n const step = grid.size + grid.gap;\n const scale = useAxisLabelScale();\n\n const labels = useMemo(() => {\n if (given) {\n return given\n .slice(0, data.length)\n .map((label, column) => ({ column, label }))\n .filter((entry) => entry.label);\n }\n\n const out: { column: number; label: string }[] = [];\n let lastMonth = -1;\n\n data.forEach((column, index) => {\n const date = column.bins.find((bin) => bin.date)?.date;\n if (!date) return;\n const month = date.getMonth();\n if (month === lastMonth) return;\n lastMonth = month;\n const label = formatLabel ? formatLabel(date, index) : MONTHS[month]!;\n if (label) out.push({ column: index, label });\n });\n\n // The first month is usually a stub of a week or two, and a label over it\n // collides with the next one. Drop it when it has no room.\n if (out.length > 1 && out[1]!.column - out[0]!.column < 3) out.shift();\n return out;\n }, [data, formatLabel, given]);\n\n return (\n 1 && { height: Math.ceil(X_AXIS_HEIGHT * scale) }]}\n >\n {labels.map(({ column, label }) => (\n \n {label}\n \n ))}\n \n );\n}\nHeatmapXAxis.slot = 'x-axis' as Slot;\n\nexport interface HeatmapYAxisProps {\n className?: string;\n /**\n * Width reserved for the labels. The grid is sized around it. Defaults to 26,\n * and widens with the system text size so a larger weekday does not wrap.\n */\n width?: number;\n /** Which rows get a label. Every other row is the usual choice. */\n tickFilter?: 'all' | 'odd' | 'even';\n /** `initial` is the single letter; `full` is the abbreviated name. */\n labelFormat?: 'initial' | 'full';\n /**\n * Row labels, top to bottom. Overrides the weekday names — for a grid whose\n * rows are not days.\n */\n labels?: string[];\n}\n\n/** Weekday labels down the left of the grid. */\nfunction HeatmapYAxis({\n className,\n width: widthProp,\n tickFilter = 'odd',\n labelFormat = 'full',\n labels,\n}: HeatmapYAxisProps) {\n const { grid, weekStartDay } = useHeatmap('HeatmapChart.YAxis');\n const step = grid.size + grid.gap;\n const scale = useAxisLabelScale();\n const width = widthProp ?? Math.ceil(DEFAULT_AXIS_WIDTH * scale);\n\n return (\n \n {Array.from({ length: grid.rows }, (_unused, row) => {\n if (tickFilter === 'odd' && row % 2 === 0) return null;\n if (tickFilter === 'even' && row % 2 === 1) return null;\n\n const name = labels\n ? (labels[row] ?? '')\n : WEEKDAYS[(row + weekStartDay) % DAYS_IN_WEEK]!;\n return (\n \n {labels\n ? name\n : labelFormat === 'initial'\n ? name.slice(0, 1)\n : name.slice(0, 3)}\n \n );\n })}\n \n );\n}\nHeatmapYAxis.slot = 'y-axis' as Slot;\n\n/* -------------------------------------------------------------------------- */\n/* Tooltip */\n/* -------------------------------------------------------------------------- */\n\nexport interface HeatmapTooltipProps {\n className?: string;\n /** The line shown for a cell. Defaults to the count and the date. */\n formatLabel?: (cell: HeatmapCell) => string;\n /**\n * How long a press has to be held before the readout takes over, in\n * milliseconds.\n *\n * It is not zero, and cannot be: a full year of columns lives inside a\n * horizontal scroller, and a readout that claims the touch on the first pixel\n * of movement means the chart can never be scrolled. Holding first is what\n * separates \"I am moving the chart\" from \"I am reading it\". Set `0` only for\n * a chart that is not inside a scroll view at all.\n */\n activateAfterLongPress?: number;\n}\n\n/**\n * A readout following the finger across the grid.\n *\n * It lives in the view layer, over the SVG: a gesture handler cannot be\n * attached to an SVG node, and SVG text ignores the platform's text scaling.\n * The cell under the finger is resolved on the UI thread and only crosses back\n * into JS when it changes, so a drag across a year costs a handful of\n * re-renders rather than one per frame.\n */\nfunction HeatmapTooltip({\n className,\n formatLabel,\n activateAfterLongPress = DEFAULT_HOLD,\n}: HeatmapTooltipProps) {\n const { grid, activeCell, setActiveCell, cellAt } = useHeatmap(\n 'HeatmapChart.Tooltip'\n );\n const step = grid.size + grid.gap;\n\n // Mirrored for the worklet, which cannot read the JS closure's latest value.\n const lastKey = useSharedValue(-1);\n\n const resolve = useMemo(\n () => (column: number, row: number) => setActiveCell(cellAt(column, row)),\n [cellAt, setActiveCell]\n );\n const clear = useMemo(() => () => setActiveCell(null), [setActiveCell]);\n\n /*\n * The cell is resolved here, on the UI thread, and only crosses into JS when\n * it *changes*. Sending every frame's coordinates over and picking the cell\n * in React would cost a re-render per frame for a value that changes maybe\n * fifty times across a whole drag.\n */\n const pick = (x: number, y: number) => {\n 'worklet';\n const column = Math.floor(x / step);\n const row = Math.floor(y / step);\n if (column < 0 || column >= grid.columns || row < 0 || row >= grid.rows) return;\n const key = column * grid.rows + row;\n if (key === lastKey.value) return;\n lastKey.value = key;\n runOnJS(resolve)(column, row);\n };\n\n /*\n * The readout takes over only once the press has been held.\n *\n * `onStart` rather than `onBegin` is the other half of it: `onBegin` fires on\n * touch-down whatever happens next, so picking there would light a cell and\n * dim the rest of the grid for the first moment of every scroll swipe — the\n * gesture would yield correctly and still leave a flicker behind it.\n */\n const pan = Gesture.Pan()\n .minDistance(0)\n .activateAfterLongPress(activateAfterLongPress)\n .onStart((event) => {\n 'worklet';\n pick(event.x, event.y);\n })\n .onUpdate((event) => {\n 'worklet';\n pick(event.x, event.y);\n })\n .onFinalize(() => {\n 'worklet';\n // Fires whether or not the gesture ever activated, so a swipe that only\n // scrolled must not report a cell change it never made.\n if (lastKey.value === -1) return;\n lastKey.value = -1;\n runOnJS(clear)();\n });\n\n const label = activeCell\n ? formatLabel\n ? formatLabel(activeCell)\n : defaultTooltipLabel(activeCell)\n : null;\n\n // Flipped to the left of the finger past the halfway mark, so the readout\n // never runs off the edge it is closest to — and dropped below the cell in\n // the top two rows, where there is no room above it inside the chart.\n const anchorX = activeCell ? activeCell.column * step + grid.size / 2 : 0;\n const flipped = anchorX > grid.width / 2;\n const below = (activeCell?.row ?? 0) < 2;\n const anchorY = activeCell\n ? below\n ? activeCell.row * step + grid.size + 6\n : activeCell.row * step - TOOLTIP_HEIGHT - 6\n : 0;\n\n return (\n <>\n \n \n \n\n {activeCell && label ? (\n \n \n {label}\n \n \n ) : null}\n \n );\n}\nHeatmapTooltip.slot = 'tooltip' as Slot;\n\n/*\n * A cell with no date is not a cell with no data — it is a cell in a grid that\n * is not a calendar, and it has a count like any other. Saying \"No data\" over\n * it reports a hole in the data that is not there. Without a date there is\n * simply nothing to say after the number, so the number is the whole label,\n * and a grid that wants a noun for it passes `formatLabel`.\n */\nfunction defaultTooltipLabel(cell: HeatmapCell) {\n if (!cell.date) return `${cell.count}`;\n const count = `${cell.count} ${cell.count === 1 ? 'contribution' : 'contributions'}`;\n const date = `${MONTHS[cell.date.getMonth()]} ${cell.date.getDate()}`;\n return `${count} on ${date}`;\n}\n\n/* -------------------------------------------------------------------------- */\n/* Legend */\n/* -------------------------------------------------------------------------- */\n\nexport interface HeatmapLegendProps {\n className?: string;\n /** Text at the low end of the ramp. */\n lessLabel?: string;\n /** Text at the high end. */\n moreLabel?: string;\n /** Side of a swatch, in pixels. Defaults to the chart's cell size. */\n swatchSize?: number;\n}\n\n/** The `Less ▢▢▢▢▢ More` key, under the grid. */\nfunction HeatmapLegend({\n className,\n lessLabel = 'Less',\n moreLabel = 'More',\n swatchSize,\n}: HeatmapLegendProps) {\n const { ramp, opacities, grid, cornerRadius } = useHeatmap('HeatmapChart.Legend');\n const size = swatchSize ?? Math.max(grid.size, 10);\n\n return (\n \n \n \n {lessLabel}\n \n {ramp.map((fill, level) => (\n \n \n \n ))}\n \n {moreLabel}\n \n \n );\n}\nHeatmapLegend.slot = 'legend' as Slot;\n\n/* -------------------------------------------------------------------------- */\n/* Header */\n/* -------------------------------------------------------------------------- */\n\nexport interface HeatmapHeaderProps extends ViewProps {\n className?: string;\n /** Small line above the value — what the grid is of. */\n title?: string;\n /** The readout. The largest thing on the card, and the first thing read. */\n value?: string;\n /** One muted line under the value — a period, a total, the held cell. */\n caption?: string;\n /**\n * Draw the ramp along the trailing edge, `Less ▢▢▢▢▢ More`. The key for a\n * grid that scrolls sideways, where `HeatmapChart.Legend` under the cells\n * would scroll away with them.\n */\n legend?: boolean;\n /** Text at the low end of the ramp, when `legend` is set. */\n lessLabel?: string;\n /** Text at the high end. */\n moreLabel?: string;\n /** Trailing slot — a control, a badge, a range picker. Wins over `legend`. */\n children?: ReactNode;\n}\n\n/**\n * The strip above the grid: what the chart is of, what it currently reads, and\n * what the shading means.\n *\n * It belongs to the chart rather than to the card around it because it is about\n * the *grid* — the number changes as a finger moves across the cells, and the\n * ramp is the scale the chart itself derived. The card's header is a caption on\n * the tray the chart sits in; this is the chart introducing itself.\n *\n * The value is not derived here. Take it from `onActiveCellChange` and pass the\n * formatted string down, so one header can show a total when nothing is held\n * and a day's own count when something is.\n */\nfunction HeatmapHeader({\n className,\n title,\n value,\n caption,\n legend = false,\n lessLabel = 'Less',\n moreLabel = 'More',\n children,\n ...props\n}: HeatmapHeaderProps) {\n const { ramp, opacities, grid, cornerRadius } = useHeatmap('HeatmapChart.Header');\n // Small enough to sit on one line beside the text, whatever the cells are.\n const size = Math.max(Math.min(grid.size, 12), 8);\n\n const trailing =\n children ??\n (legend ? (\n \n \n {lessLabel}\n \n {ramp.map((fill, level) => (\n \n \n \n ))}\n \n {moreLabel}\n \n \n ) : null);\n\n return (\n \n \n {title ? (\n \n {title}\n \n ) : null}\n {value ? (\n \n {value}\n \n ) : null}\n {caption ? (\n \n {caption}\n \n ) : null}\n \n {/* Shrinkable, unlike a view's default in React Native — held rigid, the\n ramp takes the width it wants and the caption wraps around it. */}\n {trailing ? {trailing} : null}\n \n );\n}\nHeatmapHeader.displayName = 'HeatmapChart.Header';\nHeatmapHeader.slot = 'header' as Slot;\n\nexport const HeatmapChart = Object.assign(HeatmapChartRoot, {\n Header: HeatmapHeader,\n Cells: HeatmapCells,\n Separator: HeatmapSeparator,\n XAxis: HeatmapXAxis,\n YAxis: HeatmapYAxis,\n Tooltip: HeatmapTooltip,\n Legend: HeatmapLegend,\n});\n" } ] } diff --git a/apps/docs/public/r/icons.json b/apps/docs/public/r/icons.json index 3c1441109..fd2be9ec2 100644 --- a/apps/docs/public/r/icons.json +++ b/apps/docs/public/r/icons.json @@ -15,7 +15,7 @@ { "path": "ui/icons.tsx", "type": "registry:ui", - "content": "/**\n * Icon set.\n *\n * Every glyph is a 24×24 stroked drawing, sized and coloured by the caller.\n * They exist so the library can draw its own chrome — chevrons, a close\n * button, a check inside a checkbox — without every consumer having to choose\n * an icon package before a Select will render.\n *\n * ## How a colour is resolved\n *\n * In one order, always: an explicit `color` prop, then the colour inherited\n * from an enclosing `IconColorProvider`, then the icon's own fallback.\n * Coloured surfaces such as Button provide the foreground that reads against\n * them, so an icon dropped into one follows the theme without the caller\n * hardcoding a hex — which is what breaks the moment the theme inverts.\n *\n * ## Weight\n *\n * `strokeWidth` defaults to 2 rather than to the drawing's own 1.5. The set is\n * drawn at 24 and used at 14–20, and a hairline that reads correctly at full\n * size thins to nothing at two thirds of it. A few glyphs carry their own\n * weight because they sit beside something they have to match: the check and\n * the bar inside a checkbox, the grip on a drag handle.\n *\n * ## Mirroring\n *\n * A glyph whose *meaning* is a horizontal direction mirrors in a right-to-left\n * subtree — the two side chevrons, the outward arrow, the send plane. Yoga\n * moves a chevron to the other end of its row but cannot turn the glyph\n * around, so without this an RTL list row ends up with a right-pointing\n * chevron on its left edge, pointing back at the text.\n *\n * Everything else is drawn once and left alone. The vertical axis does not\n * mirror, and an icon that is merely asymmetric — a magnifier, a pencil, a\n * play triangle — means the same thing either way round; flipping it would\n * only be a wrong drawing.\n *\n * A mirroring icon always carries a transform, identity included. Dropping the\n * prop leaves the last matrix the view was given in place, so a glyph mirrored\n * once stays mirrored when the direction flips back — the arrows in an app\n * that can switch direction at runtime end up pointing at the text one toggle\n * in, and never recover.\n *\n * ## The four drawn by hand\n *\n * Google, Facebook and Apple carry their own palettes, and a brand mark\n * repainted to match a surface is no longer the mark. `BadgeCheckIcon` is\n * two-tone by design — a solid badge with a check punched through it in a\n * second colour — and `checkColor` is a public prop that a single-colour\n * outline could only ignore. All four are drawn here rather than mapped.\n */\nimport { createContext, useContext, type ReactNode } from 'react';\nimport { Platform, View } from 'react-native';\nimport { HugeiconsIcon, type IconSvgElement } from '@hugeicons/react-native';\nimport Svg, { G, Path, type SvgProps } from 'react-native-svg';\nimport { useDirection } from '@/hooks/use-direction';\nimport HgAlert02Icon from '@hugeicons/core-free-icons/Alert02Icon';\nimport HgArrowDown01Icon from '@hugeicons/core-free-icons/ArrowDown01Icon';\nimport HgArrowDown02Icon from '@hugeicons/core-free-icons/ArrowDown02Icon';\nimport HgArrowLeft01Icon from '@hugeicons/core-free-icons/ArrowLeft01Icon';\nimport HgArrowRight01Icon from '@hugeicons/core-free-icons/ArrowRight01Icon';\nimport HgArrowUp01Icon from '@hugeicons/core-free-icons/ArrowUp01Icon';\nimport HgArrowUp02Icon from '@hugeicons/core-free-icons/ArrowUp02Icon';\nimport HgArrowUpRight01Icon from '@hugeicons/core-free-icons/ArrowUpRight01Icon';\nimport HgAttachment01Icon from '@hugeicons/core-free-icons/Attachment01Icon';\nimport HgAudioWave01Icon from '@hugeicons/core-free-icons/AudioWave01Icon';\nimport HgBookmark01Icon from '@hugeicons/core-free-icons/Bookmark01Icon';\nimport HgBubbleChatIcon from '@hugeicons/core-free-icons/BubbleChatIcon';\nimport HgCalendar03Icon from '@hugeicons/core-free-icons/Calendar03Icon';\nimport HgCamera01Icon from '@hugeicons/core-free-icons/Camera01Icon';\nimport HgCameraRotated01Icon from '@hugeicons/core-free-icons/CameraRotated01Icon';\nimport HgFlashIcon from '@hugeicons/core-free-icons/FlashIcon';\nimport HgFlashOffIcon from '@hugeicons/core-free-icons/FlashOffIcon';\nimport HgTimer02Icon from '@hugeicons/core-free-icons/Timer02Icon';\nimport HgCancel01Icon from '@hugeicons/core-free-icons/Cancel01Icon';\nimport HgCheckListIcon from '@hugeicons/core-free-icons/CheckListIcon';\nimport HgCheckmarkCircle01Icon from '@hugeicons/core-free-icons/CheckmarkCircle01Icon';\nimport HgCircleIcon from '@hugeicons/core-free-icons/CircleIcon';\nimport HgClock01Icon from '@hugeicons/core-free-icons/Clock01Icon';\nimport HgCodeIcon from '@hugeicons/core-free-icons/CodeIcon';\nimport HgCompassIcon from '@hugeicons/core-free-icons/CompassIcon';\nimport HgCopy01Icon from '@hugeicons/core-free-icons/Copy01Icon';\nimport HgCreditCardIcon from '@hugeicons/core-free-icons/CreditCardIcon';\nimport HgCrosshairIcon from '@hugeicons/core-free-icons/CrosshairIcon';\nimport HgDelete02Icon from '@hugeicons/core-free-icons/Delete02Icon';\nimport HgDownload01Icon from '@hugeicons/core-free-icons/Download01Icon';\nimport HgDragDropVerticalIcon from '@hugeicons/core-free-icons/DragDropVerticalIcon';\nimport HgEyeIcon from '@hugeicons/core-free-icons/EyeIcon';\nimport HgFavouriteIcon from '@hugeicons/core-free-icons/FavouriteIcon';\nimport HgCloudUploadIcon from '@hugeicons/core-free-icons/CloudUploadIcon';\nimport HgFile01Icon from '@hugeicons/core-free-icons/File01Icon';\nimport HgFolder01Icon from '@hugeicons/core-free-icons/Folder01Icon';\nimport HgFolderOpenIcon from '@hugeicons/core-free-icons/FolderOpenIcon';\nimport HgFullScreenIcon from '@hugeicons/core-free-icons/FullScreenIcon';\nimport HgGlobeIcon from '@hugeicons/core-free-icons/GlobeIcon';\nimport HgHeading01Icon from '@hugeicons/core-free-icons/Heading01Icon';\nimport HgImage01Icon from '@hugeicons/core-free-icons/Image01Icon';\nimport HgVideo01Icon from '@hugeicons/core-free-icons/Video01Icon';\nimport HgInformationCircleIcon from '@hugeicons/core-free-icons/InformationCircleIcon';\nimport HgKeyboardIcon from '@hugeicons/core-free-icons/KeyboardIcon';\nimport HgLeftToRightListBulletIcon from '@hugeicons/core-free-icons/LeftToRightListBulletIcon';\nimport HgLeftToRightListNumberIcon from '@hugeicons/core-free-icons/LeftToRightListNumberIcon';\nimport HgLink01Icon from '@hugeicons/core-free-icons/Link01Icon';\nimport HgLockIcon from '@hugeicons/core-free-icons/LockIcon';\nimport HgLockOpenIcon from '@hugeicons/core-free-icons/LockOpenIcon';\nimport HgMenu01Icon from '@hugeicons/core-free-icons/Menu01Icon';\nimport HgMic01Icon from '@hugeicons/core-free-icons/Mic01Icon';\nimport HgMinusSignIcon from '@hugeicons/core-free-icons/MinusSignIcon';\nimport HgMoonIcon from '@hugeicons/core-free-icons/MoonIcon';\nimport HgMoreHorizontalIcon from '@hugeicons/core-free-icons/MoreHorizontalIcon';\nimport HgNotification03Icon from '@hugeicons/core-free-icons/Notification03Icon';\nimport HgPackageIcon from '@hugeicons/core-free-icons/PackageIcon';\nimport HgPauseIcon from '@hugeicons/core-free-icons/PauseIcon';\nimport HgPencilEdit01Icon from '@hugeicons/core-free-icons/PencilEdit01Icon';\nimport HgPlayIcon from '@hugeicons/core-free-icons/PlayIcon';\nimport HgPlusSignIcon from '@hugeicons/core-free-icons/PlusSignIcon';\nimport HgPlusSignSquareIcon from '@hugeicons/core-free-icons/PlusSignSquareIcon';\nimport HgQuoteDownIcon from '@hugeicons/core-free-icons/QuoteDownIcon';\nimport HgReceiptIcon from '@hugeicons/core-free-icons/ReceiptIcon';\nimport HgRefreshCcwIcon from '@hugeicons/core-free-icons/RefreshCcwIcon';\nimport HgRefreshCwIcon from '@hugeicons/core-free-icons/RefreshCwIcon';\nimport HgRotateCwSquareIcon from '@hugeicons/core-free-icons/RotateCwSquareIcon';\nimport HgRepeatIcon from '@hugeicons/core-free-icons/RepeatIcon';\nimport HgSearch01Icon from '@hugeicons/core-free-icons/Search01Icon';\nimport HgSent02Icon from '@hugeicons/core-free-icons/Sent02Icon';\nimport HgSentIcon from '@hugeicons/core-free-icons/SentIcon';\nimport HgShare08Icon from '@hugeicons/core-free-icons/Share08Icon';\nimport HgShieldAlertIcon from '@hugeicons/core-free-icons/ShieldAlertIcon';\nimport HgShieldCheckIcon from '@hugeicons/core-free-icons/ShieldCheckIcon';\nimport HgSparklesIcon from '@hugeicons/core-free-icons/SparklesIcon';\nimport HgStarIcon from '@hugeicons/core-free-icons/StarIcon';\nimport HgSun03Icon from '@hugeicons/core-free-icons/Sun03Icon';\nimport HgBook02Icon from '@hugeicons/core-free-icons/Book02Icon';\nimport HgBug01Icon from '@hugeicons/core-free-icons/Bug01Icon';\nimport HgCall02Icon from '@hugeicons/core-free-icons/Call02Icon';\nimport HgHeadsetIcon from '@hugeicons/core-free-icons/HeadsetIcon';\nimport HgHelpCircleIcon from '@hugeicons/core-free-icons/HelpCircleIcon';\nimport HgLifebuoyIcon from '@hugeicons/core-free-icons/LifebuoyIcon';\nimport HgMail01Icon from '@hugeicons/core-free-icons/Mail01Icon';\nimport HgTicket01Icon from '@hugeicons/core-free-icons/Ticket01Icon';\nimport HgTextBoldIcon from '@hugeicons/core-free-icons/TextBoldIcon';\nimport HgTextItalicIcon from '@hugeicons/core-free-icons/TextItalicIcon';\nimport HgTick02Icon from '@hugeicons/core-free-icons/Tick02Icon';\nimport HgUnfoldMoreIcon from '@hugeicons/core-free-icons/UnfoldMoreIcon';\n\nexport interface IconProps extends SvgProps {\n /** Both dimensions — icons are always square. */\n size?: number;\n /** Overrides the inherited colour and the icon's own fallback. */\n color?: string;\n /** Line weight. Defaults to 2, or to the glyph's own where it has one. */\n strokeWidth?: number;\n}\n\n/**\n * The colour icons inherit when they are not given one explicitly.\n *\n * Coloured surfaces (Button, and anything else that paints its own\n * background) provide the foreground that reads against them, so an icon\n * dropped into one follows the theme without the caller hardcoding a hex —\n * which is what breaks the moment the theme inverts.\n */\nconst IconColorContext = createContext(undefined);\n\nexport function IconColorProvider({\n color,\n children,\n}: {\n color: string | undefined;\n children: ReactNode;\n}) {\n return {children};\n}\n\n/** The inherited icon colour, if a surface is providing one. */\nexport function useIconColor(): string | undefined {\n return useContext(IconColorContext);\n}\n\n/** Resolves an icon's colour: explicit prop, then inherited, then fallback. */\nfunction useResolvedColor(explicit: string | undefined, fallback: string): string {\n const inherited = useIconColor();\n return explicit ?? inherited ?? fallback;\n}\n\n/**\n * Props for icons that must never be announced by a screen reader.\n *\n * Two sets, because the two platforms take the instruction differently.\n * `accessibilityElementsHidden` and `importantForAccessibility` are native\n * props: on web they reach the DOM `` untranslated, where React warns\n * about the casing of the second one and neither has any effect — so the glyph\n * stays in the accessibility tree inside every button and field that already\n * carries its own name, and every icon on the page logs an error while it does.\n * `aria-hidden` is what says it there.\n */\nconst decorative = (\n Platform.OS === 'web'\n ? { 'aria-hidden': true }\n : {\n accessibilityElementsHidden: true,\n importantForAccessibility: 'no-hide-descendants',\n }\n) as Record;\n\n/** The default line weight. See the note on weight above. */\nconst STROKE = 2;\n\ninterface IconDefaults {\n /** Both dimensions at the size this glyph is normally drawn. */\n size: number;\n /** The colour used when neither a prop nor a provider supplies one. */\n color: string;\n /** Overrides `STROKE` for a glyph that has to match something beside it. */\n strokeWidth?: number;\n /** Whether the glyph's meaning is a horizontal direction. */\n flip?: boolean;\n /** Whether the glyph is a toggle that can be solid. See `ToggleIconProps`. */\n fillable?: boolean;\n}\n\n/**\n * Builds one icon from a glyph and the defaults it is normally drawn with.\n *\n * A factory rather than seventy near-identical components: what varies between\n * them is data, and what does not — the colour order, the weight default, the\n * mirror, the fill toggle, the prop passthrough — is worth having in exactly\n * one place, where a fix reaches the whole set at once.\n */\nfunction icon(glyph: IconSvgElement, defaults: IconDefaults) {\n function Icon({\n size = defaults.size,\n color,\n strokeWidth,\n filled,\n style,\n ...props\n }: ToggleIconProps) {\n const resolved = useResolvedColor(color, defaults.color);\n const rtl = useDirection() === 'rtl';\n\n const drawing = (\n \n );\n\n if (!defaults.flip) return drawing;\n\n /*\n * The mirror goes on a view around the glyph, not on the glyph.\n *\n * The drawing component takes a `style` prop and drops it: it destructures\n * `style` out of its props and never puts it back, and the interop layer\n * that would otherwise have carried it is a package this library does not\n * use. A transform handed to it is silently discarded, which is a chevron\n * that keeps pointing right in a right-to-left row — and a failure with\n * nothing to see, since every other prop on the same element does arrive.\n *\n * A plain view cannot lose it. It also puts the caller's own `style` back\n * on the outside, where it is applied rather than thrown away.\n */\n return (\n {drawing}\n );\n }\n\n return Icon;\n}\n\n/* -------------------------------------------------------------------------- */\n/* Chrome */\n/* -------------------------------------------------------------------------- */\n\nexport const ChevronUpIcon = icon(HgArrowUp01Icon, { size: 16, color: '#737373' });\nexport const ChevronDownIcon = icon(HgArrowDown01Icon, { size: 16, color: '#737373' });\nexport const ChevronLeftIcon = icon(HgArrowLeft01Icon, {\n size: 16,\n color: '#737373',\n flip: true,\n});\nexport const ChevronRightIcon = icon(HgArrowRight01Icon, {\n size: 16,\n color: '#737373',\n flip: true,\n});\nexport const ChevronsUpDownIcon = icon(HgUnfoldMoreIcon, { size: 16, color: '#737373' });\nexport const EllipsisIcon = icon(HgMoreHorizontalIcon, { size: 16, color: '#737373' });\nexport const MenuIcon = icon(HgMenu01Icon, { size: 20, color: '#737373' });\nexport const XIcon = icon(HgCancel01Icon, { size: 16, color: '#737373' });\nexport const SearchIcon = icon(HgSearch01Icon, { size: 16, color: '#737373' });\nexport const ArrowUpRightIcon = icon(HgArrowUpRight01Icon, {\n size: 20,\n color: '#fff',\n flip: true,\n});\nexport const PlusIcon = icon(HgPlusSignIcon, { size: 16, color: '#737373' });\nexport const MinusIcon = icon(HgMinusSignIcon, { size: 14, color: '#fff', strokeWidth: 3 });\nexport const CheckIcon = icon(HgTick02Icon, { size: 14, color: '#fff', strokeWidth: 3 });\nexport const CircleIcon = icon(HgCircleIcon, { size: 16, color: '#737373', fillable: true });\nexport const GripVerticalIcon = icon(HgDragDropVerticalIcon, {\n size: 16,\n color: '#737373',\n strokeWidth: 2.5,\n});\nexport const MaximizeIcon = icon(HgFullScreenIcon, { size: 16, color: '#737373' });\n\n/* -------------------------------------------------------------------------- */\n/* Status */\n/* -------------------------------------------------------------------------- */\n\nexport const InfoIcon = icon(HgInformationCircleIcon, { size: 20, color: 'currentColor' });\nexport const CheckCircleIcon = icon(HgCheckmarkCircle01Icon, {\n size: 20,\n color: 'currentColor',\n});\nexport const AlertTriangleIcon = icon(HgAlert02Icon, { size: 20, color: 'currentColor' });\nexport const ShieldAlertIcon = icon(HgShieldAlertIcon, { size: 16, color: '#737373' });\nexport const ShieldCheckIcon = icon(HgShieldCheckIcon, { size: 16, color: '#737373' });\nexport const BellIcon = icon(HgNotification03Icon, { size: 16, color: '#737373' });\n\n/* -------------------------------------------------------------------------- */\n/* Time and place */\n/* -------------------------------------------------------------------------- */\n\nexport const CalendarIcon = icon(HgCalendar03Icon, { size: 16, color: '#737373' });\nexport const ClockIcon = icon(HgClock01Icon, { size: 16, color: '#737373' });\nexport const GlobeIcon = icon(HgGlobeIcon, { size: 16, color: '#737373' });\nexport const CompassIcon = icon(HgCompassIcon, { size: 16, color: '#737373' });\nexport const CrosshairIcon = icon(HgCrosshairIcon, { size: 16, color: '#737373' });\n\n/* -------------------------------------------------------------------------- */\n/* Text and editing */\n/* -------------------------------------------------------------------------- */\n\nexport const BoldIcon = icon(HgTextBoldIcon, { size: 16, color: '#737373' });\nexport const ItalicIcon = icon(HgTextItalicIcon, { size: 16, color: '#737373' });\nexport const HeadingIcon = icon(HgHeading01Icon, { size: 16, color: '#737373' });\nexport const ListIcon = icon(HgLeftToRightListBulletIcon, { size: 16, color: '#737373' });\nexport const ListOrderedIcon = icon(HgLeftToRightListNumberIcon, {\n size: 16,\n color: '#737373',\n});\nexport const ListChecksIcon = icon(HgCheckListIcon, { size: 16, color: '#737373' });\nexport const QuoteIcon = icon(HgQuoteDownIcon, { size: 16, color: '#737373' });\nexport const CodeIcon = icon(HgCodeIcon, { size: 16, color: '#737373' });\nexport const PencilIcon = icon(HgPencilEdit01Icon, { size: 16, color: '#737373' });\nexport const KeyboardIcon = icon(HgKeyboardIcon, { size: 16, color: '#737373' });\n\n/* -------------------------------------------------------------------------- */\n/* Files and media */\n/* -------------------------------------------------------------------------- */\n\nexport const FileIcon = icon(HgFile01Icon, { size: 16, color: '#737373' });\nexport const FolderIcon = icon(HgFolder01Icon, { size: 16, color: '#737373' });\nexport const FolderOpenIcon = icon(HgFolderOpenIcon, { size: 16, color: '#737373' });\nexport const ImageIcon = icon(HgImage01Icon, { size: 16, color: '#737373' });\nexport const PaperclipIcon = icon(HgAttachment01Icon, { size: 16, color: '#737373' });\nexport const CameraIcon = icon(HgCamera01Icon, { size: 16, color: '#737373' });\nexport const VideoIcon = icon(HgVideo01Icon, { size: 16, color: '#737373' });\nexport const CameraSwitchIcon = icon(HgCameraRotated01Icon, { size: 16, color: '#737373' });\nexport const FlashIcon = icon(HgFlashIcon, { size: 16, color: '#737373' });\nexport const FlashOffIcon = icon(HgFlashOffIcon, { size: 16, color: '#737373' });\nexport const TimerIcon = icon(HgTimer02Icon, { size: 16, color: '#737373' });\nexport const UploadIcon = icon(HgCloudUploadIcon, { size: 16, color: '#737373' });\nexport const MicIcon = icon(HgMic01Icon, { size: 16, color: '#737373' });\nexport const AudioLinesIcon = icon(HgAudioWave01Icon, { size: 16, color: '#737373' });\nexport const PlayIcon = icon(HgPlayIcon, { size: 16, color: '#737373' });\nexport const PauseIcon = icon(HgPauseIcon, { size: 16, color: '#737373', strokeWidth: 1.5 });\nexport const DownloadIcon = icon(HgDownload01Icon, { size: 16, color: '#737373' });\nexport const PackageIcon = icon(HgPackageIcon, { size: 16, color: '#737373' });\n\n/* -------------------------------------------------------------------------- */\n/* Actions */\n/* -------------------------------------------------------------------------- */\n\nexport const CopyIcon = icon(HgCopy01Icon, { size: 16, color: '#737373' });\nexport const LinkIcon = icon(HgLink01Icon, { size: 16, color: '#737373' });\nexport const ShareNodesIcon = icon(HgShare08Icon, { size: 16, color: '#737373' });\nexport const SendIcon = icon(HgSentIcon, { size: 16, color: '#737373', flip: true });\nexport const SendArrowIcon = icon(HgSent02Icon, {\n size: 16,\n color: '#737373',\n strokeWidth: 2.5,\n});\nexport const TrashIcon = icon(HgDelete02Icon, { size: 16, color: '#737373' });\nexport const RotateCcwIcon = icon(HgRefreshCcwIcon, { size: 16, color: '#737373' });\nexport const RotateCwIcon = icon(HgRefreshCwIcon, { size: 16, color: '#737373' });\n/** A square with an arrow turning round it — turn an image, rather than refresh. */\nexport const RotateSquareIcon = icon(HgRotateCwSquareIcon, { size: 16, color: '#737373' });\nexport const RepeatIcon = icon(HgRepeatIcon, { size: 16, color: '#737373' });\nexport const PlusSquareIcon = icon(HgPlusSignSquareIcon, { size: 16, color: '#737373' });\nexport const EyeIcon = icon(HgEyeIcon, { size: 16, color: '#737373' });\nexport const LockIcon = icon(HgLockIcon, { size: 16, color: '#737373' });\nexport const UnlockIcon = icon(HgLockOpenIcon, { size: 16, color: '#737373' });\n\n/* -------------------------------------------------------------------------- */\n/* Marks and moods */\n/* -------------------------------------------------------------------------- */\n\nexport const StarIcon = icon(HgStarIcon, {\n size: 20,\n color: '#737373',\n strokeWidth: 1.6,\n fillable: true,\n});\nexport const HeartIcon = icon(HgFavouriteIcon, {\n size: 16,\n color: '#737373',\n fillable: true,\n});\nexport const BookmarkIcon = icon(HgBookmark01Icon, {\n size: 16,\n color: '#737373',\n fillable: true,\n});\nexport const SparklesIcon = icon(HgSparklesIcon, { size: 16, color: '#737373' });\nexport const MessageCircleIcon = icon(HgBubbleChatIcon, {\n size: 16,\n color: '#737373',\n fillable: true,\n});\nexport const CardIcon = icon(HgCreditCardIcon, { size: 16, color: '#737373' });\nexport const HeadsetIcon = icon(HgHeadsetIcon, { size: 16, color: '#737373' });\nexport const LifebuoyIcon = icon(HgLifebuoyIcon, { size: 16, color: '#737373' });\nexport const HelpCircleIcon = icon(HgHelpCircleIcon, { size: 16, color: '#737373' });\nexport const MailIcon = icon(HgMail01Icon, { size: 16, color: '#737373' });\nexport const CallIcon = icon(HgCall02Icon, { size: 16, color: '#737373' });\nexport const BugIcon = icon(HgBug01Icon, { size: 16, color: '#737373' });\nexport const BookIcon = icon(HgBook02Icon, { size: 16, color: '#737373' });\nexport const TicketIcon = icon(HgTicket01Icon, { size: 16, color: '#737373' });\nexport const ReceiptIcon = icon(HgReceiptIcon, { size: 16, color: '#737373' });\nexport const SunIcon = icon(HgSun03Icon, { size: 18, color: '#f5f5f5' });\nexport const MoonIcon = icon(HgMoonIcon, { size: 18, color: '#262626' });\n\n/* -------------------------------------------------------------------------- */\n/* A post's toggles */\n/* -------------------------------------------------------------------------- */\n\nexport const ArrowUpIcon = icon(HgArrowUp02Icon, {\n size: 16,\n color: '#737373',\n fillable: true,\n});\nexport const ArrowDownIcon = icon(HgArrowDown02Icon, {\n size: 16,\n color: '#737373',\n fillable: true,\n});\n\n/* -------------------------------------------------------------------------- */\n/* Drawn by hand */\n/* -------------------------------------------------------------------------- */\n\n/**\n * The icons a post's action row is made of.\n *\n * These are the only ones in the set that take `filled`, and it is not a\n * stylistic choice: a like, a save and a vote are toggles, and the outline and\n * the solid are the two states of one control. Drawn as two different icons\n * they would swap shape under the finger; drawn as one that fills, the shape\n * stays put and only the inside changes.\n */\nexport interface ToggleIconProps extends IconProps {\n /** Solid rather than outlined — the on state of a like, a save, a vote. */\n filled?: boolean;\n}\n\nexport interface BadgeCheckIconProps extends IconProps {\n /** The tick's colour when the rosette is solid. Defaults to white. */\n checkColor?: string;\n}\n\n/** Google \"G\", in the four official brand colours. */\nexport function GoogleIcon({ size = 18, ...props }: Omit) {\n return (\n \n \n \n \n \n \n \n \n );\n}\n\n/** Facebook \"f\" mark. */\n\n/** Facebook \"f\" mark. */\nexport function FacebookIcon({ size = 18, ...props }: Omit) {\n return (\n \n \n \n );\n}\n\n/**\n * Apple mark. Monochrome by design, so unlike the other brand marks it does\n * follow the icon colour context — Apple's guidelines require it to match the\n * button's text colour.\n */\n\n/**\n * Apple mark. Monochrome by design, so unlike the other brand marks it does\n * follow the icon colour context — Apple's guidelines require it to match the\n * button's text colour.\n */\nexport function AppleIcon({ size = 18, color, ...props }: IconProps) {\n const resolved = useResolvedColor(color, '#000000');\n return (\n \n \n \n );\n}\n\n/* -------------------------------------------------------------------------- */\n/* Event icons (Timeline and other status surfaces) */\n/* -------------------------------------------------------------------------- */\n\n/**\n * The verified rosette.\n *\n * Solid by default, unlike everything else here. A verification mark is a\n * claim about the account rather than a control, and an outlined one next to\n * an outlined like button reads as another thing you could press.\n */\nexport function BadgeCheckIcon({\n size = 16,\n color,\n checkColor = '#ffffff',\n ...props\n}: BadgeCheckIconProps) {\n const resolved = useResolvedColor(color, '#737373');\n return (\n \n \n \n \n );\n}\n" + "content": "/**\n * Icon set.\n *\n * Every glyph is a 24×24 stroked drawing, sized and coloured by the caller.\n * They exist so the library can draw its own chrome — chevrons, a close\n * button, a check inside a checkbox — without every consumer having to choose\n * an icon package before a Select will render.\n *\n * ## How a colour is resolved\n *\n * In one order, always: an explicit `color` prop, then the colour inherited\n * from an enclosing `IconColorProvider`, then the icon's own fallback.\n * Coloured surfaces such as Button provide the foreground that reads against\n * them, so an icon dropped into one follows the theme without the caller\n * hardcoding a hex — which is what breaks the moment the theme inverts.\n *\n * ## Weight\n *\n * `strokeWidth` defaults to 2 rather than to the drawing's own 1.5. The set is\n * drawn at 24 and used at 14–20, and a hairline that reads correctly at full\n * size thins to nothing at two thirds of it. A few glyphs carry their own\n * weight because they sit beside something they have to match: the check and\n * the bar inside a checkbox, the grip on a drag handle.\n *\n * ## Mirroring\n *\n * A glyph whose *meaning* is a horizontal direction mirrors in a right-to-left\n * subtree — the two side chevrons, the outward arrow, the send plane. Yoga\n * moves a chevron to the other end of its row but cannot turn the glyph\n * around, so without this an RTL list row ends up with a right-pointing\n * chevron on its left edge, pointing back at the text.\n *\n * Everything else is drawn once and left alone. The vertical axis does not\n * mirror, and an icon that is merely asymmetric — a magnifier, a pencil, a\n * play triangle — means the same thing either way round; flipping it would\n * only be a wrong drawing.\n *\n * A mirroring icon always carries a transform, identity included. Dropping the\n * prop leaves the last matrix the view was given in place, so a glyph mirrored\n * once stays mirrored when the direction flips back — the arrows in an app\n * that can switch direction at runtime end up pointing at the text one toggle\n * in, and never recover.\n *\n * ## The four drawn by hand\n *\n * Google, Facebook and Apple carry their own palettes, and a brand mark\n * repainted to match a surface is no longer the mark. `BadgeCheckIcon` is\n * two-tone by design — a solid badge with a check punched through it in a\n * second colour — and `checkColor` is a public prop that a single-colour\n * outline could only ignore. All four are drawn here rather than mapped.\n */\nimport { createContext, useContext, type ReactNode } from 'react';\nimport { Platform, View } from 'react-native';\nimport { HugeiconsIcon, type IconSvgElement } from '@hugeicons/react-native';\nimport Svg, { G, Path, type SvgProps } from 'react-native-svg';\nimport { useDirection } from '@/hooks/use-direction';\nimport HgAlert02Icon from '@hugeicons/core-free-icons/Alert02Icon';\nimport HgArrowDown01Icon from '@hugeicons/core-free-icons/ArrowDown01Icon';\nimport HgArrowDown02Icon from '@hugeicons/core-free-icons/ArrowDown02Icon';\nimport HgArrowLeft01Icon from '@hugeicons/core-free-icons/ArrowLeft01Icon';\nimport HgArrowRight01Icon from '@hugeicons/core-free-icons/ArrowRight01Icon';\nimport HgArrowUp01Icon from '@hugeicons/core-free-icons/ArrowUp01Icon';\nimport HgArrowUp02Icon from '@hugeicons/core-free-icons/ArrowUp02Icon';\nimport HgArrowUpRight01Icon from '@hugeicons/core-free-icons/ArrowUpRight01Icon';\nimport HgAttachment01Icon from '@hugeicons/core-free-icons/Attachment01Icon';\nimport HgAudioWave01Icon from '@hugeicons/core-free-icons/AudioWave01Icon';\nimport HgBookmark01Icon from '@hugeicons/core-free-icons/Bookmark01Icon';\nimport HgBubbleChatIcon from '@hugeicons/core-free-icons/BubbleChatIcon';\nimport HgCalendar03Icon from '@hugeicons/core-free-icons/Calendar03Icon';\nimport HgCamera01Icon from '@hugeicons/core-free-icons/Camera01Icon';\nimport HgCameraRotated01Icon from '@hugeicons/core-free-icons/CameraRotated01Icon';\nimport HgFlashIcon from '@hugeicons/core-free-icons/FlashIcon';\nimport HgFlashOffIcon from '@hugeicons/core-free-icons/FlashOffIcon';\nimport HgTimer02Icon from '@hugeicons/core-free-icons/Timer02Icon';\nimport HgCancel01Icon from '@hugeicons/core-free-icons/Cancel01Icon';\nimport HgCheckListIcon from '@hugeicons/core-free-icons/CheckListIcon';\nimport HgCheckmarkCircle01Icon from '@hugeicons/core-free-icons/CheckmarkCircle01Icon';\nimport HgCircleIcon from '@hugeicons/core-free-icons/CircleIcon';\nimport HgClock01Icon from '@hugeicons/core-free-icons/Clock01Icon';\nimport HgCodeIcon from '@hugeicons/core-free-icons/CodeIcon';\nimport HgCompassIcon from '@hugeicons/core-free-icons/CompassIcon';\nimport HgCopy01Icon from '@hugeicons/core-free-icons/Copy01Icon';\nimport HgCreditCardIcon from '@hugeicons/core-free-icons/CreditCardIcon';\nimport HgCrosshairIcon from '@hugeicons/core-free-icons/CrosshairIcon';\nimport HgDelete02Icon from '@hugeicons/core-free-icons/Delete02Icon';\nimport HgDownload01Icon from '@hugeicons/core-free-icons/Download01Icon';\nimport HgDragDropVerticalIcon from '@hugeicons/core-free-icons/DragDropVerticalIcon';\nimport HgEyeIcon from '@hugeicons/core-free-icons/EyeIcon';\nimport HgFavouriteIcon from '@hugeicons/core-free-icons/FavouriteIcon';\nimport HgCloudUploadIcon from '@hugeicons/core-free-icons/CloudUploadIcon';\nimport HgFile01Icon from '@hugeicons/core-free-icons/File01Icon';\nimport HgFolder01Icon from '@hugeicons/core-free-icons/Folder01Icon';\nimport HgFolderOpenIcon from '@hugeicons/core-free-icons/FolderOpenIcon';\nimport HgFullScreenIcon from '@hugeicons/core-free-icons/FullScreenIcon';\nimport HgGlobeIcon from '@hugeicons/core-free-icons/GlobeIcon';\nimport HgHeading01Icon from '@hugeicons/core-free-icons/Heading01Icon';\nimport HgImage01Icon from '@hugeicons/core-free-icons/Image01Icon';\nimport HgVideo01Icon from '@hugeicons/core-free-icons/Video01Icon';\nimport HgInformationCircleIcon from '@hugeicons/core-free-icons/InformationCircleIcon';\nimport HgKeyboardIcon from '@hugeicons/core-free-icons/KeyboardIcon';\nimport HgLeftToRightListBulletIcon from '@hugeicons/core-free-icons/LeftToRightListBulletIcon';\nimport HgLeftToRightListNumberIcon from '@hugeicons/core-free-icons/LeftToRightListNumberIcon';\nimport HgLink01Icon from '@hugeicons/core-free-icons/Link01Icon';\nimport HgLockIcon from '@hugeicons/core-free-icons/LockIcon';\nimport HgLockOpenIcon from '@hugeicons/core-free-icons/LockOpenIcon';\nimport HgMenu01Icon from '@hugeicons/core-free-icons/Menu01Icon';\nimport HgMic01Icon from '@hugeicons/core-free-icons/Mic01Icon';\nimport HgMinusSignIcon from '@hugeicons/core-free-icons/MinusSignIcon';\nimport HgMoonIcon from '@hugeicons/core-free-icons/MoonIcon';\nimport HgMoreHorizontalIcon from '@hugeicons/core-free-icons/MoreHorizontalIcon';\nimport HgNotification03Icon from '@hugeicons/core-free-icons/Notification03Icon';\nimport HgPackageIcon from '@hugeicons/core-free-icons/PackageIcon';\nimport HgPauseIcon from '@hugeicons/core-free-icons/PauseIcon';\nimport HgPencilEdit01Icon from '@hugeicons/core-free-icons/PencilEdit01Icon';\nimport HgPlayIcon from '@hugeicons/core-free-icons/PlayIcon';\nimport HgPlusSignIcon from '@hugeicons/core-free-icons/PlusSignIcon';\nimport HgPlusSignSquareIcon from '@hugeicons/core-free-icons/PlusSignSquareIcon';\nimport HgQuoteDownIcon from '@hugeicons/core-free-icons/QuoteDownIcon';\nimport HgReceiptIcon from '@hugeicons/core-free-icons/ReceiptIcon';\nimport HgRefreshCcwIcon from '@hugeicons/core-free-icons/RefreshCcwIcon';\nimport HgRefreshCwIcon from '@hugeicons/core-free-icons/RefreshCwIcon';\nimport HgRotateCwSquareIcon from '@hugeicons/core-free-icons/RotateCwSquareIcon';\nimport HgRepeatIcon from '@hugeicons/core-free-icons/RepeatIcon';\nimport HgSearch01Icon from '@hugeicons/core-free-icons/Search01Icon';\nimport HgSent02Icon from '@hugeicons/core-free-icons/Sent02Icon';\nimport HgSentIcon from '@hugeicons/core-free-icons/SentIcon';\nimport HgShare08Icon from '@hugeicons/core-free-icons/Share08Icon';\nimport HgShieldAlertIcon from '@hugeicons/core-free-icons/ShieldAlertIcon';\nimport HgShieldCheckIcon from '@hugeicons/core-free-icons/ShieldCheckIcon';\nimport HgSparklesIcon from '@hugeicons/core-free-icons/SparklesIcon';\nimport HgStarIcon from '@hugeicons/core-free-icons/StarIcon';\nimport HgSun03Icon from '@hugeicons/core-free-icons/Sun03Icon';\nimport HgBook02Icon from '@hugeicons/core-free-icons/Book02Icon';\nimport HgBug01Icon from '@hugeicons/core-free-icons/Bug01Icon';\nimport HgCall02Icon from '@hugeicons/core-free-icons/Call02Icon';\nimport HgHeadsetIcon from '@hugeicons/core-free-icons/HeadsetIcon';\nimport HgHelpCircleIcon from '@hugeicons/core-free-icons/HelpCircleIcon';\nimport HgLifebuoyIcon from '@hugeicons/core-free-icons/LifebuoyIcon';\nimport HgMail01Icon from '@hugeicons/core-free-icons/Mail01Icon';\nimport HgTicket01Icon from '@hugeicons/core-free-icons/Ticket01Icon';\nimport HgTextBoldIcon from '@hugeicons/core-free-icons/TextBoldIcon';\nimport HgTextItalicIcon from '@hugeicons/core-free-icons/TextItalicIcon';\nimport HgTick02Icon from '@hugeicons/core-free-icons/Tick02Icon';\nimport HgUnfoldMoreIcon from '@hugeicons/core-free-icons/UnfoldMoreIcon';\n\nexport interface IconProps extends SvgProps {\n /** Both dimensions — icons are always square. */\n size?: number;\n /** Overrides the inherited colour and the icon's own fallback. */\n color?: string;\n /** Line weight. Defaults to 2, or to the glyph's own where it has one. */\n strokeWidth?: number;\n}\n\n/**\n * The colour icons inherit when they are not given one explicitly.\n *\n * Coloured surfaces (Button, and anything else that paints its own\n * background) provide the foreground that reads against them, so an icon\n * dropped into one follows the theme without the caller hardcoding a hex —\n * which is what breaks the moment the theme inverts.\n */\nconst IconColorContext = createContext(undefined);\n\nexport function IconColorProvider({\n color,\n children,\n}: {\n color: string | undefined;\n children: ReactNode;\n}) {\n return {children};\n}\n\n/** The inherited icon colour, if a surface is providing one. */\nexport function useIconColor(): string | undefined {\n return useContext(IconColorContext);\n}\n\n/** Resolves an icon's colour: explicit prop, then inherited, then fallback. */\nfunction useResolvedColor(explicit: string | undefined, fallback: string): string {\n const inherited = useIconColor();\n return explicit ?? inherited ?? fallback;\n}\n\n/**\n * Props for icons that must never be announced by a screen reader.\n *\n * Two sets, because the two platforms take the instruction differently.\n * `accessibilityElementsHidden` and `importantForAccessibility` are native\n * props: on web they reach the DOM `` untranslated, where React warns\n * about the casing of the second one and neither has any effect — so the glyph\n * stays in the accessibility tree inside every button and field that already\n * carries its own name, and every icon on the page logs an error while it does.\n * `aria-hidden` is what says it there.\n */\nconst decorative = (\n Platform.OS === 'web'\n ? { 'aria-hidden': true }\n : {\n accessibilityElementsHidden: true,\n importantForAccessibility: 'no-hide-descendants',\n }\n) as Record;\n\n/** The default line weight. See the note on weight above. */\nconst STROKE = 2;\n\n/**\n * The defaults a glyph is drawn with when the caller passes nothing.\n * The second argument to `icon()`.\n */\nexport interface IconDefaults {\n /** Both dimensions at the size this glyph is normally drawn. */\n size: number;\n /** The colour used when neither a prop nor a provider supplies one. */\n color: string;\n /** Overrides `STROKE` for a glyph that has to match something beside it. */\n strokeWidth?: number;\n /** Whether the glyph's meaning is a horizontal direction. */\n flip?: boolean;\n /** Whether the glyph is a toggle that can be solid. See `ToggleIconProps`. */\n fillable?: boolean;\n}\n\n/**\n * Builds one icon from a glyph and the defaults it is normally drawn with.\n *\n * Exported so an app can add a glyph the set does not carry, and have it\n * behave exactly like the built-in icons: the same colour order (prop, then\n * `IconColorProvider`, then the default), the same line weight, the same\n * right-to-left mirror and the same `filled` toggle.\n *\n * ```tsx\n * import HgViewOffIcon from '@hugeicons/core-free-icons/ViewOffIcon';\n * import { icon } from 'panelui-native/icons';\n *\n * export const EyeOffIcon = icon(HgViewOffIcon, { size: 16, color: '#737373' });\n * ```\n *\n * A factory rather than seventy near-identical components: what varies between\n * them is data, and what does not — the colour order, the weight default, the\n * mirror, the fill toggle, the prop passthrough — is worth having in exactly\n * one place, where a fix reaches the whole set at once.\n */\nexport function icon(glyph: IconSvgElement, defaults: IconDefaults) {\n function Icon({\n size = defaults.size,\n color,\n strokeWidth,\n filled,\n style,\n ...props\n }: ToggleIconProps) {\n const resolved = useResolvedColor(color, defaults.color);\n const rtl = useDirection() === 'rtl';\n\n const drawing = (\n \n );\n\n if (!defaults.flip) return drawing;\n\n /*\n * The mirror goes on a view around the glyph, not on the glyph.\n *\n * The drawing component takes a `style` prop and drops it: it destructures\n * `style` out of its props and never puts it back, and the interop layer\n * that would otherwise have carried it is a package this library does not\n * use. A transform handed to it is silently discarded, which is a chevron\n * that keeps pointing right in a right-to-left row — and a failure with\n * nothing to see, since every other prop on the same element does arrive.\n *\n * A plain view cannot lose it. It also puts the caller's own `style` back\n * on the outside, where it is applied rather than thrown away.\n */\n return (\n {drawing}\n );\n }\n\n return Icon;\n}\n\n/** Another name for `icon()`, for code that prefers the `create` prefix. */\nexport const createIcon = icon;\n\n/* -------------------------------------------------------------------------- */\n/* Chrome */\n/* -------------------------------------------------------------------------- */\n\nexport const ChevronUpIcon = icon(HgArrowUp01Icon, { size: 16, color: '#737373' });\nexport const ChevronDownIcon = icon(HgArrowDown01Icon, { size: 16, color: '#737373' });\nexport const ChevronLeftIcon = icon(HgArrowLeft01Icon, {\n size: 16,\n color: '#737373',\n flip: true,\n});\nexport const ChevronRightIcon = icon(HgArrowRight01Icon, {\n size: 16,\n color: '#737373',\n flip: true,\n});\nexport const ChevronsUpDownIcon = icon(HgUnfoldMoreIcon, { size: 16, color: '#737373' });\nexport const EllipsisIcon = icon(HgMoreHorizontalIcon, { size: 16, color: '#737373' });\nexport const MenuIcon = icon(HgMenu01Icon, { size: 20, color: '#737373' });\nexport const XIcon = icon(HgCancel01Icon, { size: 16, color: '#737373' });\nexport const SearchIcon = icon(HgSearch01Icon, { size: 16, color: '#737373' });\nexport const ArrowUpRightIcon = icon(HgArrowUpRight01Icon, {\n size: 20,\n color: '#fff',\n flip: true,\n});\nexport const PlusIcon = icon(HgPlusSignIcon, { size: 16, color: '#737373' });\nexport const MinusIcon = icon(HgMinusSignIcon, { size: 14, color: '#fff', strokeWidth: 3 });\nexport const CheckIcon = icon(HgTick02Icon, { size: 14, color: '#fff', strokeWidth: 3 });\nexport const CircleIcon = icon(HgCircleIcon, { size: 16, color: '#737373', fillable: true });\nexport const GripVerticalIcon = icon(HgDragDropVerticalIcon, {\n size: 16,\n color: '#737373',\n strokeWidth: 2.5,\n});\nexport const MaximizeIcon = icon(HgFullScreenIcon, { size: 16, color: '#737373' });\n\n/* -------------------------------------------------------------------------- */\n/* Status */\n/* -------------------------------------------------------------------------- */\n\nexport const InfoIcon = icon(HgInformationCircleIcon, { size: 20, color: 'currentColor' });\nexport const CheckCircleIcon = icon(HgCheckmarkCircle01Icon, {\n size: 20,\n color: 'currentColor',\n});\nexport const AlertTriangleIcon = icon(HgAlert02Icon, { size: 20, color: 'currentColor' });\nexport const ShieldAlertIcon = icon(HgShieldAlertIcon, { size: 16, color: '#737373' });\nexport const ShieldCheckIcon = icon(HgShieldCheckIcon, { size: 16, color: '#737373' });\nexport const BellIcon = icon(HgNotification03Icon, { size: 16, color: '#737373' });\n\n/* -------------------------------------------------------------------------- */\n/* Time and place */\n/* -------------------------------------------------------------------------- */\n\nexport const CalendarIcon = icon(HgCalendar03Icon, { size: 16, color: '#737373' });\nexport const ClockIcon = icon(HgClock01Icon, { size: 16, color: '#737373' });\nexport const GlobeIcon = icon(HgGlobeIcon, { size: 16, color: '#737373' });\nexport const CompassIcon = icon(HgCompassIcon, { size: 16, color: '#737373' });\nexport const CrosshairIcon = icon(HgCrosshairIcon, { size: 16, color: '#737373' });\n\n/* -------------------------------------------------------------------------- */\n/* Text and editing */\n/* -------------------------------------------------------------------------- */\n\nexport const BoldIcon = icon(HgTextBoldIcon, { size: 16, color: '#737373' });\nexport const ItalicIcon = icon(HgTextItalicIcon, { size: 16, color: '#737373' });\nexport const HeadingIcon = icon(HgHeading01Icon, { size: 16, color: '#737373' });\nexport const ListIcon = icon(HgLeftToRightListBulletIcon, { size: 16, color: '#737373' });\nexport const ListOrderedIcon = icon(HgLeftToRightListNumberIcon, {\n size: 16,\n color: '#737373',\n});\nexport const ListChecksIcon = icon(HgCheckListIcon, { size: 16, color: '#737373' });\nexport const QuoteIcon = icon(HgQuoteDownIcon, { size: 16, color: '#737373' });\nexport const CodeIcon = icon(HgCodeIcon, { size: 16, color: '#737373' });\nexport const PencilIcon = icon(HgPencilEdit01Icon, { size: 16, color: '#737373' });\nexport const KeyboardIcon = icon(HgKeyboardIcon, { size: 16, color: '#737373' });\n\n/* -------------------------------------------------------------------------- */\n/* Files and media */\n/* -------------------------------------------------------------------------- */\n\nexport const FileIcon = icon(HgFile01Icon, { size: 16, color: '#737373' });\nexport const FolderIcon = icon(HgFolder01Icon, { size: 16, color: '#737373' });\nexport const FolderOpenIcon = icon(HgFolderOpenIcon, { size: 16, color: '#737373' });\nexport const ImageIcon = icon(HgImage01Icon, { size: 16, color: '#737373' });\nexport const PaperclipIcon = icon(HgAttachment01Icon, { size: 16, color: '#737373' });\nexport const CameraIcon = icon(HgCamera01Icon, { size: 16, color: '#737373' });\nexport const VideoIcon = icon(HgVideo01Icon, { size: 16, color: '#737373' });\nexport const CameraSwitchIcon = icon(HgCameraRotated01Icon, { size: 16, color: '#737373' });\nexport const FlashIcon = icon(HgFlashIcon, { size: 16, color: '#737373' });\nexport const FlashOffIcon = icon(HgFlashOffIcon, { size: 16, color: '#737373' });\nexport const TimerIcon = icon(HgTimer02Icon, { size: 16, color: '#737373' });\nexport const UploadIcon = icon(HgCloudUploadIcon, { size: 16, color: '#737373' });\nexport const MicIcon = icon(HgMic01Icon, { size: 16, color: '#737373' });\nexport const AudioLinesIcon = icon(HgAudioWave01Icon, { size: 16, color: '#737373' });\nexport const PlayIcon = icon(HgPlayIcon, { size: 16, color: '#737373' });\nexport const PauseIcon = icon(HgPauseIcon, { size: 16, color: '#737373', strokeWidth: 1.5 });\nexport const DownloadIcon = icon(HgDownload01Icon, { size: 16, color: '#737373' });\nexport const PackageIcon = icon(HgPackageIcon, { size: 16, color: '#737373' });\n\n/* -------------------------------------------------------------------------- */\n/* Actions */\n/* -------------------------------------------------------------------------- */\n\nexport const CopyIcon = icon(HgCopy01Icon, { size: 16, color: '#737373' });\nexport const LinkIcon = icon(HgLink01Icon, { size: 16, color: '#737373' });\nexport const ShareNodesIcon = icon(HgShare08Icon, { size: 16, color: '#737373' });\nexport const SendIcon = icon(HgSentIcon, { size: 16, color: '#737373', flip: true });\nexport const SendArrowIcon = icon(HgSent02Icon, {\n size: 16,\n color: '#737373',\n strokeWidth: 2.5,\n});\nexport const TrashIcon = icon(HgDelete02Icon, { size: 16, color: '#737373' });\nexport const RotateCcwIcon = icon(HgRefreshCcwIcon, { size: 16, color: '#737373' });\nexport const RotateCwIcon = icon(HgRefreshCwIcon, { size: 16, color: '#737373' });\n/** A square with an arrow turning round it — turn an image, rather than refresh. */\nexport const RotateSquareIcon = icon(HgRotateCwSquareIcon, { size: 16, color: '#737373' });\nexport const RepeatIcon = icon(HgRepeatIcon, { size: 16, color: '#737373' });\nexport const PlusSquareIcon = icon(HgPlusSignSquareIcon, { size: 16, color: '#737373' });\nexport const EyeIcon = icon(HgEyeIcon, { size: 16, color: '#737373' });\nexport const LockIcon = icon(HgLockIcon, { size: 16, color: '#737373' });\nexport const UnlockIcon = icon(HgLockOpenIcon, { size: 16, color: '#737373' });\n\n/* -------------------------------------------------------------------------- */\n/* Marks and moods */\n/* -------------------------------------------------------------------------- */\n\nexport const StarIcon = icon(HgStarIcon, {\n size: 20,\n color: '#737373',\n strokeWidth: 1.6,\n fillable: true,\n});\nexport const HeartIcon = icon(HgFavouriteIcon, {\n size: 16,\n color: '#737373',\n fillable: true,\n});\nexport const BookmarkIcon = icon(HgBookmark01Icon, {\n size: 16,\n color: '#737373',\n fillable: true,\n});\nexport const SparklesIcon = icon(HgSparklesIcon, { size: 16, color: '#737373' });\nexport const MessageCircleIcon = icon(HgBubbleChatIcon, {\n size: 16,\n color: '#737373',\n fillable: true,\n});\nexport const CardIcon = icon(HgCreditCardIcon, { size: 16, color: '#737373' });\nexport const HeadsetIcon = icon(HgHeadsetIcon, { size: 16, color: '#737373' });\nexport const LifebuoyIcon = icon(HgLifebuoyIcon, { size: 16, color: '#737373' });\nexport const HelpCircleIcon = icon(HgHelpCircleIcon, { size: 16, color: '#737373' });\nexport const MailIcon = icon(HgMail01Icon, { size: 16, color: '#737373' });\nexport const CallIcon = icon(HgCall02Icon, { size: 16, color: '#737373' });\nexport const BugIcon = icon(HgBug01Icon, { size: 16, color: '#737373' });\nexport const BookIcon = icon(HgBook02Icon, { size: 16, color: '#737373' });\nexport const TicketIcon = icon(HgTicket01Icon, { size: 16, color: '#737373' });\nexport const ReceiptIcon = icon(HgReceiptIcon, { size: 16, color: '#737373' });\nexport const SunIcon = icon(HgSun03Icon, { size: 18, color: '#f5f5f5' });\nexport const MoonIcon = icon(HgMoonIcon, { size: 18, color: '#262626' });\n\n/* -------------------------------------------------------------------------- */\n/* A post's toggles */\n/* -------------------------------------------------------------------------- */\n\nexport const ArrowUpIcon = icon(HgArrowUp02Icon, {\n size: 16,\n color: '#737373',\n fillable: true,\n});\nexport const ArrowDownIcon = icon(HgArrowDown02Icon, {\n size: 16,\n color: '#737373',\n fillable: true,\n});\n\n/* -------------------------------------------------------------------------- */\n/* Drawn by hand */\n/* -------------------------------------------------------------------------- */\n\n/**\n * The icons a post's action row is made of.\n *\n * These are the only ones in the set that take `filled`, and it is not a\n * stylistic choice: a like, a save and a vote are toggles, and the outline and\n * the solid are the two states of one control. Drawn as two different icons\n * they would swap shape under the finger; drawn as one that fills, the shape\n * stays put and only the inside changes.\n */\nexport interface ToggleIconProps extends IconProps {\n /** Solid rather than outlined — the on state of a like, a save, a vote. */\n filled?: boolean;\n}\n\nexport interface BadgeCheckIconProps extends IconProps {\n /** The tick's colour when the rosette is solid. Defaults to white. */\n checkColor?: string;\n}\n\n/** Google \"G\", in the four official brand colours. */\nexport function GoogleIcon({ size = 18, ...props }: Omit) {\n return (\n \n \n \n \n \n \n \n \n );\n}\n\n/** Facebook \"f\" mark. */\n\n/** Facebook \"f\" mark. */\nexport function FacebookIcon({ size = 18, ...props }: Omit) {\n return (\n \n \n \n );\n}\n\n/**\n * Apple mark. Monochrome by design, so unlike the other brand marks it does\n * follow the icon colour context — Apple's guidelines require it to match the\n * button's text colour.\n */\n\n/**\n * Apple mark. Monochrome by design, so unlike the other brand marks it does\n * follow the icon colour context — Apple's guidelines require it to match the\n * button's text colour.\n */\nexport function AppleIcon({ size = 18, color, ...props }: IconProps) {\n const resolved = useResolvedColor(color, '#000000');\n return (\n \n \n \n );\n}\n\n/* -------------------------------------------------------------------------- */\n/* Event icons (Timeline and other status surfaces) */\n/* -------------------------------------------------------------------------- */\n\n/**\n * The verified rosette.\n *\n * Solid by default, unlike everything else here. A verification mark is a\n * claim about the account rather than a control, and an outlined one next to\n * an outlined like button reads as another thing you could press.\n */\nexport function BadgeCheckIcon({\n size = 16,\n color,\n checkColor = '#ffffff',\n ...props\n}: BadgeCheckIconProps) {\n const resolved = useResolvedColor(color, '#737373');\n return (\n \n \n \n \n );\n}\n" } ] } diff --git a/apps/docs/public/r/index.json b/apps/docs/public/r/index.json index 1b1815da1..859d1ddef 100644 --- a/apps/docs/public/r/index.json +++ b/apps/docs/public/r/index.json @@ -8,6 +8,7 @@ "description": "Collapsible sections with single or multiple selection.", "registryDependencies": [ "icons", + "refs", "text" ], "docsPath": "components/accordion" @@ -66,6 +67,7 @@ "registryDependencies": [ "cn", "icons", + "refs", "text" ], "docsPath": "components/alert" @@ -79,6 +81,7 @@ "description": "Status pill whose icon and label roll over when the status changes.", "registryDependencies": [ "icons", + "refs", "text" ], "docsPath": "components/animated-badge" @@ -90,7 +93,9 @@ "group": "components", "stability": "stable", "description": "Pressable with UI-thread press feedback.", - "registryDependencies": [] + "registryDependencies": [ + "refs" + ] }, { "name": "area-chart", @@ -118,6 +123,7 @@ "animated-pressable", "cn", "item", + "refs", "shimmer", "text" ], @@ -132,6 +138,7 @@ "description": "User image with an initials fallback, a badge overlay, and a stack for a group of them.", "registryDependencies": [ "cn", + "refs", "text" ], "docsPath": "components/avatar" @@ -158,6 +165,7 @@ "stability": "stable", "description": "Compact status label, dot, or notification count.", "registryDependencies": [ + "refs", "text" ], "docsPath": "components/badge" @@ -228,6 +236,7 @@ "animated-pressable", "cn", "icons", + "refs", "text" ], "docsPath": "components/breadcrumb" @@ -277,6 +286,7 @@ "cn", "icons", "native", + "refs", "spinner", "text" ], @@ -290,7 +300,8 @@ "stability": "stable", "description": "Several buttons drawn as one control — a segmented run, a split action, a toolbar.", "registryDependencies": [ - "button" + "button", + "refs" ], "docsPath": "components/button-group" }, @@ -351,6 +362,7 @@ "description": "Content surface with header, body and footer.", "registryDependencies": [ "cn", + "refs", "text" ], "docsPath": "components/card" @@ -397,6 +409,7 @@ "description": "Animated checkbox, as a row or a selectable card.", "registryDependencies": [ "icons", + "refs", "text" ], "docsPath": "components/checkbox" @@ -413,6 +426,7 @@ "cn", "haptics", "icons", + "refs", "text" ], "docsPath": "components/chip" @@ -477,6 +491,7 @@ "registryDependencies": [ "collapse", "icons", + "refs", "text" ], "docsPath": "components/collapsible" @@ -502,6 +517,7 @@ "color", "haptics", "popover", + "refs", "text", "use-direction" ], @@ -575,6 +591,7 @@ "description": "The time left until a moment, ticking down to it.", "registryDependencies": [ "cn", + "refs", "text" ], "docsPath": "components/countdown" @@ -637,6 +654,7 @@ "registryDependencies": [ "cn", "portal", + "refs", "scrim", "text", "use-back-handler" @@ -682,6 +700,7 @@ "stability": "stable", "description": "Placeholder for a list or screen with no content.", "registryDependencies": [ + "refs", "text" ], "docsPath": "components/empty-state" @@ -700,6 +719,7 @@ "haptics", "icons", "native", + "refs", "scrim", "text", "use-back-handler" @@ -719,6 +739,7 @@ "icons", "keyboard-avoider", "portal", + "refs", "scrim", "text", "use-back-handler" @@ -734,6 +755,7 @@ "description": "Layout and validation-state kit a form control composes into.", "registryDependencies": [ "label", + "refs", "separator", "text" ], @@ -755,6 +777,7 @@ "icons", "image-viewer", "menu", + "refs", "text" ], "docsPath": "components/file-upload" @@ -776,7 +799,8 @@ "stability": "stable", "description": "Two faces of one card, and a turn between them.", "registryDependencies": [ - "cn" + "cn", + "refs" ], "docsPath": "components/flip-card" }, @@ -824,6 +848,7 @@ "registryDependencies": [ "cn", "icons", + "refs", "text" ], "docsPath": "components/frame" @@ -853,6 +878,7 @@ "description": "The iOS system material, drawn where it exists and a solid surface everywhere else.", "registryDependencies": [ "cn", + "refs", "scrim", "use-theme" ] @@ -867,6 +893,7 @@ "registryDependencies": [ "animated-pressable", "cn", + "refs", "text" ], "docsPath": "components/grid-item" @@ -891,6 +918,7 @@ "chart-accessibility", "cn", "date", + "refs", "text" ], "docsPath": "charts/heatmap-chart" @@ -965,6 +993,7 @@ "icons", "keyboard-avoider", "label", + "refs", "text", "use-keyboard-avoidance" ], @@ -979,6 +1008,7 @@ "description": "Input with leading and trailing decorators.", "registryDependencies": [ "input", + "refs", "text" ], "docsPath": "components/input-group" @@ -993,6 +1023,7 @@ "registryDependencies": [ "animated-pressable", "cn", + "refs", "text" ], "docsPath": "components/item" @@ -1022,6 +1053,7 @@ "icons", "line-chart", "progress", + "refs", "surface", "text" ], @@ -1035,6 +1067,7 @@ "stability": "stable", "description": "Form field label with required, invalid and disabled states.", "registryDependencies": [ + "refs", "text" ], "docsPath": "components/label" @@ -1050,6 +1083,7 @@ "avatar", "cn", "icons", + "refs", "text" ], "docsPath": "components/leaderboard" @@ -1123,6 +1157,7 @@ "button-group", "cn", "icons", + "refs", "response", "text", "textarea" @@ -1140,6 +1175,7 @@ "animated-pressable", "cn", "icons", + "refs", "separator", "shimmer", "text" @@ -1188,6 +1224,7 @@ "animated-pressable", "cn", "icons", + "refs", "text" ], "docsPath": "components/message" @@ -1215,6 +1252,7 @@ "stability": "stable", "description": "A measurement on a fixed scale, coloured by where it falls.", "registryDependencies": [ + "refs", "text" ], "docsPath": "components/meter" @@ -1267,6 +1305,7 @@ "haptics", "icons", "label", + "refs", "text" ], "docsPath": "components/number-input" @@ -1279,6 +1318,7 @@ "stability": "stable", "description": "One-time-code field drawn as a row of separate cells.", "registryDependencies": [ + "refs", "text" ], "docsPath": "components/otp-input" @@ -1295,6 +1335,7 @@ "avatar", "cn", "icons", + "refs", "text" ], "docsPath": "components/page-header" @@ -1311,6 +1352,7 @@ "cn", "haptics", "icons", + "refs", "text", "use-direction" ], @@ -1393,6 +1435,7 @@ "dialog", "frame", "icons", + "refs", "text" ], "docsPath": "components/planner" @@ -1480,6 +1523,7 @@ "cn", "haptics", "icons", + "refs", "text", "use-previous" ], @@ -1494,6 +1538,7 @@ "description": "Determinate and indeterminate progress bar.", "registryDependencies": [ "cn", + "refs", "text", "use-direction" ], @@ -1509,6 +1554,7 @@ "registryDependencies": [ "haptics", "icons", + "refs", "text" ], "docsPath": "components/progress-button" @@ -1538,6 +1584,7 @@ "registryDependencies": [ "cn", "popover", + "refs", "text" ], "docsPath": "components/qr-code" @@ -1555,6 +1602,7 @@ "frame", "icons", "input", + "refs", "text" ], "docsPath": "components/questionnaire" @@ -1583,6 +1631,7 @@ "description": "Single-select list of options.", "registryDependencies": [ "cn", + "refs", "text" ], "docsPath": "components/radio-group" @@ -1598,6 +1647,7 @@ "cn", "haptics", "icons", + "refs", "text" ], "docsPath": "components/rating" @@ -1618,6 +1668,15 @@ ], "docsPath": "ai-components/reasoning" }, + { + "name": "refs", + "type": "registry:ui", + "kind": "ui", + "group": "components", + "stability": "stable", + "description": "The types a forwarded ref points at, the same on every React Native version.", + "registryDependencies": [] + }, { "name": "response", "type": "registry:ui", @@ -1735,6 +1794,7 @@ "description": "A screen title that hands over to a compact bar as the page scrolls.", "registryDependencies": [ "cn", + "refs", "scrim", "text", "use-theme" @@ -1775,6 +1835,7 @@ "icons", "input", "keyboard-avoider", + "refs", "spinner", "text", "use-keyboard" @@ -1859,6 +1920,7 @@ "description": "Horizontal or vertical rule between content, optionally labelled.", "registryDependencies": [ "cn", + "refs", "text" ], "docsPath": "components/separator" @@ -1945,6 +2007,7 @@ "registryDependencies": [ "haptics", "native", + "refs", "text", "use-direction" ], @@ -2061,6 +2124,7 @@ "controllable-state", "haptics", "icons", + "refs", "text" ], "docsPath": "components/stack-card" @@ -2074,6 +2138,7 @@ "description": "Stepper for multi-step flows.", "registryDependencies": [ "icons", + "refs", "spinner", "text" ], @@ -2089,6 +2154,7 @@ "registryDependencies": [ "animated-pressable", "cn", + "refs", "text" ], "docsPath": "components/support" @@ -2100,7 +2166,9 @@ "group": "components", "stability": "stable", "description": "Elevated container with a variant ladder.", - "registryDependencies": [], + "registryDependencies": [ + "refs" + ], "docsPath": "components/surface" }, { @@ -2115,6 +2183,7 @@ "cn", "haptics", "icons", + "refs", "text", "use-direction" ], @@ -2131,6 +2200,7 @@ "field", "haptics", "native", + "refs", "use-direction" ], "docsPath": "components/switch" @@ -2148,6 +2218,7 @@ "frame", "haptics", "icons", + "refs", "text" ], "docsPath": "components/table" @@ -2178,6 +2249,7 @@ "haptics", "icons", "label", + "refs", "text" ], "docsPath": "components/tag-input" @@ -2232,6 +2304,7 @@ "registryDependencies": [ "keyboard-avoider", "label", + "refs", "text", "use-keyboard-avoidance" ], @@ -2309,6 +2382,7 @@ "registryDependencies": [ "haptics", "icons", + "refs", "text" ], "docsPath": "components/timeline" @@ -2341,6 +2415,7 @@ "cn", "haptics", "icons", + "refs", "text" ], "docsPath": "components/toggle-button" @@ -2385,6 +2460,7 @@ "description": "A hierarchy you can open a level at a time.", "registryDependencies": [ "icons", + "refs", "text", "use-direction" ], @@ -2414,6 +2490,7 @@ "description": "Semantic text presets.", "registryDependencies": [ "cn", + "refs", "text" ], "docsPath": "components/typography" diff --git a/apps/docs/public/r/input-group.json b/apps/docs/public/r/input-group.json index 5a4dcf0ab..d975bdf8c 100644 --- a/apps/docs/public/r/input-group.json +++ b/apps/docs/public/r/input-group.json @@ -4,6 +4,7 @@ "description": "Input with leading and trailing decorators.", "registryDependencies": [ "input", + "refs", "text" ], "dependencies": [ @@ -14,7 +15,7 @@ { "path": "ui/input-group.tsx", "type": "registry:ui", - "content": "/**\n * InputGroup — an Input with leading and/or trailing decorators.\n *\n * Prefix and Suffix are absolutely positioned and their measured widths become\n * padding on the Input, so text never runs underneath a decorator regardless\n * of how wide that decorator turns out to be.\n */\nimport {\n createContext,\n forwardRef,\n useCallback,\n useContext,\n useMemo,\n type ReactNode,\n} from 'react';\nimport {\n View,\n type LayoutChangeEvent,\n type TextInput,\n type ViewProps,\n} from 'react-native';\nimport { tv } from 'tailwind-variants';\nimport { Input, type InputProps } from '@/components/ui/input';\nimport { textChildren } from '@/components/ui/text';\nimport {\n useInputGroupDecoratorMeasurement,\n useInputGroupMeasurements,\n type InputGroupDecoratorOwner,\n type InputGroupDecoratorSide,\n} from '@/components/ui/input-group-measurements';\n\nconst inputGroupVariants = tv({\n slots: {\n root: 'w-full',\n prefix: 'absolute bottom-0 start-0 top-0 z-10 flex-row items-center justify-center gap-2 px-3',\n suffix: 'absolute bottom-0 end-0 top-0 z-10 flex-row items-center justify-center gap-2 px-3',\n },\n variants: {\n isDisabled: {\n true: { prefix: 'opacity-[0.64]', suffix: 'opacity-[0.64]' },\n },\n },\n});\n\ninterface InputGroupState {\n isDisabled: boolean;\n prefixWidth: number;\n suffixWidth: number;\n measureDecorator: (\n side: InputGroupDecoratorSide,\n owner: InputGroupDecoratorOwner,\n width: number\n ) => void;\n removeDecorator: (\n side: InputGroupDecoratorSide,\n owner: InputGroupDecoratorOwner\n ) => void;\n}\n\nconst InputGroupContext = createContext(null);\n\nfunction useInputGroup() {\n return useContext(InputGroupContext);\n}\n\nexport interface InputGroupProps extends ViewProps {\n className?: string;\n /** Disables the input and dims both decorators. */\n isDisabled?: boolean;\n children?: ReactNode;\n}\n\nconst InputGroupRoot = forwardRef(\n ({ className, isDisabled = false, children, ...props }, ref) => {\n const { root } = inputGroupVariants();\n const { prefixWidth, suffixWidth, measureDecorator, removeDecorator } =\n useInputGroupMeasurements();\n\n const value = useMemo(\n () => ({\n isDisabled,\n prefixWidth,\n suffixWidth,\n measureDecorator,\n removeDecorator,\n }),\n [\n isDisabled,\n prefixWidth,\n suffixWidth,\n measureDecorator,\n removeDecorator,\n ]\n );\n\n return (\n \n \n {textChildren(children)}\n \n \n );\n }\n);\nInputGroupRoot.displayName = 'InputGroup';\n\nexport interface InputGroupDecoratorProps extends ViewProps {\n className?: string;\n /**\n * Marks the decorator as presentation-only: touches fall through to the\n * Input and screen readers skip it. Leave it off when the decorator holds\n * something interactive, such as a show-password toggle.\n */\n isDecorative?: boolean;\n children?: ReactNode;\n}\n\n/** Builds Prefix/Suffix, which differ only in which side they measure. */\nfunction createDecorator(side: 'prefix' | 'suffix') {\n const Decorator = forwardRef(\n ({ className, isDecorative = false, onLayout, children, ...props }, ref) => {\n const group = useInputGroup();\n const slots = inputGroupVariants({ isDisabled: group?.isDisabled ?? false });\n const measure = useInputGroupDecoratorMeasurement(\n side,\n group?.measureDecorator,\n group?.removeDecorator\n );\n\n const handleLayout = useCallback(\n (event: LayoutChangeEvent) => {\n measure(event.nativeEvent.layout.width);\n onLayout?.(event);\n },\n [measure, onLayout]\n );\n\n return (\n \n {textChildren(children)}\n \n );\n }\n );\n Decorator.displayName = side === 'prefix' ? 'InputGroup.Prefix' : 'InputGroup.Suffix';\n return Decorator;\n}\n\nconst InputGroupPrefix = createDecorator('prefix');\nconst InputGroupSuffix = createDecorator('suffix');\n\nexport type InputGroupInputProps = InputProps;\n\n/**\n * The Input itself. Receives left/right padding matching the measured\n * decorator widths, so its text always clears them.\n */\nconst InputGroupInput = forwardRef(\n ({ style, disabled, ...props }, ref) => {\n const group = useInputGroup();\n\n return (\n \n );\n }\n);\nInputGroupInput.displayName = 'InputGroup.Input';\n\nexport const InputGroup = Object.assign(InputGroupRoot, {\n Prefix: InputGroupPrefix,\n Input: InputGroupInput,\n Suffix: InputGroupSuffix,\n});\n" + "content": "/**\n * InputGroup — an Input with leading and/or trailing decorators.\n *\n * Prefix and Suffix are absolutely positioned and their measured widths become\n * padding on the Input, so text never runs underneath a decorator regardless\n * of how wide that decorator turns out to be.\n */\nimport {\n createContext,\n forwardRef,\n useCallback,\n useContext,\n useMemo,\n type ReactNode,\n} from 'react';\nimport {\n View,\n type LayoutChangeEvent,\n type TextInput,\n type ViewProps,\n} from 'react-native';\nimport { tv } from 'tailwind-variants';\nimport { Input, type InputProps } from '@/components/ui/input';\nimport { textChildren } from '@/components/ui/text';\nimport {\n useInputGroupDecoratorMeasurement,\n useInputGroupMeasurements,\n type InputGroupDecoratorOwner,\n type InputGroupDecoratorSide,\n} from '@/components/ui/input-group-measurements';\nimport type { TextInputRef, ViewRef } from '@/components/ui/refs';\n\nconst inputGroupVariants = tv({\n slots: {\n root: 'w-full',\n prefix: 'absolute bottom-0 start-0 top-0 z-10 flex-row items-center justify-center gap-2 px-3',\n suffix: 'absolute bottom-0 end-0 top-0 z-10 flex-row items-center justify-center gap-2 px-3',\n },\n variants: {\n isDisabled: {\n true: { prefix: 'opacity-[0.64]', suffix: 'opacity-[0.64]' },\n },\n },\n});\n\ninterface InputGroupState {\n isDisabled: boolean;\n prefixWidth: number;\n suffixWidth: number;\n measureDecorator: (\n side: InputGroupDecoratorSide,\n owner: InputGroupDecoratorOwner,\n width: number\n ) => void;\n removeDecorator: (\n side: InputGroupDecoratorSide,\n owner: InputGroupDecoratorOwner\n ) => void;\n}\n\nconst InputGroupContext = createContext(null);\n\nfunction useInputGroup() {\n return useContext(InputGroupContext);\n}\n\nexport interface InputGroupProps extends ViewProps {\n className?: string;\n /** Disables the input and dims both decorators. */\n isDisabled?: boolean;\n children?: ReactNode;\n}\n\nconst InputGroupRoot = forwardRef(\n ({ className, isDisabled = false, children, ...props }, ref) => {\n const { root } = inputGroupVariants();\n const { prefixWidth, suffixWidth, measureDecorator, removeDecorator } =\n useInputGroupMeasurements();\n\n const value = useMemo(\n () => ({\n isDisabled,\n prefixWidth,\n suffixWidth,\n measureDecorator,\n removeDecorator,\n }),\n [\n isDisabled,\n prefixWidth,\n suffixWidth,\n measureDecorator,\n removeDecorator,\n ]\n );\n\n return (\n \n \n {textChildren(children)}\n \n \n );\n }\n);\nInputGroupRoot.displayName = 'InputGroup';\n\nexport interface InputGroupDecoratorProps extends ViewProps {\n className?: string;\n /**\n * Marks the decorator as presentation-only: touches fall through to the\n * Input and screen readers skip it. Leave it off when the decorator holds\n * something interactive, such as a show-password toggle.\n */\n isDecorative?: boolean;\n children?: ReactNode;\n}\n\n/** Builds Prefix/Suffix, which differ only in which side they measure. */\nfunction createDecorator(side: 'prefix' | 'suffix') {\n const Decorator = forwardRef(\n ({ className, isDecorative = false, onLayout, children, ...props }, ref) => {\n const group = useInputGroup();\n const slots = inputGroupVariants({ isDisabled: group?.isDisabled ?? false });\n const measure = useInputGroupDecoratorMeasurement(\n side,\n group?.measureDecorator,\n group?.removeDecorator\n );\n\n const handleLayout = useCallback(\n (event: LayoutChangeEvent) => {\n measure(event.nativeEvent.layout.width);\n onLayout?.(event);\n },\n [measure, onLayout]\n );\n\n return (\n \n {textChildren(children)}\n \n );\n }\n );\n Decorator.displayName = side === 'prefix' ? 'InputGroup.Prefix' : 'InputGroup.Suffix';\n return Decorator;\n}\n\nconst InputGroupPrefix = createDecorator('prefix');\nconst InputGroupSuffix = createDecorator('suffix');\n\nexport type InputGroupInputProps = InputProps;\n\n/**\n * The Input itself. Receives left/right padding matching the measured\n * decorator widths, so its text always clears them.\n */\nconst InputGroupInput = forwardRef(\n ({ style, disabled, ...props }, ref) => {\n const group = useInputGroup();\n\n return (\n \n );\n }\n);\nInputGroupInput.displayName = 'InputGroup.Input';\n\nexport const InputGroup = Object.assign(InputGroupRoot, {\n Prefix: InputGroupPrefix,\n Input: InputGroupInput,\n Suffix: InputGroupSuffix,\n});\n" }, { "path": "ui/input-group-measurements.ts", diff --git a/apps/docs/public/r/input.json b/apps/docs/public/r/input.json index 08733b241..ead775779 100644 --- a/apps/docs/public/r/input.json +++ b/apps/docs/public/r/input.json @@ -6,6 +6,7 @@ "icons", "keyboard-avoider", "label", + "refs", "text", "use-keyboard-avoidance" ], @@ -19,7 +20,7 @@ { "path": "ui/input.tsx", "type": "registry:ui", - "content": "/**\n * Input — a text field with a label, a description and an error line.\n *\n * The field's focus state is animated rather than switched. A border that\n * snaps between two colours reads as a redraw; one that crosses over in\n * 150ms reads as the field responding to you. The interpolation runs on the\n * UI thread, so it costs nothing and keeps up with a fast tab through a form.\n *\n * Two backgrounds, because a field's job changes with what surrounds it:\n * `outline` sits on the page and draws its own edge; `filled` sits inside a\n * card or a sheet, where a second border next to the container's own reads as\n * a seam.\n *\n * ```tsx\n * \n * \n * } />\n * ```\n *\n * ## An icon inside the field, and why it lives here\n *\n * `startContent` and `endContent` are measured and turned into padding on the\n * text, so a value never runs underneath them however wide they turn out to\n * be. They are positioned against the *field box* rather than against the\n * component, which is the whole reason they are a prop rather than something\n * you compose: a label and a description are laid out above and below the\n * field, so anything centred on the component as a whole drifts upward the\n * moment a label is added.\n *\n * Both are pressable. A button dropped in there — a clear ✕, a show-password\n * eye — gets its own touches, while the padding around it stays transparent so\n * a tap that misses still lands on the field and puts the caret in it. An icon\n * that is only decoration should say so with `interactiveContent={false}`,\n * which gives the whole field back to the caret and takes the icon out of the\n * accessibility tree.\n *\n * `InputGroup` is still the right answer for a decorator that is not part of\n * the field — a button attached to its end, a select bolted to its start, an\n * addon with its own background. It measures the same way; it just spans a\n * different box.\n */\nimport { forwardRef, useCallback, useEffect, useState, type ReactNode } from 'react';\nimport {\n TextInput,\n View,\n type LayoutChangeEvent,\n type TextInputProps,\n} from 'react-native';\nimport Animated, {\n interpolateColor,\n useAnimatedStyle,\n useSharedValue,\n withTiming,\n} from 'react-native-reanimated';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport { useCSSVariable } from 'uniwind';\nimport type { KeyboardAvoidanceMode } from '@/hooks/use-keyboard-avoidance';\nimport { IconColorProvider } from '@/components/ui/icons';\nimport { KeyboardAvoider } from '@/components/ui/keyboard-avoider';\nimport { Text, textChildren } from '@/components/ui/text';\nimport { Label } from '@/components/ui/label';\nimport { inputContentPadding } from '@/components/ui/input-content-padding';\n\n/** Long enough to read as a transition, short enough not to lag a fast tab. */\nconst FOCUS_DURATION = 150;\n\nconst inputVariants = tv({\n slots: {\n container: 'w-full gap-1.5',\n /*\n * `font-normal` on a field that is not bold: this is a TextInput, so it\n * does not go through the Text primitive and inherits nothing. The class\n * is what an app's `--font-normal` token attaches to, and without it a\n * custom font would stop at the edge of every field in the library.\n */\n field: 'w-full rounded-lg border font-normal text-foreground',\n description: 'text-sm text-muted-foreground',\n error: 'text-sm text-destructive',\n /*\n * The box the content is positioned against, and the reason this works\n * where wrapping the whole component does not: it is the field alone, so\n * a label above it and a description below it cannot move what is inside.\n */\n fieldBox: 'relative w-full',\n startContent:\n 'absolute bottom-0 start-0 top-0 z-10 flex-row items-center justify-center gap-2',\n endContent:\n 'absolute bottom-0 end-0 top-0 z-10 flex-row items-center justify-center gap-2',\n },\n variants: {\n variant: {\n // The border colour is animated, so it is deliberately not set here —\n // only the background and the resting border width belong to the class.\n outline: { field: 'bg-background' },\n filled: { field: 'bg-muted' },\n },\n size: {\n /*\n * The font size is given as a length rather than through a `text-*`\n * step, because those set a size and a line height together — 16px text\n * in a 24px line box. The extra leading lands above the glyphs, so in a\n * field of fixed height the text and the placeholder end up sitting\n * below the middle of the box, a few pixels off whatever is beside them.\n * A length sets the size alone and leaves the line box the font's own.\n */\n // The content boxes take the field's own horizontal padding, so an icon\n // sits at exactly the inset the text would have started at.\n //\n // Eight points a step, 32 to 64. `xs` is for a field inside something\n // else that is already compact, such as a table cell or a toolbar; `xl`\n // is for the one field a screen is about, such as a search or an amount.\n xs: { field: 'h-8 px-2.5 text-[13px]', startContent: 'px-2.5', endContent: 'px-2.5' },\n sm: { field: 'h-10 px-3 text-[14px]', startContent: 'px-3', endContent: 'px-3' },\n md: { field: 'h-12 px-3.5 text-[16px]', startContent: 'px-3.5', endContent: 'px-3.5' },\n lg: { field: 'h-14 px-4 text-[16px]', startContent: 'px-4', endContent: 'px-4' },\n xl: { field: 'h-16 px-5 text-[18px]', startContent: 'px-5', endContent: 'px-5' },\n },\n multiline: {\n // A multiline field grows, so a fixed height would crop it. Text starts\n // at the top rather than floating in the middle of an empty box — and\n // once there are several lines the leading is wanted back, since here it\n // separates the lines instead of pushing one off centre.\n //\n // The content follows the text to the top for the same reason: centred\n // in a box that grows, an icon slides further from the line it belongs\n // to with every line typed.\n true: {\n field: 'h-auto min-h-24 py-3 leading-normal',\n startContent: 'bottom-auto py-3',\n endContent: 'bottom-auto py-3',\n },\n },\n disabled: {\n true: {\n field: 'opacity-[0.64]',\n startContent: 'opacity-[0.64]',\n endContent: 'opacity-[0.64]',\n },\n },\n },\n defaultVariants: {\n variant: 'outline',\n size: 'md',\n },\n});\n\ntype InputVariantProps = VariantProps;\n\nexport interface InputProps\n extends TextInputProps,\n Omit {\n className?: string;\n containerClassName?: string;\n label?: string;\n description?: string;\n /** Error message. When set, the field renders in its invalid state. */\n errorMessage?: string;\n /** Marks the field required — an asterisk on the label, and the a11y state. */\n isRequired?: boolean;\n disabled?: boolean;\n /**\n * Content inside the field, before the text — usually an icon. Measured, so\n * the text is padded clear of it however wide it is.\n *\n * `start`, not `left`: it follows the reading direction, and swaps sides\n * under `Direction dir=\"rtl\"` along with the text it introduces.\n */\n startContent?: ReactNode;\n /** Content inside the field, after the text — an icon, a unit, a count. */\n endContent?: ReactNode;\n /**\n * Whether touches reach the content.\n *\n * On by default, so a button placed in the field — a clear ✕, a\n * show-password eye, a unit picker — is pressable without anything else\n * being passed. Only the content itself takes those touches: the padding\n * around it is transparent, so a tap that misses the button still lands on\n * the field and puts the caret in it.\n *\n * Turn it off for pure decoration, where the icon should not be a target at\n * all and every pixel of the field should focus it. That also drops the\n * content from the accessibility tree, which is right for an icon that only\n * restates the label.\n */\n interactiveContent?: boolean;\n /**\n * Keep the field clear of the software keyboard. Moves by exactly the\n * overlap, and not at all when the field is already clear — or when the\n * keyboard belongs to a different field. The overlap is re-read every frame\n * while the field is focused, so the field keeps its place in the page as it\n * scrolls under and back out of the keyboard.\n *\n * Install `react-native-keyboard-controller` for this to behave on Android.\n *\n * Do not toggle this at runtime — it changes which component renders the\n * container, which would remount the field and drop focus.\n */\n avoidKeyboard?: boolean;\n /**\n * How the field gets clear. `lift` moves it up by its overlap and follows\n * the scroll — right for a field in the flow of a page. `dock` makes it\n * travel with the keyboard, for a composer already pinned near the bottom\n * edge; pair it with `keyboardBottomInset`.\n */\n keyboardMode?: KeyboardAvoidanceMode;\n /** Gap kept between the field and the keyboard. `keyboardMode=\"lift\"` only. */\n keyboardOffset?: number;\n /**\n * How far above the bottom edge the field already sits — usually the safe\n * area inset. `keyboardMode=\"dock\"` only.\n */\n keyboardBottomInset?: number;\n}\n\nconst AnimatedTextInput = Animated.createAnimatedComponent(TextInput);\n\nexport const Input = forwardRef(\n (\n {\n className,\n containerClassName,\n label,\n description,\n errorMessage,\n isRequired,\n disabled,\n startContent,\n endContent,\n interactiveContent = true,\n variant,\n size,\n avoidKeyboard = false,\n keyboardMode = 'lift',\n keyboardOffset = 16,\n keyboardBottomInset = 0,\n onFocus,\n onBlur,\n style,\n ...props\n },\n ref\n ) => {\n const [focused, setFocused] = useState(false);\n const invalid = !!errorMessage;\n // Measured so the text can be padded clear of the content, whatever it\n // turns out to be — an icon, a unit, a two-word label.\n const [startWidth, setStartWidth] = useState(0);\n const [endWidth, setEndWidth] = useState(0);\n\n const placeholderColor = useCSSVariable('--color-muted-foreground');\n const restColor = useCSSVariable('--color-input');\n const focusColor = useCSSVariable('--color-ring');\n const errorColor = useCSSVariable('--color-destructive');\n\n const slots = inputVariants({\n variant,\n size,\n multiline: !!props.multiline,\n disabled: !!disabled,\n });\n\n /*\n * Border colour is driven by one 0..1 value rather than by a class per\n * state. Uniwind can only swap a class wholesale, which is the snap this\n * is here to avoid, and a shared value crosses between the two colours on\n * the UI thread without a re-render.\n */\n const focus = useSharedValue(0);\n useEffect(() => {\n focus.value = withTiming(focused ? 1 : 0, { duration: FOCUS_DURATION });\n }, [focused, focus]);\n\n const resting = typeof restColor === 'string' ? restColor : '#e5e5e5';\n const active = invalid\n ? typeof errorColor === 'string'\n ? errorColor\n : '#ef4444'\n : typeof focusColor === 'string'\n ? focusColor\n : '#a3a3a3';\n // An invalid field is tinted even at rest — the error is a fact about the\n // value, not about whether the field happens to be focused.\n const idle = invalid ? active : resting;\n\n const borderStyle = useAnimatedStyle(() => ({\n borderColor: interpolateColor(focus.value, [0, 1], [idle, active]),\n }));\n\n const handleFocus = useCallback>(\n (event) => {\n setFocused(true);\n onFocus?.(event);\n },\n [onFocus]\n );\n\n const handleBlur = useCallback>(\n (event) => {\n setFocused(false);\n onBlur?.(event);\n },\n [onBlur]\n );\n\n const handleStartLayout = useCallback((event: LayoutChangeEvent) => {\n setStartWidth(event.nativeEvent.layout.width);\n }, []);\n\n const handleEndLayout = useCallback((event: LayoutChangeEvent) => {\n setEndWidth(event.nativeEvent.layout.width);\n }, []);\n\n const startPadding = inputContentPadding(startWidth, !!startContent);\n const endPadding = inputContentPadding(endWidth, !!endContent);\n\n /*\n * The content is quieter than the value. An icon at the same weight as the\n * text reads as part of what was typed, and the placeholder colour is\n * already the field's own word for \"not the value\".\n */\n const iconColor = typeof placeholderColor === 'string' ? placeholderColor : undefined;\n\n const field = (\n \n );\n\n const body = (\n <>\n {label ? (\n \n ) : null}\n {startContent || endContent ? (\n /*\n * Only wrapped when there is something to position. An extra view\n * around every field in an app is a real cost in a long form, and\n * the field is `w-full` either way so the wrapper changes nothing\n * else about the layout.\n */\n \n {startContent ? (\n \n \n {textChildren(startContent)}\n \n \n ) : null}\n {field}\n {endContent ? (\n \n \n {textChildren(endContent)}\n \n \n ) : null}\n \n ) : (\n field\n )}\n {errorMessage ? (\n {errorMessage}\n ) : description ? (\n {description}\n ) : null}\n \n );\n\n const containerClasses = slots.container({ className: containerClassName });\n\n /*\n * The keyboard hook is deliberately behind a component boundary rather\n * than an `enabled` flag. Calling it at all has global consequences —\n * without the keyboard controller installed it falls back to Reanimated's\n * useAnimatedKeyboard, which switches Android out of adjustResize for the\n * whole app. A field that never asked to avoid the keyboard must not do\n * that to every other screen.\n */\n if (avoidKeyboard) {\n return (\n \n {body}\n \n );\n }\n\n return {body};\n }\n);\n\nInput.displayName = 'Input';\n" + "content": "/**\n * Input — a text field with a label, a description and an error line.\n *\n * The field's focus state is animated rather than switched. A border that\n * snaps between two colours reads as a redraw; one that crosses over in\n * 150ms reads as the field responding to you. The interpolation runs on the\n * UI thread, so it costs nothing and keeps up with a fast tab through a form.\n *\n * Two backgrounds, because a field's job changes with what surrounds it:\n * `outline` sits on the page and draws its own edge; `filled` sits inside a\n * card or a sheet, where a second border next to the container's own reads as\n * a seam.\n *\n * ```tsx\n * \n * \n * } />\n * ```\n *\n * ## An icon inside the field, and why it lives here\n *\n * `startContent` and `endContent` are measured and turned into padding on the\n * text, so a value never runs underneath them however wide they turn out to\n * be. They are positioned against the *field box* rather than against the\n * component, which is the whole reason they are a prop rather than something\n * you compose: a label and a description are laid out above and below the\n * field, so anything centred on the component as a whole drifts upward the\n * moment a label is added.\n *\n * Both are pressable. A button dropped in there — a clear ✕, a show-password\n * eye — gets its own touches, while the padding around it stays transparent so\n * a tap that misses still lands on the field and puts the caret in it. An icon\n * that is only decoration should say so with `interactiveContent={false}`,\n * which gives the whole field back to the caret and takes the icon out of the\n * accessibility tree.\n *\n * `InputGroup` is still the right answer for a decorator that is not part of\n * the field — a button attached to its end, a select bolted to its start, an\n * addon with its own background. It measures the same way; it just spans a\n * different box.\n */\nimport { forwardRef, useCallback, useEffect, useState, type ReactNode } from 'react';\nimport {\n TextInput,\n View,\n type LayoutChangeEvent,\n type TextInputProps,\n} from 'react-native';\nimport Animated, {\n interpolateColor,\n useAnimatedStyle,\n useSharedValue,\n withTiming,\n} from 'react-native-reanimated';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport { useCSSVariable } from 'uniwind';\nimport type { KeyboardAvoidanceMode } from '@/hooks/use-keyboard-avoidance';\nimport { IconColorProvider } from '@/components/ui/icons';\nimport { KeyboardAvoider } from '@/components/ui/keyboard-avoider';\nimport { Text, textChildren } from '@/components/ui/text';\nimport { Label } from '@/components/ui/label';\nimport { inputContentPadding } from '@/components/ui/input-content-padding';\nimport type { TextInputRef } from '@/components/ui/refs';\n\n/** Long enough to read as a transition, short enough not to lag a fast tab. */\nconst FOCUS_DURATION = 150;\n\nconst inputVariants = tv({\n slots: {\n container: 'w-full gap-1.5',\n /*\n * `font-normal` on a field that is not bold: this is a TextInput, so it\n * does not go through the Text primitive and inherits nothing. The class\n * is what an app's `--font-normal` token attaches to, and without it a\n * custom font would stop at the edge of every field in the library.\n */\n field: 'w-full rounded-lg border font-normal text-foreground',\n description: 'text-sm text-muted-foreground',\n error: 'text-sm text-destructive',\n /*\n * The box the content is positioned against, and the reason this works\n * where wrapping the whole component does not: it is the field alone, so\n * a label above it and a description below it cannot move what is inside.\n */\n fieldBox: 'relative w-full',\n startContent:\n 'absolute bottom-0 start-0 top-0 z-10 flex-row items-center justify-center gap-2',\n endContent:\n 'absolute bottom-0 end-0 top-0 z-10 flex-row items-center justify-center gap-2',\n },\n variants: {\n variant: {\n // The border colour is animated, so it is deliberately not set here —\n // only the background and the resting border width belong to the class.\n outline: { field: 'bg-background' },\n filled: { field: 'bg-muted' },\n },\n size: {\n /*\n * The font size is given as a length rather than through a `text-*`\n * step, because those set a size and a line height together — 16px text\n * in a 24px line box. The extra leading lands above the glyphs, so in a\n * field of fixed height the text and the placeholder end up sitting\n * below the middle of the box, a few pixels off whatever is beside them.\n * A length sets the size alone and leaves the line box the font's own.\n */\n // The content boxes take the field's own horizontal padding, so an icon\n // sits at exactly the inset the text would have started at.\n //\n // Eight points a step, 32 to 64. `xs` is for a field inside something\n // else that is already compact, such as a table cell or a toolbar; `xl`\n // is for the one field a screen is about, such as a search or an amount.\n xs: { field: 'h-8 px-2.5 text-[13px]', startContent: 'px-2.5', endContent: 'px-2.5' },\n sm: { field: 'h-10 px-3 text-[14px]', startContent: 'px-3', endContent: 'px-3' },\n md: { field: 'h-12 px-3.5 text-[16px]', startContent: 'px-3.5', endContent: 'px-3.5' },\n lg: { field: 'h-14 px-4 text-[16px]', startContent: 'px-4', endContent: 'px-4' },\n xl: { field: 'h-16 px-5 text-[18px]', startContent: 'px-5', endContent: 'px-5' },\n },\n multiline: {\n // A multiline field grows, so a fixed height would crop it. Text starts\n // at the top rather than floating in the middle of an empty box — and\n // once there are several lines the leading is wanted back, since here it\n // separates the lines instead of pushing one off centre.\n //\n // The content follows the text to the top for the same reason: centred\n // in a box that grows, an icon slides further from the line it belongs\n // to with every line typed.\n true: {\n field: 'h-auto min-h-24 py-3 leading-normal',\n startContent: 'bottom-auto py-3',\n endContent: 'bottom-auto py-3',\n },\n },\n disabled: {\n true: {\n field: 'opacity-[0.64]',\n startContent: 'opacity-[0.64]',\n endContent: 'opacity-[0.64]',\n },\n },\n },\n defaultVariants: {\n variant: 'outline',\n size: 'md',\n },\n});\n\ntype InputVariantProps = VariantProps;\n\nexport interface InputProps\n extends TextInputProps,\n Omit {\n className?: string;\n containerClassName?: string;\n label?: string;\n description?: string;\n /** Error message. When set, the field renders in its invalid state. */\n errorMessage?: string;\n /** Marks the field required — an asterisk on the label, and the a11y state. */\n isRequired?: boolean;\n disabled?: boolean;\n /**\n * Content inside the field, before the text — usually an icon. Measured, so\n * the text is padded clear of it however wide it is.\n *\n * `start`, not `left`: it follows the reading direction, and swaps sides\n * under `Direction dir=\"rtl\"` along with the text it introduces.\n */\n startContent?: ReactNode;\n /** Content inside the field, after the text — an icon, a unit, a count. */\n endContent?: ReactNode;\n /**\n * Whether touches reach the content.\n *\n * On by default, so a button placed in the field — a clear ✕, a\n * show-password eye, a unit picker — is pressable without anything else\n * being passed. Only the content itself takes those touches: the padding\n * around it is transparent, so a tap that misses the button still lands on\n * the field and puts the caret in it.\n *\n * Turn it off for pure decoration, where the icon should not be a target at\n * all and every pixel of the field should focus it. That also drops the\n * content from the accessibility tree, which is right for an icon that only\n * restates the label.\n */\n interactiveContent?: boolean;\n /**\n * Keep the field clear of the software keyboard. Moves by exactly the\n * overlap, and not at all when the field is already clear — or when the\n * keyboard belongs to a different field. The overlap is re-read every frame\n * while the field is focused, so the field keeps its place in the page as it\n * scrolls under and back out of the keyboard.\n *\n * Install `react-native-keyboard-controller` for this to behave on Android.\n *\n * Do not toggle this at runtime — it changes which component renders the\n * container, which would remount the field and drop focus.\n */\n avoidKeyboard?: boolean;\n /**\n * How the field gets clear. `lift` moves it up by its overlap and follows\n * the scroll — right for a field in the flow of a page. `dock` makes it\n * travel with the keyboard, for a composer already pinned near the bottom\n * edge; pair it with `keyboardBottomInset`.\n */\n keyboardMode?: KeyboardAvoidanceMode;\n /** Gap kept between the field and the keyboard. `keyboardMode=\"lift\"` only. */\n keyboardOffset?: number;\n /**\n * How far above the bottom edge the field already sits — usually the safe\n * area inset. `keyboardMode=\"dock\"` only.\n */\n keyboardBottomInset?: number;\n}\n\nconst AnimatedTextInput = Animated.createAnimatedComponent(TextInput);\n\nexport const Input = forwardRef(\n (\n {\n className,\n containerClassName,\n label,\n description,\n errorMessage,\n isRequired,\n disabled,\n startContent,\n endContent,\n interactiveContent = true,\n variant,\n size,\n avoidKeyboard = false,\n keyboardMode = 'lift',\n keyboardOffset = 16,\n keyboardBottomInset = 0,\n onFocus,\n onBlur,\n style,\n ...props\n },\n ref\n ) => {\n const [focused, setFocused] = useState(false);\n const invalid = !!errorMessage;\n // Measured so the text can be padded clear of the content, whatever it\n // turns out to be — an icon, a unit, a two-word label.\n const [startWidth, setStartWidth] = useState(0);\n const [endWidth, setEndWidth] = useState(0);\n\n const placeholderColor = useCSSVariable('--color-muted-foreground');\n const restColor = useCSSVariable('--color-input');\n const focusColor = useCSSVariable('--color-ring');\n const errorColor = useCSSVariable('--color-destructive');\n\n const slots = inputVariants({\n variant,\n size,\n multiline: !!props.multiline,\n disabled: !!disabled,\n });\n\n /*\n * Border colour is driven by one 0..1 value rather than by a class per\n * state. Uniwind can only swap a class wholesale, which is the snap this\n * is here to avoid, and a shared value crosses between the two colours on\n * the UI thread without a re-render.\n */\n const focus = useSharedValue(0);\n useEffect(() => {\n focus.value = withTiming(focused ? 1 : 0, { duration: FOCUS_DURATION });\n }, [focused, focus]);\n\n const resting = typeof restColor === 'string' ? restColor : '#e5e5e5';\n const active = invalid\n ? typeof errorColor === 'string'\n ? errorColor\n : '#ef4444'\n : typeof focusColor === 'string'\n ? focusColor\n : '#a3a3a3';\n // An invalid field is tinted even at rest — the error is a fact about the\n // value, not about whether the field happens to be focused.\n const idle = invalid ? active : resting;\n\n const borderStyle = useAnimatedStyle(() => ({\n borderColor: interpolateColor(focus.value, [0, 1], [idle, active]),\n }));\n\n const handleFocus = useCallback>(\n (event) => {\n setFocused(true);\n onFocus?.(event);\n },\n [onFocus]\n );\n\n const handleBlur = useCallback>(\n (event) => {\n setFocused(false);\n onBlur?.(event);\n },\n [onBlur]\n );\n\n const handleStartLayout = useCallback((event: LayoutChangeEvent) => {\n setStartWidth(event.nativeEvent.layout.width);\n }, []);\n\n const handleEndLayout = useCallback((event: LayoutChangeEvent) => {\n setEndWidth(event.nativeEvent.layout.width);\n }, []);\n\n const startPadding = inputContentPadding(startWidth, !!startContent);\n const endPadding = inputContentPadding(endWidth, !!endContent);\n\n /*\n * The content is quieter than the value. An icon at the same weight as the\n * text reads as part of what was typed, and the placeholder colour is\n * already the field's own word for \"not the value\".\n */\n const iconColor = typeof placeholderColor === 'string' ? placeholderColor : undefined;\n\n const field = (\n \n );\n\n const body = (\n <>\n {label ? (\n \n ) : null}\n {startContent || endContent ? (\n /*\n * Only wrapped when there is something to position. An extra view\n * around every field in an app is a real cost in a long form, and\n * the field is `w-full` either way so the wrapper changes nothing\n * else about the layout.\n */\n \n {startContent ? (\n \n \n {textChildren(startContent)}\n \n \n ) : null}\n {field}\n {endContent ? (\n \n \n {textChildren(endContent)}\n \n \n ) : null}\n \n ) : (\n field\n )}\n {errorMessage ? (\n {errorMessage}\n ) : description ? (\n {description}\n ) : null}\n \n );\n\n const containerClasses = slots.container({ className: containerClassName });\n\n /*\n * The keyboard hook is deliberately behind a component boundary rather\n * than an `enabled` flag. Calling it at all has global consequences —\n * without the keyboard controller installed it falls back to Reanimated's\n * useAnimatedKeyboard, which switches Android out of adjustResize for the\n * whole app. A field that never asked to avoid the keyboard must not do\n * that to every other screen.\n */\n if (avoidKeyboard) {\n return (\n \n {body}\n \n );\n }\n\n return {body};\n }\n);\n\nInput.displayName = 'Input';\n" }, { "path": "ui/input-content-padding.ts", diff --git a/apps/docs/public/r/item.json b/apps/docs/public/r/item.json index 2f822d94f..647cb0218 100644 --- a/apps/docs/public/r/item.json +++ b/apps/docs/public/r/item.json @@ -5,6 +5,7 @@ "registryDependencies": [ "animated-pressable", "cn", + "refs", "text" ], "dependencies": [ @@ -15,7 +16,7 @@ { "path": "ui/item.tsx", "type": "registry:ui", - "content": "/**\n * Item — a row of content: media on one side, a title and description in the\n * middle, actions on the other side.\n *\n * It is the shape almost every list in an app already has — a settings row, a\n * file in a picker, a member in a team list, a tool call in a transcript — so\n * it exists as one composable primitive rather than being rebuilt per screen\n * with slightly different padding each time.\n *\n * ```tsx\n * \n * \n * \n * Invoice.pdf\n * 2.4 MB · Updated yesterday\n * \n * \n * \n * ```\n *\n * Two axes, and they are independent. `orientation` on the item decides\n * whether its own parts sit side by side or stack into a card; `orientation`\n * on `Item.Group` decides whether the items themselves run down the screen or\n * across it. A carousel wants both set to the non-default: a horizontal group\n * of vertical items.\n */\nimport { createContext, forwardRef, useContext, type ReactNode } from 'react';\nimport { View, type ViewProps } from 'react-native';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport { AnimatedPressable, type AnimatedPressableProps } from '@/components/ui/animated-pressable';\nimport { Text, type TextProps, textChildren } from '@/components/ui/text';\nimport { cn } from '@/lib/cn';\n\ntype ItemSize = 'default' | 'sm' | 'xs';\ntype ItemOrientation = 'horizontal' | 'vertical';\n\nconst itemVariants = tv({\n slots: {\n root: 'gap-3 rounded-xl',\n title: 'font-medium text-foreground',\n description: 'text-muted-foreground',\n },\n variants: {\n variant: {\n default: {},\n outline: { root: 'border border-border' },\n muted: { root: 'bg-muted' },\n },\n size: {\n default: { root: 'p-4', title: 'text-base', description: 'text-sm' },\n sm: { root: 'p-3', title: 'text-sm', description: 'text-xs' },\n xs: { root: 'gap-2 p-2', title: 'text-sm', description: 'text-xs' },\n },\n orientation: {\n /** Media, text and actions side by side — the list-row shape. */\n horizontal: { root: 'w-full flex-row items-center' },\n /** Stacked into a card — the shape a horizontal carousel wants. */\n vertical: { root: 'flex-col items-start' },\n },\n disabled: {\n true: { root: 'opacity-[0.64]' },\n },\n },\n defaultVariants: {\n variant: 'default',\n size: 'default',\n orientation: 'horizontal',\n },\n});\n\n/** Sub-components inherit the row's density and axis rather than repeating them. */\nconst ItemContext = createContext<{ size: ItemSize; orientation: ItemOrientation }>({\n size: 'default',\n orientation: 'horizontal',\n});\n\nconst mediaVariants = tv({\n base: 'shrink-0 items-center justify-center',\n variants: {\n variant: {\n /** No box — for an Avatar or anything that styles itself. */\n default: '',\n /** Rounded square tile sized for an icon. */\n icon: 'rounded-lg border border-border bg-muted',\n /** Clipped frame for an image or thumbnail. */\n image: 'overflow-hidden rounded-lg bg-muted',\n },\n size: {\n default: '',\n sm: '',\n xs: '',\n },\n },\n compoundVariants: [\n { variant: 'icon', size: 'default', class: 'h-10 w-10' },\n { variant: 'icon', size: 'sm', class: 'h-8 w-8' },\n { variant: 'icon', size: 'xs', class: 'h-6 w-6' },\n { variant: 'image', size: 'default', class: 'h-12 w-12' },\n { variant: 'image', size: 'sm', class: 'h-10 w-10' },\n { variant: 'image', size: 'xs', class: 'h-8 w-8' },\n ],\n defaultVariants: {\n variant: 'default',\n size: 'default',\n },\n});\n\nexport interface ItemProps\n extends Omit,\n Omit, 'disabled'> {\n className?: string;\n disabled?: boolean;\n /**\n * Row density. `Item.Media`, `Item.Title` and `Item.Description` follow it,\n * so it only needs setting here.\n */\n size?: ItemSize;\n /**\n * `horizontal` is the list row: media, text and actions side by side.\n * `vertical` stacks them into a card, which is what a horizontal carousel\n * wants — and it is also what `Item.Header` and `Item.Footer` need, since\n * both are full-width strips.\n */\n orientation?: ItemOrientation;\n children?: ReactNode;\n}\n\n/**\n * Renders as a pressable when given `onPress`, and as a plain view otherwise,\n * so a static row does not announce itself as a button.\n */\nconst ItemRoot = forwardRef(\n (\n {\n className,\n variant,\n size = 'default',\n orientation = 'horizontal',\n disabled,\n children,\n onPress,\n ...props\n },\n ref\n ) => {\n const { root } = itemVariants({ variant, size, orientation, disabled: !!disabled });\n\n const body = !onPress ? (\n \n {textChildren(children)}\n \n ) : (\n \n {textChildren(children)}\n \n );\n\n return (\n {body}\n );\n }\n);\nItemRoot.displayName = 'Item';\n\nexport interface ItemGroupProps extends ViewProps {\n className?: string;\n /**\n * `vertical` stacks the items — the settings-list shape. `horizontal` runs\n * them across instead, for a carousel; pair it with a scrollable and\n * `orientation=\"vertical\"` on each item so every entry reads as a card.\n */\n orientation?: ItemOrientation;\n children?: ReactNode;\n}\n\n/** Stack of items. Pair with `Item.Separator` between them. */\nconst ItemGroup = forwardRef(\n ({ className, orientation = 'vertical', children, ...props }, ref) => (\n \n {textChildren(children)}\n \n )\n);\nItemGroup.displayName = 'Item.Group';\n\nexport interface ItemSeparatorProps extends ViewProps {\n className?: string;\n /** Match the group's axis: a horizontal group needs vertical hairlines. */\n orientation?: ItemOrientation;\n}\n\n/** Hairline between rows in a group. */\nconst ItemSeparator = forwardRef(\n ({ className, orientation = 'vertical', ...props }, ref) => (\n \n )\n);\nItemSeparator.displayName = 'Item.Separator';\n\nexport interface ItemMediaProps\n extends ViewProps,\n VariantProps {\n className?: string;\n children?: ReactNode;\n}\n\n/** Leading slot: an icon tile, a thumbnail, or an avatar passed through. */\nconst ItemMedia = forwardRef(\n ({ className, variant, size, children, ...props }, ref) => {\n const item = useContext(ItemContext);\n\n return (\n \n {textChildren(children)}\n \n );\n }\n);\nItemMedia.displayName = 'Item.Media';\n\nexport interface ItemContentProps extends ViewProps {\n className?: string;\n children?: ReactNode;\n}\n\n/**\n * The text column. In a horizontal item it takes the remaining width so the\n * actions stay pinned to the trailing edge; in a vertical one it just fills\n * the width, because there is nothing beside it to push against.\n */\nconst ItemContent = forwardRef(\n ({ className, children, ...props }, ref) => {\n const { orientation } = useContext(ItemContext);\n\n return (\n \n {textChildren(children)}\n \n );\n }\n);\nItemContent.displayName = 'Item.Content';\n\nexport interface ItemTitleProps extends TextProps {\n className?: string;\n}\n\nconst ItemTitle = forwardRef, ItemTitleProps>(\n ({ className, ...props }, ref) => {\n const { size } = useContext(ItemContext);\n const { title } = itemVariants({ size });\n\n return ;\n }\n);\nItemTitle.displayName = 'Item.Title';\n\nexport interface ItemDescriptionProps extends TextProps {\n className?: string;\n}\n\nconst ItemDescription = forwardRef<\n React.ElementRef,\n ItemDescriptionProps\n>(({ className, ...props }, ref) => {\n const { size } = useContext(ItemContext);\n const { description } = itemVariants({ size });\n\n return ;\n});\nItemDescription.displayName = 'Item.Description';\n\nexport interface ItemActionsProps extends ViewProps {\n className?: string;\n children?: ReactNode;\n}\n\n/** Trailing slot: buttons, a chevron, a switch. */\nconst ItemActions = forwardRef(\n ({ className, children, ...props }, ref) => (\n \n {textChildren(children)}\n \n )\n);\nItemActions.displayName = 'Item.Actions';\n\nexport interface ItemHeaderProps extends ViewProps {\n className?: string;\n children?: ReactNode;\n}\n\n/**\n * Full-width strip above the item's main content — an eyebrow label, a badge\n * row. Needs `orientation=\"vertical\"` on the item, since a strip only makes\n * sense once the item stacks.\n */\nconst ItemHeader = forwardRef(\n ({ className, children, ...props }, ref) => (\n \n {textChildren(children)}\n \n )\n);\nItemHeader.displayName = 'Item.Header';\n\nexport interface ItemFooterProps extends ViewProps {\n className?: string;\n children?: ReactNode;\n}\n\n/** Full-width strip below the row's main content. */\nconst ItemFooter = forwardRef(\n ({ className, children, ...props }, ref) => (\n \n {textChildren(children)}\n \n )\n);\nItemFooter.displayName = 'Item.Footer';\n\nexport const Item = Object.assign(ItemRoot, {\n Group: ItemGroup,\n Separator: ItemSeparator,\n Media: ItemMedia,\n Content: ItemContent,\n Title: ItemTitle,\n Description: ItemDescription,\n Actions: ItemActions,\n Header: ItemHeader,\n Footer: ItemFooter,\n});\n" + "content": "/**\n * Item — a row of content: media on one side, a title and description in the\n * middle, actions on the other side.\n *\n * It is the shape almost every list in an app already has — a settings row, a\n * file in a picker, a member in a team list, a tool call in a transcript — so\n * it exists as one composable primitive rather than being rebuilt per screen\n * with slightly different padding each time.\n *\n * ```tsx\n * \n * \n * \n * Invoice.pdf\n * 2.4 MB · Updated yesterday\n * \n * \n * \n * ```\n *\n * Two axes, and they are independent. `orientation` on the item decides\n * whether its own parts sit side by side or stack into a card; `orientation`\n * on `Item.Group` decides whether the items themselves run down the screen or\n * across it. A carousel wants both set to the non-default: a horizontal group\n * of vertical items.\n */\nimport { createContext, forwardRef, useContext, type ReactNode } from 'react';\nimport { View, type ViewProps } from 'react-native';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport { AnimatedPressable, type AnimatedPressableProps } from '@/components/ui/animated-pressable';\nimport { Text, type TextProps, textChildren } from '@/components/ui/text';\nimport { cn } from '@/lib/cn';\nimport type { TextRef, ViewRef } from '@/components/ui/refs';\n\ntype ItemSize = 'default' | 'sm' | 'xs';\ntype ItemOrientation = 'horizontal' | 'vertical';\n\nconst itemVariants = tv({\n slots: {\n root: 'gap-3 rounded-xl',\n title: 'font-medium text-foreground',\n description: 'text-muted-foreground',\n },\n variants: {\n variant: {\n default: {},\n outline: { root: 'border border-border' },\n muted: { root: 'bg-muted' },\n },\n size: {\n default: { root: 'p-4', title: 'text-base', description: 'text-sm' },\n sm: { root: 'p-3', title: 'text-sm', description: 'text-xs' },\n xs: { root: 'gap-2 p-2', title: 'text-sm', description: 'text-xs' },\n },\n orientation: {\n /** Media, text and actions side by side — the list-row shape. */\n horizontal: { root: 'w-full flex-row items-center' },\n /** Stacked into a card — the shape a horizontal carousel wants. */\n vertical: { root: 'flex-col items-start' },\n },\n disabled: {\n true: { root: 'opacity-[0.64]' },\n },\n },\n defaultVariants: {\n variant: 'default',\n size: 'default',\n orientation: 'horizontal',\n },\n});\n\n/** Sub-components inherit the row's density and axis rather than repeating them. */\nconst ItemContext = createContext<{ size: ItemSize; orientation: ItemOrientation }>({\n size: 'default',\n orientation: 'horizontal',\n});\n\nconst mediaVariants = tv({\n base: 'shrink-0 items-center justify-center',\n variants: {\n variant: {\n /** No box — for an Avatar or anything that styles itself. */\n default: '',\n /** Rounded square tile sized for an icon. */\n icon: 'rounded-lg border border-border bg-muted',\n /** Clipped frame for an image or thumbnail. */\n image: 'overflow-hidden rounded-lg bg-muted',\n },\n size: {\n default: '',\n sm: '',\n xs: '',\n },\n },\n compoundVariants: [\n { variant: 'icon', size: 'default', class: 'h-10 w-10' },\n { variant: 'icon', size: 'sm', class: 'h-8 w-8' },\n { variant: 'icon', size: 'xs', class: 'h-6 w-6' },\n { variant: 'image', size: 'default', class: 'h-12 w-12' },\n { variant: 'image', size: 'sm', class: 'h-10 w-10' },\n { variant: 'image', size: 'xs', class: 'h-8 w-8' },\n ],\n defaultVariants: {\n variant: 'default',\n size: 'default',\n },\n});\n\nexport interface ItemProps\n extends Omit,\n Omit, 'disabled'> {\n className?: string;\n disabled?: boolean;\n /**\n * Row density. `Item.Media`, `Item.Title` and `Item.Description` follow it,\n * so it only needs setting here.\n */\n size?: ItemSize;\n /**\n * `horizontal` is the list row: media, text and actions side by side.\n * `vertical` stacks them into a card, which is what a horizontal carousel\n * wants — and it is also what `Item.Header` and `Item.Footer` need, since\n * both are full-width strips.\n */\n orientation?: ItemOrientation;\n children?: ReactNode;\n}\n\n/**\n * Renders as a pressable when given `onPress`, and as a plain view otherwise,\n * so a static row does not announce itself as a button.\n */\nconst ItemRoot = forwardRef(\n (\n {\n className,\n variant,\n size = 'default',\n orientation = 'horizontal',\n disabled,\n children,\n onPress,\n ...props\n },\n ref\n ) => {\n const { root } = itemVariants({ variant, size, orientation, disabled: !!disabled });\n\n const body = !onPress ? (\n \n {textChildren(children)}\n \n ) : (\n \n {textChildren(children)}\n \n );\n\n return (\n {body}\n );\n }\n);\nItemRoot.displayName = 'Item';\n\nexport interface ItemGroupProps extends ViewProps {\n className?: string;\n /**\n * `vertical` stacks the items — the settings-list shape. `horizontal` runs\n * them across instead, for a carousel; pair it with a scrollable and\n * `orientation=\"vertical\"` on each item so every entry reads as a card.\n */\n orientation?: ItemOrientation;\n children?: ReactNode;\n}\n\n/** Stack of items. Pair with `Item.Separator` between them. */\nconst ItemGroup = forwardRef(\n ({ className, orientation = 'vertical', children, ...props }, ref) => (\n \n {textChildren(children)}\n \n )\n);\nItemGroup.displayName = 'Item.Group';\n\nexport interface ItemSeparatorProps extends ViewProps {\n className?: string;\n /** Match the group's axis: a horizontal group needs vertical hairlines. */\n orientation?: ItemOrientation;\n}\n\n/** Hairline between rows in a group. */\nconst ItemSeparator = forwardRef(\n ({ className, orientation = 'vertical', ...props }, ref) => (\n \n )\n);\nItemSeparator.displayName = 'Item.Separator';\n\nexport interface ItemMediaProps\n extends ViewProps,\n VariantProps {\n className?: string;\n children?: ReactNode;\n}\n\n/** Leading slot: an icon tile, a thumbnail, or an avatar passed through. */\nconst ItemMedia = forwardRef(\n ({ className, variant, size, children, ...props }, ref) => {\n const item = useContext(ItemContext);\n\n return (\n \n {textChildren(children)}\n \n );\n }\n);\nItemMedia.displayName = 'Item.Media';\n\nexport interface ItemContentProps extends ViewProps {\n className?: string;\n children?: ReactNode;\n}\n\n/**\n * The text column. In a horizontal item it takes the remaining width so the\n * actions stay pinned to the trailing edge; in a vertical one it just fills\n * the width, because there is nothing beside it to push against.\n */\nconst ItemContent = forwardRef(\n ({ className, children, ...props }, ref) => {\n const { orientation } = useContext(ItemContext);\n\n return (\n \n {textChildren(children)}\n \n );\n }\n);\nItemContent.displayName = 'Item.Content';\n\nexport interface ItemTitleProps extends TextProps {\n className?: string;\n}\n\nconst ItemTitle = forwardRef(\n ({ className, ...props }, ref) => {\n const { size } = useContext(ItemContext);\n const { title } = itemVariants({ size });\n\n return ;\n }\n);\nItemTitle.displayName = 'Item.Title';\n\nexport interface ItemDescriptionProps extends TextProps {\n className?: string;\n}\n\nconst ItemDescription = forwardRef<\n TextRef,\n ItemDescriptionProps\n>(({ className, ...props }, ref) => {\n const { size } = useContext(ItemContext);\n const { description } = itemVariants({ size });\n\n return ;\n});\nItemDescription.displayName = 'Item.Description';\n\nexport interface ItemActionsProps extends ViewProps {\n className?: string;\n children?: ReactNode;\n}\n\n/** Trailing slot: buttons, a chevron, a switch. */\nconst ItemActions = forwardRef(\n ({ className, children, ...props }, ref) => (\n \n {textChildren(children)}\n \n )\n);\nItemActions.displayName = 'Item.Actions';\n\nexport interface ItemHeaderProps extends ViewProps {\n className?: string;\n children?: ReactNode;\n}\n\n/**\n * Full-width strip above the item's main content — an eyebrow label, a badge\n * row. Needs `orientation=\"vertical\"` on the item, since a strip only makes\n * sense once the item stacks.\n */\nconst ItemHeader = forwardRef(\n ({ className, children, ...props }, ref) => (\n \n {textChildren(children)}\n \n )\n);\nItemHeader.displayName = 'Item.Header';\n\nexport interface ItemFooterProps extends ViewProps {\n className?: string;\n children?: ReactNode;\n}\n\n/** Full-width strip below the row's main content. */\nconst ItemFooter = forwardRef(\n ({ className, children, ...props }, ref) => (\n \n {textChildren(children)}\n \n )\n);\nItemFooter.displayName = 'Item.Footer';\n\nexport const Item = Object.assign(ItemRoot, {\n Group: ItemGroup,\n Separator: ItemSeparator,\n Media: ItemMedia,\n Content: ItemContent,\n Title: ItemTitle,\n Description: ItemDescription,\n Actions: ItemActions,\n Header: ItemHeader,\n Footer: ItemFooter,\n});\n" } ] } diff --git a/apps/docs/public/r/kpi.json b/apps/docs/public/r/kpi.json index a8f138918..958af995f 100644 --- a/apps/docs/public/r/kpi.json +++ b/apps/docs/public/r/kpi.json @@ -8,6 +8,7 @@ "icons", "line-chart", "progress", + "refs", "surface", "text" ], @@ -20,7 +21,7 @@ { "path": "ui/kpi.tsx", "type": "registry:ui", - "content": "/**\n * Kpi — one number, and what it is doing.\n *\n * A metric card is not a chart with a caption. The number is the message and\n * the chart is the footnote, so the parts here are sized and ordered around\n * that: a title that stays quiet, a value that does not, a trend that says\n * which way and by how much, and a sparkline that is allowed to be small\n * because nobody is reading values off it.\n *\n * ```tsx\n * \n * \n * Active users\n * \n * \n * 12,480\n * \n * \n * \n * \n * ```\n *\n * The trend is given a number rather than a written string, so the component\n * decides the direction and the colour rather than the call site remembering\n * to keep a minus sign and a red in step. Where a rise is the bad news —\n * churn, latency, cost — say so with `goodDirection=\"down\"` and the colour\n * follows the meaning instead of the sign.\n */\nimport { createContext, forwardRef, useContext, useMemo, type ReactNode } from 'react';\nimport { View, type ViewProps } from 'react-native';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport { useCSSVariable } from 'uniwind';\nimport { ArrowDownIcon, ArrowUpIcon, MinusIcon } from '@/components/ui/icons';\nimport { Text } from '@/components/ui/text';\nimport { cn } from '@/lib/cn';\nimport { type SeriesColorIndex } from '@/lib/chart';\nimport { LineChart, type LineChartDatum } from '@/components/ui/line-chart';\nimport { Progress } from '@/components/ui/progress';\nimport { Surface } from '@/components/ui/surface';\n\n/** Height of the sparkline when nothing else is said. */\nconst CHART_HEIGHT = 56;\n/**\n * …and its box beside the number, which is fixed rather than shared out.\n *\n * A column of stat cards has labels of every length — \"Revenue\" over \"New\n * customers\" — and a chart taking whatever the text leaves would be a\n * different width on every card in the stack. Fixed, the shapes line up down\n * the right-hand edge and the eye can compare them, which is the only reason\n * they are there.\n */\nconst INLINE_CHART_WIDTH = 128;\nconst INLINE_CHART_HEIGHT = 56;\n/** Width ÷ height of the sparkline in that box. */\nconst INLINE_ASPECT = 2.3;\n\n/** The arrow in a trend badge. Small — it is a direction, not an icon. */\nconst TREND_ICON = 12;\nconst TREND_STROKE = 2.5;\n\nconst kpiVariants = tv({\n slots: {\n root: 'w-full gap-2',\n header: 'flex-row items-center gap-2',\n icon: 'h-8 w-8 items-center justify-center rounded-xl',\n title: 'text-sm font-medium text-muted-foreground',\n content: 'flex-row items-end justify-between gap-3',\n value: 'text-2xl font-bold text-foreground',\n /** The stacked title/value/trend block, tight enough to read as one thing. */\n stat: 'flex-1 gap-1',\n trend: 'flex-row items-center gap-1',\n trendLabel: 'font-medium',\n footer: 'flex-row items-center gap-2',\n separator: 'h-px w-full bg-border',\n group: 'w-full',\n groupSeparator: 'bg-border',\n },\n variants: {\n /**\n * Which way the number moved, *after* the metric's own polarity has been\n * applied — so `good` is green whether it went up or down.\n */\n tone: {\n good: { trendLabel: 'text-success', icon: 'bg-success-subtle' },\n bad: { trendLabel: 'text-destructive', icon: 'bg-destructive-subtle' },\n flat: { trendLabel: 'text-muted-foreground', icon: 'bg-muted' },\n neutral: { trendLabel: 'text-muted-foreground', icon: 'bg-secondary' },\n },\n /**\n * How the change is drawn.\n *\n * `text` is the quieter of the two and the default: a line of colour under\n * the number, which is where the eye already is. `badge` puts a pill round\n * it for a card dense enough that a bare line of colour gets lost.\n */\n trendVariant: {\n text: { trend: 'gap-0.5', trendLabel: 'text-sm' },\n badge: { trend: 'rounded-full px-2 py-0.5', trendLabel: 'text-xs' },\n },\n /** Where the chart sits relative to the number. */\n layout: {\n /** Under everything, full width. The chart is a footnote. */\n below: {},\n /**\n * Beside the number: the text takes the width and the chart takes a\n * fixed column on the end. The number is read first and the shape\n * second, which is the order they sit in — so this is the shape a stat\n * card wants, and `below` is for a chart big enough to be looked at.\n */\n inline: { content: 'items-center gap-4' },\n },\n },\n compoundVariants: [\n { trendVariant: 'badge', tone: 'good', class: { trend: 'bg-success-subtle' } },\n { trendVariant: 'badge', tone: 'bad', class: { trend: 'bg-destructive-subtle' } },\n { trendVariant: 'badge', tone: 'flat', class: { trend: 'bg-muted' } },\n { trendVariant: 'badge', tone: 'neutral', class: { trend: 'bg-muted' } },\n ],\n defaultVariants: {\n tone: 'neutral',\n trendVariant: 'text',\n layout: 'below',\n },\n});\n\ntype KpiVariantProps = VariantProps;\n\n/** How a trend is coloured once the metric's polarity has been applied. */\nexport type KpiTone = NonNullable;\n\n/** Which direction of movement is the good news for this metric. */\nexport type KpiGoodDirection = 'up' | 'down' | 'none';\n\ninterface KpiContextValue {\n colorIndex: SeriesColorIndex;\n goodDirection: KpiGoodDirection;\n}\n\nconst KpiContext = createContext(null);\n\n/**\n * Whether the part rendering is inside `Kpi.Header`.\n *\n * `Title` is the only part that cares, and it cares a great deal. In a header\n * it is one of several things on a row and has to take the space between the\n * icon and the actions, so it grows. Written straight into the card it is one\n * of several things in a *column* — and a growing child of a column absorbs\n * the leftover height, which pushes the number under it down by however much\n * that card had spare. Three of those side by side is three numbers at three\n * different heights, which is exactly what a row of metrics must not be.\n */\nconst KpiHeaderContext = createContext(false);\n\n/**\n * Whether the part rendering is inside `Kpi.Stat`.\n *\n * `Trend` is the one that cares. `self-center` on a flex child means \"centre\n * on the cross axis\", and the cross axis is not the same axis in the two\n * places a trend is put: in a row it is vertical, which is what a badge on the\n * end of a header wants, and in the stat column it is *horizontal* — which\n * indents the change by half whatever width it did not use, so two rows with\n * captions of different lengths start at two different places.\n */\nconst KpiStatContext = createContext(false);\n\nfunction useKpi(part: string): KpiContextValue {\n const context = useContext(KpiContext);\n if (!context) throw new Error(`${part} must be used inside .`);\n return context;\n}\n\n/* ------------------------------------------------------------------ *\n * Root.\n * ------------------------------------------------------------------ */\n\nexport interface KpiProps extends Omit {\n className?: string;\n /**\n * Which `--color-chart-*` token the sparkline and the icon take. Set on the\n * card rather than on the chart so a row of cards can be given five\n * different series colours without repeating the choice on every part.\n */\n colorIndex?: SeriesColorIndex;\n /**\n * Which way is the good news. `up` for revenue and signups, `down` for churn\n * and latency, `none` for a number that is neither — a headcount, a version.\n * Defaults to `up`.\n */\n goodDirection?: KpiGoodDirection;\n /** Draw the card on a surface. Turn off to place it in a shell of your own. */\n surface?: boolean;\n children: ReactNode;\n}\n\nconst KpiRoot = forwardRef(function KpiRoot(\n { className, colorIndex = 1, goodDirection = 'up', surface = true, children, ...props },\n ref\n) {\n const { root } = kpiVariants();\n const context = useMemo(() => ({ colorIndex, goodDirection }), [colorIndex, goodDirection]);\n\n const body = (\n \n {children}\n \n );\n\n return (\n \n {surface ? (\n \n {body}\n \n ) : (\n body\n )}\n \n );\n});\n\n/* ------------------------------------------------------------------ *\n * Header, and the things that live in it.\n * ------------------------------------------------------------------ */\n\nexport interface KpiHeaderProps extends ViewProps {\n className?: string;\n children: ReactNode;\n}\n\n/** The top row: an icon, the metric's name, and anything acting on it. */\nfunction KpiHeader({ className, children, ...props }: KpiHeaderProps) {\n const { header } = kpiVariants();\n return (\n \n \n {children}\n \n \n );\n}\n\nexport interface KpiIconProps extends ViewProps {\n className?: string;\n /** Overrides the tint the card's `colorIndex` would give it. */\n tone?: KpiTone;\n children: ReactNode;\n}\n\n/**\n * A tinted square for a glyph.\n *\n * It takes the element rather than drawing one, because a metric's icon comes\n * from whatever set the app already uses — and an icon from outside this\n * library will not read an ambient colour, so pass it one.\n */\nfunction KpiIcon({ className, tone = 'neutral', children, ...props }: KpiIconProps) {\n const { icon } = kpiVariants({ tone });\n return (\n \n {children}\n \n );\n}\n\nexport interface KpiTitleProps {\n className?: string;\n children: ReactNode;\n}\n\n/** The metric's name. Quiet on purpose — the value is the thing being read. */\nfunction KpiTitle({ className, children }: KpiTitleProps) {\n const { title } = kpiVariants();\n // Grows across a header row; never down a column — see `KpiHeaderContext`.\n const inHeader = useContext(KpiHeaderContext);\n return (\n \n {children}\n \n );\n}\n\nexport interface KpiStatProps extends ViewProps {\n className?: string;\n children: ReactNode;\n}\n\n/**\n * The stacked title / value / change block.\n *\n * Its own container rather than three loose children of the card, because the\n * three belong together more tightly than they belong to whatever is above or\n * below them — 4pt between the lines of one fact, and the card's own spacing\n * between facts. It also takes the width in an `inline` row, leaving the chart\n * its column on the end.\n */\nfunction KpiStat({ className, children, ...props }: KpiStatProps) {\n const { stat } = kpiVariants();\n return (\n \n \n {children}\n \n \n );\n}\n\nexport interface KpiActionsProps extends ViewProps {\n className?: string;\n children: ReactNode;\n}\n\n/** The trailing end of the header — a menu trigger, a filter, a link. */\nfunction KpiActions({ className, children, ...props }: KpiActionsProps) {\n return (\n \n {children}\n \n );\n}\n\n/* ------------------------------------------------------------------ *\n * The number, and what it is doing.\n * ------------------------------------------------------------------ */\n\nexport interface KpiContentProps extends ViewProps {\n className?: string;\n /** `inline` puts the chart beside the value instead of under everything. */\n layout?: NonNullable;\n children: ReactNode;\n}\n\n/** The row the value and the trend share. */\nfunction KpiContent({\n className,\n layout = 'below',\n children,\n ...props\n}: KpiContentProps) {\n const { content } = kpiVariants({ layout });\n return (\n \n {children}\n \n );\n}\n\nexport interface KpiValueProps {\n className?: string;\n children: ReactNode;\n}\n\n/**\n * The number.\n *\n * Formatted by the caller, not here: thousands separators, currency symbols\n * and units are locale decisions, and a component that guessed them would be\n * wrong in a way that is hard to notice and impossible to override.\n */\nfunction KpiValue({ className, children }: KpiValueProps) {\n const { value } = kpiVariants();\n return (\n \n {children}\n \n );\n}\n\nexport interface KpiTrendProps extends Omit {\n className?: string;\n /**\n * How much it moved, as a percentage. The sign carries the direction, so\n * `-4.2` is a fall of 4.2%; there is no separate direction prop to keep in\n * step with it.\n */\n value: number;\n /**\n * Writes the number yourself. Receives the raw value, sign and all. The\n * default prints one decimal place with an explicit `+` or `−`.\n */\n format?: (value: number) => string;\n /** Overrides the card's own `goodDirection` for this one figure. */\n goodDirection?: KpiGoodDirection;\n /**\n * `text` is a line of colour under the number — the default, and what a\n * stat card usually wants. `badge` puts a pill round it, with an arrow, for\n * a card busy enough that a bare line of colour is lost in it.\n */\n variant?: NonNullable;\n /** What it is being compared against — \"last 30d\", \"vs last week\". */\n caption?: string;\n /** Anything after the number, when a caption is not enough. */\n children?: ReactNode;\n /** Below which a movement counts as no movement. Defaults to `0`. */\n threshold?: number;\n}\n\n/**\n * The change.\n *\n * Colour comes from what the movement *means*, not from its sign: a fall in\n * churn is good news and is drawn as good news. That is the whole reason this\n * takes a number rather than a string — a caller writing \"−4.2%\" into a green\n * label has to remember to change the colour when the metric changes, and\n * nobody does.\n */\nfunction KpiTrend({\n className,\n value,\n format,\n goodDirection,\n variant = 'text',\n caption,\n threshold = 0,\n children,\n ...props\n}: KpiTrendProps) {\n const context = useKpi('Kpi.Trend');\n const polarity = goodDirection ?? context.goodDirection;\n\n const flat = Math.abs(value) <= threshold;\n const rising = value > 0;\n const tone: KpiTone = flat\n ? 'flat'\n : polarity === 'none'\n ? 'neutral'\n : (rising && polarity === 'up') || (!rising && polarity === 'down')\n ? 'good'\n : 'bad';\n\n // Down a column it starts at the leading edge, level with the title and the\n // number above it; across a row it centres against them — see `KpiStatContext`.\n const inStat = useContext(KpiStatContext);\n const { trend, trendLabel } = kpiVariants({ tone, trendVariant: variant });\n\n // The arrow's colour, resolved so it matches the label beside it. An icon\n // from outside this library does not inherit a text colour.\n const goodTint = useCSSVariable('--color-success');\n const badTint = useCSSVariable('--color-destructive');\n const mutedTint = useCSSVariable('--color-muted-foreground');\n const raw = tone === 'good' ? goodTint : tone === 'bad' ? badTint : mutedTint;\n const tint = typeof raw === 'string' ? raw : '#737373';\n\n const Arrow = flat ? MinusIcon : rising ? ArrowUpIcon : ArrowDownIcon;\n\n const label = format\n ? format(value)\n : // A true minus sign rather than a hyphen: at this size a hyphen reads as\n // a dash between two words, and the sign is half the meaning.\n `${rising ? '+' : value < 0 ? '−' : ''}${Math.abs(value).toFixed(1)}%`;\n\n return (\n \n {/* No arrow in `text`: the sign is already in front of the number, and\n drawing both says the same thing twice in the same three points. */}\n {variant === 'badge' ? (\n \n ) : null}\n \n {label}\n {/* One Text, not two: a caption in its own element wraps onto its own\n line the moment the card gets narrow, which reads as a second fact\n rather than the rest of this one. */}\n {caption ? {caption} : null}\n \n {children}\n \n );\n}\n\n/* ------------------------------------------------------------------ *\n * The chart, and the bar.\n * ------------------------------------------------------------------ */\n\nexport interface KpiSparklineProps {\n className?: string;\n /** The rows. One point each, in order. */\n data: LineChartDatum[];\n /** Key holding the y values. */\n dataKey: string;\n /** Overrides the card's `colorIndex`. */\n colorIndex?: SeriesColorIndex;\n /**\n * Fill under the line. Off beside the number, where the chart is a gesture\n * and a fill would make it a second block competing with the value; on when\n * it has the full width under everything and is being looked at properly.\n */\n filled?: boolean;\n /** Height in points. */\n height?: number;\n /**\n * Put it beside the number, taking whatever width the text leaves rather\n * than a column of its own. Pair with `layout=\"inline\"` on the content row.\n */\n inline?: boolean;\n strokeWidth?: number;\n}\n\n/**\n * The sparkline.\n *\n * A line chart with the axis padding dropped, which is what `compact` on the\n * chart itself means — there is no grid, no axis and no crosshair here, so\n * every point of padding is a point the shape is not using. Nobody reads a\n * value off one of these; they read whether it is going up.\n */\nfunction KpiSparkline({\n className,\n data,\n dataKey,\n colorIndex,\n filled,\n height,\n inline = false,\n strokeWidth = 2,\n}: KpiSparklineProps) {\n const context = useKpi('Kpi.Chart');\n const index = colorIndex ?? context.colorIndex;\n const fill = filled ?? !inline;\n\n return (\n \n \n {fill ? : null}\n \n \n \n );\n}\n\nexport interface KpiProgressProps {\n className?: string;\n /** Where it has got to. */\n value: number;\n /** The value at which the bar reads as full. Defaults to `100`. */\n maxValue?: number;\n /** A caption above the bar. */\n label?: string;\n /** Print the percentage on the right of the caption row. */\n showValueLabel?: boolean;\n}\n\n/** Progress towards a target, for a metric that has one. */\nfunction KpiProgressBar({\n className,\n value,\n maxValue = 100,\n label,\n showValueLabel,\n}: KpiProgressProps) {\n return (\n \n );\n}\n\n/* ------------------------------------------------------------------ *\n * Footer and separator.\n * ------------------------------------------------------------------ */\n\nexport interface KpiFooterProps extends ViewProps {\n className?: string;\n children: ReactNode;\n}\n\n/** The bottom strip — a comparison period, a caveat, a link. */\nfunction KpiFooter({ className, children, ...props }: KpiFooterProps) {\n const { footer } = kpiVariants();\n return (\n \n {children}\n \n );\n}\n\nexport interface KpiSeparatorProps extends ViewProps {\n className?: string;\n}\n\n/** A hairline across the card. */\nfunction KpiSeparator({ className, ...props }: KpiSeparatorProps) {\n const { separator } = kpiVariants();\n return ;\n}\n\n/* ------------------------------------------------------------------ *\n * Group.\n * ------------------------------------------------------------------ */\n\nexport type KpiGroupOrientation = 'horizontal' | 'vertical';\n\nexport interface KpiGroupProps extends ViewProps {\n className?: string;\n /** `horizontal` splits the row between the cards; `vertical` stacks them. */\n orientation?: KpiGroupOrientation;\n /**\n * Draw a hairline between the cards rather than spacing them apart. Several\n * metrics separated by a rule read as one panel; several spaced apart read\n * as several panels that happen to be adjacent.\n */\n separated?: boolean;\n children: ReactNode;\n}\n\n/**\n * Several metrics, laid out as one panel.\n *\n * The separators are inserted between the children rather than written by the\n * caller, because \"between\" is the one thing a list of siblings cannot express\n * — a trailing rule after the last card is the mistake this exists to prevent.\n */\nconst KpiGroup = forwardRef(function KpiGroup(\n { className, orientation = 'horizontal', separated = true, children, ...props },\n ref\n) {\n const { group, groupSeparator } = kpiVariants();\n const horizontal = orientation === 'horizontal';\n\n const items = Array.isArray(children) ? children.flat() : [children];\n const visible = items.filter(Boolean);\n\n return (\n \n {visible.map((child, index) => (\n 0 && 'ps-4',\n separated && horizontal && index < visible.length - 1 && 'pe-4',\n separated && !horizontal && index > 0 && 'pt-4',\n separated && !horizontal && index < visible.length - 1 && 'pb-4'\n )}\n >\n {separated && index > 0 ? (\n \n ) : null}\n {child}\n \n ))}\n \n );\n});\n\nKpiRoot.displayName = 'Kpi';\nKpiHeader.displayName = 'Kpi.Header';\nKpiIcon.displayName = 'Kpi.Icon';\nKpiTitle.displayName = 'Kpi.Title';\nKpiStat.displayName = 'Kpi.Stat';\nKpiActions.displayName = 'Kpi.Actions';\nKpiContent.displayName = 'Kpi.Content';\nKpiValue.displayName = 'Kpi.Value';\nKpiTrend.displayName = 'Kpi.Trend';\nKpiSparkline.displayName = 'Kpi.Chart';\nKpiProgressBar.displayName = 'Kpi.Progress';\nKpiFooter.displayName = 'Kpi.Footer';\nKpiSeparator.displayName = 'Kpi.Separator';\nKpiGroup.displayName = 'Kpi.Group';\n\nexport const Kpi = Object.assign(KpiRoot, {\n Header: KpiHeader,\n Icon: KpiIcon,\n Title: KpiTitle,\n Stat: KpiStat,\n Actions: KpiActions,\n Content: KpiContent,\n Value: KpiValue,\n Trend: KpiTrend,\n Chart: KpiSparkline,\n Progress: KpiProgressBar,\n Footer: KpiFooter,\n Separator: KpiSeparator,\n Group: KpiGroup,\n});\n" + "content": "/**\n * Kpi — one number, and what it is doing.\n *\n * A metric card is not a chart with a caption. The number is the message and\n * the chart is the footnote, so the parts here are sized and ordered around\n * that: a title that stays quiet, a value that does not, a trend that says\n * which way and by how much, and a sparkline that is allowed to be small\n * because nobody is reading values off it.\n *\n * ```tsx\n * \n * \n * Active users\n * \n * \n * 12,480\n * \n * \n * \n * \n * ```\n *\n * The trend is given a number rather than a written string, so the component\n * decides the direction and the colour rather than the call site remembering\n * to keep a minus sign and a red in step. Where a rise is the bad news —\n * churn, latency, cost — say so with `goodDirection=\"down\"` and the colour\n * follows the meaning instead of the sign.\n */\nimport { createContext, forwardRef, useContext, useMemo, type ReactNode } from 'react';\nimport { View, type ViewProps } from 'react-native';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport { useCSSVariable } from 'uniwind';\nimport { ArrowDownIcon, ArrowUpIcon, MinusIcon } from '@/components/ui/icons';\nimport { Text } from '@/components/ui/text';\nimport { cn } from '@/lib/cn';\nimport { type SeriesColorIndex } from '@/lib/chart';\nimport { LineChart, type LineChartDatum } from '@/components/ui/line-chart';\nimport { Progress } from '@/components/ui/progress';\nimport { Surface } from '@/components/ui/surface';\nimport type { ViewRef } from '@/components/ui/refs';\n\n/** Height of the sparkline when nothing else is said. */\nconst CHART_HEIGHT = 56;\n/**\n * …and its box beside the number, which is fixed rather than shared out.\n *\n * A column of stat cards has labels of every length — \"Revenue\" over \"New\n * customers\" — and a chart taking whatever the text leaves would be a\n * different width on every card in the stack. Fixed, the shapes line up down\n * the right-hand edge and the eye can compare them, which is the only reason\n * they are there.\n */\nconst INLINE_CHART_WIDTH = 128;\nconst INLINE_CHART_HEIGHT = 56;\n/** Width ÷ height of the sparkline in that box. */\nconst INLINE_ASPECT = 2.3;\n\n/** The arrow in a trend badge. Small — it is a direction, not an icon. */\nconst TREND_ICON = 12;\nconst TREND_STROKE = 2.5;\n\nconst kpiVariants = tv({\n slots: {\n root: 'w-full gap-2',\n header: 'flex-row items-center gap-2',\n icon: 'h-8 w-8 items-center justify-center rounded-xl',\n title: 'text-sm font-medium text-muted-foreground',\n content: 'flex-row items-end justify-between gap-3',\n value: 'text-2xl font-bold text-foreground',\n /** The stacked title/value/trend block, tight enough to read as one thing. */\n stat: 'flex-1 gap-1',\n trend: 'flex-row items-center gap-1',\n trendLabel: 'font-medium',\n footer: 'flex-row items-center gap-2',\n separator: 'h-px w-full bg-border',\n group: 'w-full',\n groupSeparator: 'bg-border',\n },\n variants: {\n /**\n * Which way the number moved, *after* the metric's own polarity has been\n * applied — so `good` is green whether it went up or down.\n */\n tone: {\n good: { trendLabel: 'text-success', icon: 'bg-success-subtle' },\n bad: { trendLabel: 'text-destructive', icon: 'bg-destructive-subtle' },\n flat: { trendLabel: 'text-muted-foreground', icon: 'bg-muted' },\n neutral: { trendLabel: 'text-muted-foreground', icon: 'bg-secondary' },\n },\n /**\n * How the change is drawn.\n *\n * `text` is the quieter of the two and the default: a line of colour under\n * the number, which is where the eye already is. `badge` puts a pill round\n * it for a card dense enough that a bare line of colour gets lost.\n */\n trendVariant: {\n text: { trend: 'gap-0.5', trendLabel: 'text-sm' },\n badge: { trend: 'rounded-full px-2 py-0.5', trendLabel: 'text-xs' },\n },\n /** Where the chart sits relative to the number. */\n layout: {\n /** Under everything, full width. The chart is a footnote. */\n below: {},\n /**\n * Beside the number: the text takes the width and the chart takes a\n * fixed column on the end. The number is read first and the shape\n * second, which is the order they sit in — so this is the shape a stat\n * card wants, and `below` is for a chart big enough to be looked at.\n */\n inline: { content: 'items-center gap-4' },\n },\n },\n compoundVariants: [\n { trendVariant: 'badge', tone: 'good', class: { trend: 'bg-success-subtle' } },\n { trendVariant: 'badge', tone: 'bad', class: { trend: 'bg-destructive-subtle' } },\n { trendVariant: 'badge', tone: 'flat', class: { trend: 'bg-muted' } },\n { trendVariant: 'badge', tone: 'neutral', class: { trend: 'bg-muted' } },\n ],\n defaultVariants: {\n tone: 'neutral',\n trendVariant: 'text',\n layout: 'below',\n },\n});\n\ntype KpiVariantProps = VariantProps;\n\n/** How a trend is coloured once the metric's polarity has been applied. */\nexport type KpiTone = NonNullable;\n\n/** Which direction of movement is the good news for this metric. */\nexport type KpiGoodDirection = 'up' | 'down' | 'none';\n\ninterface KpiContextValue {\n colorIndex: SeriesColorIndex;\n goodDirection: KpiGoodDirection;\n}\n\nconst KpiContext = createContext(null);\n\n/**\n * Whether the part rendering is inside `Kpi.Header`.\n *\n * `Title` is the only part that cares, and it cares a great deal. In a header\n * it is one of several things on a row and has to take the space between the\n * icon and the actions, so it grows. Written straight into the card it is one\n * of several things in a *column* — and a growing child of a column absorbs\n * the leftover height, which pushes the number under it down by however much\n * that card had spare. Three of those side by side is three numbers at three\n * different heights, which is exactly what a row of metrics must not be.\n */\nconst KpiHeaderContext = createContext(false);\n\n/**\n * Whether the part rendering is inside `Kpi.Stat`.\n *\n * `Trend` is the one that cares. `self-center` on a flex child means \"centre\n * on the cross axis\", and the cross axis is not the same axis in the two\n * places a trend is put: in a row it is vertical, which is what a badge on the\n * end of a header wants, and in the stat column it is *horizontal* — which\n * indents the change by half whatever width it did not use, so two rows with\n * captions of different lengths start at two different places.\n */\nconst KpiStatContext = createContext(false);\n\nfunction useKpi(part: string): KpiContextValue {\n const context = useContext(KpiContext);\n if (!context) throw new Error(`${part} must be used inside .`);\n return context;\n}\n\n/* ------------------------------------------------------------------ *\n * Root.\n * ------------------------------------------------------------------ */\n\nexport interface KpiProps extends Omit {\n className?: string;\n /**\n * Which `--color-chart-*` token the sparkline and the icon take. Set on the\n * card rather than on the chart so a row of cards can be given five\n * different series colours without repeating the choice on every part.\n */\n colorIndex?: SeriesColorIndex;\n /**\n * Which way is the good news. `up` for revenue and signups, `down` for churn\n * and latency, `none` for a number that is neither — a headcount, a version.\n * Defaults to `up`.\n */\n goodDirection?: KpiGoodDirection;\n /** Draw the card on a surface. Turn off to place it in a shell of your own. */\n surface?: boolean;\n children: ReactNode;\n}\n\nconst KpiRoot = forwardRef(function KpiRoot(\n { className, colorIndex = 1, goodDirection = 'up', surface = true, children, ...props },\n ref\n) {\n const { root } = kpiVariants();\n const context = useMemo(() => ({ colorIndex, goodDirection }), [colorIndex, goodDirection]);\n\n const body = (\n \n {children}\n \n );\n\n return (\n \n {surface ? (\n \n {body}\n \n ) : (\n body\n )}\n \n );\n});\n\n/* ------------------------------------------------------------------ *\n * Header, and the things that live in it.\n * ------------------------------------------------------------------ */\n\nexport interface KpiHeaderProps extends ViewProps {\n className?: string;\n children: ReactNode;\n}\n\n/** The top row: an icon, the metric's name, and anything acting on it. */\nfunction KpiHeader({ className, children, ...props }: KpiHeaderProps) {\n const { header } = kpiVariants();\n return (\n \n \n {children}\n \n \n );\n}\n\nexport interface KpiIconProps extends ViewProps {\n className?: string;\n /** Overrides the tint the card's `colorIndex` would give it. */\n tone?: KpiTone;\n children: ReactNode;\n}\n\n/**\n * A tinted square for a glyph.\n *\n * It takes the element rather than drawing one, because a metric's icon comes\n * from whatever set the app already uses — and an icon from outside this\n * library will not read an ambient colour, so pass it one.\n */\nfunction KpiIcon({ className, tone = 'neutral', children, ...props }: KpiIconProps) {\n const { icon } = kpiVariants({ tone });\n return (\n \n {children}\n \n );\n}\n\nexport interface KpiTitleProps {\n className?: string;\n children: ReactNode;\n}\n\n/** The metric's name. Quiet on purpose — the value is the thing being read. */\nfunction KpiTitle({ className, children }: KpiTitleProps) {\n const { title } = kpiVariants();\n // Grows across a header row; never down a column — see `KpiHeaderContext`.\n const inHeader = useContext(KpiHeaderContext);\n return (\n \n {children}\n \n );\n}\n\nexport interface KpiStatProps extends ViewProps {\n className?: string;\n children: ReactNode;\n}\n\n/**\n * The stacked title / value / change block.\n *\n * Its own container rather than three loose children of the card, because the\n * three belong together more tightly than they belong to whatever is above or\n * below them — 4pt between the lines of one fact, and the card's own spacing\n * between facts. It also takes the width in an `inline` row, leaving the chart\n * its column on the end.\n */\nfunction KpiStat({ className, children, ...props }: KpiStatProps) {\n const { stat } = kpiVariants();\n return (\n \n \n {children}\n \n \n );\n}\n\nexport interface KpiActionsProps extends ViewProps {\n className?: string;\n children: ReactNode;\n}\n\n/** The trailing end of the header — a menu trigger, a filter, a link. */\nfunction KpiActions({ className, children, ...props }: KpiActionsProps) {\n return (\n \n {children}\n \n );\n}\n\n/* ------------------------------------------------------------------ *\n * The number, and what it is doing.\n * ------------------------------------------------------------------ */\n\nexport interface KpiContentProps extends ViewProps {\n className?: string;\n /** `inline` puts the chart beside the value instead of under everything. */\n layout?: NonNullable;\n children: ReactNode;\n}\n\n/** The row the value and the trend share. */\nfunction KpiContent({\n className,\n layout = 'below',\n children,\n ...props\n}: KpiContentProps) {\n const { content } = kpiVariants({ layout });\n return (\n \n {children}\n \n );\n}\n\nexport interface KpiValueProps {\n className?: string;\n children: ReactNode;\n}\n\n/**\n * The number.\n *\n * Formatted by the caller, not here: thousands separators, currency symbols\n * and units are locale decisions, and a component that guessed them would be\n * wrong in a way that is hard to notice and impossible to override.\n */\nfunction KpiValue({ className, children }: KpiValueProps) {\n const { value } = kpiVariants();\n return (\n \n {children}\n \n );\n}\n\nexport interface KpiTrendProps extends Omit {\n className?: string;\n /**\n * How much it moved, as a percentage. The sign carries the direction, so\n * `-4.2` is a fall of 4.2%; there is no separate direction prop to keep in\n * step with it.\n */\n value: number;\n /**\n * Writes the number yourself. Receives the raw value, sign and all. The\n * default prints one decimal place with an explicit `+` or `−`.\n */\n format?: (value: number) => string;\n /** Overrides the card's own `goodDirection` for this one figure. */\n goodDirection?: KpiGoodDirection;\n /**\n * `text` is a line of colour under the number — the default, and what a\n * stat card usually wants. `badge` puts a pill round it, with an arrow, for\n * a card busy enough that a bare line of colour is lost in it.\n */\n variant?: NonNullable;\n /** What it is being compared against — \"last 30d\", \"vs last week\". */\n caption?: string;\n /** Anything after the number, when a caption is not enough. */\n children?: ReactNode;\n /** Below which a movement counts as no movement. Defaults to `0`. */\n threshold?: number;\n}\n\n/**\n * The change.\n *\n * Colour comes from what the movement *means*, not from its sign: a fall in\n * churn is good news and is drawn as good news. That is the whole reason this\n * takes a number rather than a string — a caller writing \"−4.2%\" into a green\n * label has to remember to change the colour when the metric changes, and\n * nobody does.\n */\nfunction KpiTrend({\n className,\n value,\n format,\n goodDirection,\n variant = 'text',\n caption,\n threshold = 0,\n children,\n ...props\n}: KpiTrendProps) {\n const context = useKpi('Kpi.Trend');\n const polarity = goodDirection ?? context.goodDirection;\n\n const flat = Math.abs(value) <= threshold;\n const rising = value > 0;\n const tone: KpiTone = flat\n ? 'flat'\n : polarity === 'none'\n ? 'neutral'\n : (rising && polarity === 'up') || (!rising && polarity === 'down')\n ? 'good'\n : 'bad';\n\n // Down a column it starts at the leading edge, level with the title and the\n // number above it; across a row it centres against them — see `KpiStatContext`.\n const inStat = useContext(KpiStatContext);\n const { trend, trendLabel } = kpiVariants({ tone, trendVariant: variant });\n\n // The arrow's colour, resolved so it matches the label beside it. An icon\n // from outside this library does not inherit a text colour.\n const goodTint = useCSSVariable('--color-success');\n const badTint = useCSSVariable('--color-destructive');\n const mutedTint = useCSSVariable('--color-muted-foreground');\n const raw = tone === 'good' ? goodTint : tone === 'bad' ? badTint : mutedTint;\n const tint = typeof raw === 'string' ? raw : '#737373';\n\n const Arrow = flat ? MinusIcon : rising ? ArrowUpIcon : ArrowDownIcon;\n\n const label = format\n ? format(value)\n : // A true minus sign rather than a hyphen: at this size a hyphen reads as\n // a dash between two words, and the sign is half the meaning.\n `${rising ? '+' : value < 0 ? '−' : ''}${Math.abs(value).toFixed(1)}%`;\n\n return (\n \n {/* No arrow in `text`: the sign is already in front of the number, and\n drawing both says the same thing twice in the same three points. */}\n {variant === 'badge' ? (\n \n ) : null}\n \n {label}\n {/* One Text, not two: a caption in its own element wraps onto its own\n line the moment the card gets narrow, which reads as a second fact\n rather than the rest of this one. */}\n {caption ? {caption} : null}\n \n {children}\n \n );\n}\n\n/* ------------------------------------------------------------------ *\n * The chart, and the bar.\n * ------------------------------------------------------------------ */\n\nexport interface KpiSparklineProps {\n className?: string;\n /** The rows. One point each, in order. */\n data: LineChartDatum[];\n /** Key holding the y values. */\n dataKey: string;\n /** Overrides the card's `colorIndex`. */\n colorIndex?: SeriesColorIndex;\n /**\n * Fill under the line. Off beside the number, where the chart is a gesture\n * and a fill would make it a second block competing with the value; on when\n * it has the full width under everything and is being looked at properly.\n */\n filled?: boolean;\n /** Height in points. */\n height?: number;\n /**\n * Put it beside the number, taking whatever width the text leaves rather\n * than a column of its own. Pair with `layout=\"inline\"` on the content row.\n */\n inline?: boolean;\n strokeWidth?: number;\n}\n\n/**\n * The sparkline.\n *\n * A line chart with the axis padding dropped, which is what `compact` on the\n * chart itself means — there is no grid, no axis and no crosshair here, so\n * every point of padding is a point the shape is not using. Nobody reads a\n * value off one of these; they read whether it is going up.\n */\nfunction KpiSparkline({\n className,\n data,\n dataKey,\n colorIndex,\n filled,\n height,\n inline = false,\n strokeWidth = 2,\n}: KpiSparklineProps) {\n const context = useKpi('Kpi.Chart');\n const index = colorIndex ?? context.colorIndex;\n const fill = filled ?? !inline;\n\n return (\n \n \n {fill ? : null}\n \n \n \n );\n}\n\nexport interface KpiProgressProps {\n className?: string;\n /** Where it has got to. */\n value: number;\n /** The value at which the bar reads as full. Defaults to `100`. */\n maxValue?: number;\n /** A caption above the bar. */\n label?: string;\n /** Print the percentage on the right of the caption row. */\n showValueLabel?: boolean;\n}\n\n/** Progress towards a target, for a metric that has one. */\nfunction KpiProgressBar({\n className,\n value,\n maxValue = 100,\n label,\n showValueLabel,\n}: KpiProgressProps) {\n return (\n \n );\n}\n\n/* ------------------------------------------------------------------ *\n * Footer and separator.\n * ------------------------------------------------------------------ */\n\nexport interface KpiFooterProps extends ViewProps {\n className?: string;\n children: ReactNode;\n}\n\n/** The bottom strip — a comparison period, a caveat, a link. */\nfunction KpiFooter({ className, children, ...props }: KpiFooterProps) {\n const { footer } = kpiVariants();\n return (\n \n {children}\n \n );\n}\n\nexport interface KpiSeparatorProps extends ViewProps {\n className?: string;\n}\n\n/** A hairline across the card. */\nfunction KpiSeparator({ className, ...props }: KpiSeparatorProps) {\n const { separator } = kpiVariants();\n return ;\n}\n\n/* ------------------------------------------------------------------ *\n * Group.\n * ------------------------------------------------------------------ */\n\nexport type KpiGroupOrientation = 'horizontal' | 'vertical';\n\nexport interface KpiGroupProps extends ViewProps {\n className?: string;\n /** `horizontal` splits the row between the cards; `vertical` stacks them. */\n orientation?: KpiGroupOrientation;\n /**\n * Draw a hairline between the cards rather than spacing them apart. Several\n * metrics separated by a rule read as one panel; several spaced apart read\n * as several panels that happen to be adjacent.\n */\n separated?: boolean;\n children: ReactNode;\n}\n\n/**\n * Several metrics, laid out as one panel.\n *\n * The separators are inserted between the children rather than written by the\n * caller, because \"between\" is the one thing a list of siblings cannot express\n * — a trailing rule after the last card is the mistake this exists to prevent.\n */\nconst KpiGroup = forwardRef(function KpiGroup(\n { className, orientation = 'horizontal', separated = true, children, ...props },\n ref\n) {\n const { group, groupSeparator } = kpiVariants();\n const horizontal = orientation === 'horizontal';\n\n const items = Array.isArray(children) ? children.flat() : [children];\n const visible = items.filter(Boolean);\n\n return (\n \n {visible.map((child, index) => (\n 0 && 'ps-4',\n separated && horizontal && index < visible.length - 1 && 'pe-4',\n separated && !horizontal && index > 0 && 'pt-4',\n separated && !horizontal && index < visible.length - 1 && 'pb-4'\n )}\n >\n {separated && index > 0 ? (\n \n ) : null}\n {child}\n \n ))}\n \n );\n});\n\nKpiRoot.displayName = 'Kpi';\nKpiHeader.displayName = 'Kpi.Header';\nKpiIcon.displayName = 'Kpi.Icon';\nKpiTitle.displayName = 'Kpi.Title';\nKpiStat.displayName = 'Kpi.Stat';\nKpiActions.displayName = 'Kpi.Actions';\nKpiContent.displayName = 'Kpi.Content';\nKpiValue.displayName = 'Kpi.Value';\nKpiTrend.displayName = 'Kpi.Trend';\nKpiSparkline.displayName = 'Kpi.Chart';\nKpiProgressBar.displayName = 'Kpi.Progress';\nKpiFooter.displayName = 'Kpi.Footer';\nKpiSeparator.displayName = 'Kpi.Separator';\nKpiGroup.displayName = 'Kpi.Group';\n\nexport const Kpi = Object.assign(KpiRoot, {\n Header: KpiHeader,\n Icon: KpiIcon,\n Title: KpiTitle,\n Stat: KpiStat,\n Actions: KpiActions,\n Content: KpiContent,\n Value: KpiValue,\n Trend: KpiTrend,\n Chart: KpiSparkline,\n Progress: KpiProgressBar,\n Footer: KpiFooter,\n Separator: KpiSeparator,\n Group: KpiGroup,\n});\n" } ] } diff --git a/apps/docs/public/r/label.json b/apps/docs/public/r/label.json index 4f912a84a..a296491df 100644 --- a/apps/docs/public/r/label.json +++ b/apps/docs/public/r/label.json @@ -3,6 +3,7 @@ "type": "registry:ui", "description": "Form field label with required, invalid and disabled states.", "registryDependencies": [ + "refs", "text" ], "dependencies": [ @@ -13,7 +14,7 @@ { "path": "ui/label.tsx", "type": "registry:ui", - "content": "/**\n * Label — text that names a form field.\n *\n * A string child is wrapped in `Label.Text` automatically, so the common case\n * stays one line; pass the parts explicitly when the label needs more than\n * text. `isRequired`, `isInvalid` and `isDisabled` are visual *and* semantic —\n * they recolour and they set the accessibility state, so the two cannot drift\n * apart.\n */\nimport {\n createContext,\n forwardRef,\n useContext,\n useMemo,\n type ReactNode,\n} from 'react';\nimport { View, type Text as RNText, type ViewProps } from 'react-native';\nimport { tv } from 'tailwind-variants';\nimport { Text, type TextProps, textChildren } from '@/components/ui/text';\n\nconst labelVariants = tv({\n slots: {\n root: 'flex-row items-center gap-1',\n text: 'text-sm font-medium text-foreground',\n asterisk: 'text-sm font-medium text-destructive',\n },\n variants: {\n isDisabled: {\n true: {\n root: 'opacity-[0.64]',\n asterisk: 'text-muted-foreground',\n },\n },\n isInvalid: {\n true: { text: 'text-destructive' },\n },\n },\n});\n\ninterface LabelState {\n isRequired: boolean;\n isInvalid: boolean;\n isDisabled: boolean;\n}\n\nconst LabelContext = createContext({\n isRequired: false,\n isInvalid: false,\n isDisabled: false,\n});\n\nexport interface LabelProps extends ViewProps {\n className?: string;\n /** Appends an asterisk marking the field as required. */\n isRequired?: boolean;\n /** Recolours the label to signal a validation error. */\n isInvalid?: boolean;\n isDisabled?: boolean;\n children?: ReactNode;\n}\n\nconst LabelRoot = forwardRef(\n (\n {\n className,\n isRequired = false,\n isInvalid = false,\n isDisabled = false,\n children,\n ...props\n },\n ref\n ) => {\n const { root } = labelVariants({ isDisabled, isInvalid });\n const state = useMemo(\n () => ({ isRequired, isInvalid, isDisabled }),\n [isRequired, isInvalid, isDisabled]\n );\n\n return (\n \n \n {/* Bare strings are the common case — wrap them so callers don't have to. */}\n {textChildren(children, (text) => {text})}\n \n \n );\n }\n);\nLabelRoot.displayName = 'Label';\n\nexport interface LabelTextProps extends TextProps {\n className?: string;\n /** Classes for the required asterisk. */\n asteriskClassName?: string;\n}\n\nconst LabelText = forwardRef(\n ({ className, asteriskClassName, children, ...props }, ref) => {\n const { isRequired, isInvalid, isDisabled } = useContext(LabelContext);\n const { text, asterisk } = labelVariants({ isDisabled, isInvalid });\n\n return (\n <>\n \n {children}\n \n {isRequired ? (\n \n *\n \n ) : null}\n \n );\n }\n);\nLabelText.displayName = 'Label.Text';\n\nexport const Label = Object.assign(LabelRoot, {\n Text: LabelText,\n});\n" + "content": "/**\n * Label — text that names a form field.\n *\n * A string child is wrapped in `Label.Text` automatically, so the common case\n * stays one line; pass the parts explicitly when the label needs more than\n * text. `isRequired`, `isInvalid` and `isDisabled` are visual *and* semantic —\n * they recolour and they set the accessibility state, so the two cannot drift\n * apart.\n */\nimport {\n createContext,\n forwardRef,\n useContext,\n useMemo,\n type ReactNode,\n} from 'react';\nimport { View, type Text as RNText, type ViewProps } from 'react-native';\nimport { tv } from 'tailwind-variants';\nimport { Text, type TextProps, textChildren } from '@/components/ui/text';\nimport type { TextRef, ViewRef } from '@/components/ui/refs';\n\nconst labelVariants = tv({\n slots: {\n root: 'flex-row items-center gap-1',\n text: 'text-sm font-medium text-foreground',\n asterisk: 'text-sm font-medium text-destructive',\n },\n variants: {\n isDisabled: {\n true: {\n root: 'opacity-[0.64]',\n asterisk: 'text-muted-foreground',\n },\n },\n isInvalid: {\n true: { text: 'text-destructive' },\n },\n },\n});\n\ninterface LabelState {\n isRequired: boolean;\n isInvalid: boolean;\n isDisabled: boolean;\n}\n\nconst LabelContext = createContext({\n isRequired: false,\n isInvalid: false,\n isDisabled: false,\n});\n\nexport interface LabelProps extends ViewProps {\n className?: string;\n /** Appends an asterisk marking the field as required. */\n isRequired?: boolean;\n /** Recolours the label to signal a validation error. */\n isInvalid?: boolean;\n isDisabled?: boolean;\n children?: ReactNode;\n}\n\nconst LabelRoot = forwardRef(\n (\n {\n className,\n isRequired = false,\n isInvalid = false,\n isDisabled = false,\n children,\n ...props\n },\n ref\n ) => {\n const { root } = labelVariants({ isDisabled, isInvalid });\n const state = useMemo(\n () => ({ isRequired, isInvalid, isDisabled }),\n [isRequired, isInvalid, isDisabled]\n );\n\n return (\n \n \n {/* Bare strings are the common case — wrap them so callers don't have to. */}\n {textChildren(children, (text) => {text})}\n \n \n );\n }\n);\nLabelRoot.displayName = 'Label';\n\nexport interface LabelTextProps extends TextProps {\n className?: string;\n /** Classes for the required asterisk. */\n asteriskClassName?: string;\n}\n\nconst LabelText = forwardRef(\n ({ className, asteriskClassName, children, ...props }, ref) => {\n const { isRequired, isInvalid, isDisabled } = useContext(LabelContext);\n const { text, asterisk } = labelVariants({ isDisabled, isInvalid });\n\n return (\n <>\n \n {children}\n \n {isRequired ? (\n \n *\n \n ) : null}\n \n );\n }\n);\nLabelText.displayName = 'Label.Text';\n\nexport const Label = Object.assign(LabelRoot, {\n Text: LabelText,\n});\n" } ] } diff --git a/apps/docs/public/r/leaderboard.json b/apps/docs/public/r/leaderboard.json index 9632b272e..e2f8b38ef 100644 --- a/apps/docs/public/r/leaderboard.json +++ b/apps/docs/public/r/leaderboard.json @@ -6,6 +6,7 @@ "avatar", "cn", "icons", + "refs", "text" ], "dependencies": [ @@ -18,7 +19,7 @@ { "path": "ui/leaderboard.tsx", "type": "registry:ui", - "content": "/**\n * Leaderboard — entries ranked by a value, with the top three on a podium.\n *\n * ```tsx\n * \n * ```\n *\n * Data in, ranks out. The caller passes values and the component sorts and\n * places them, so ties are numbered the same way everywhere and a row can\n * never show a place its value does not earn. See `leaderboard-rank.ts`.\n *\n * The podium is laid out 2-1-3, first in the middle and highest, because that\n * is the arrangement people already read a top three in. Its bars are drawn\n * to scale against the leader by default: a close race looks close and a\n * runaway looks like one, which a fixed staircase of heights cannot show.\n *\n * Every podium column and every row is one accessible element. A screen\n * reader hears \"Rank 2, Omar Haddad, 11,930 steps\" once, rather than the\n * rank, the name and the value as three stops in a grid it cannot see.\n */\nimport { forwardRef, useEffect, useMemo } from 'react';\nimport {\n Pressable,\n View,\n type ImageSourcePropType,\n type ViewProps,\n} from 'react-native';\nimport Animated, {\n useAnimatedStyle,\n useReducedMotion,\n useSharedValue,\n withDelay,\n withTiming,\n Easing,\n} from 'react-native-reanimated';\nimport { tv } from 'tailwind-variants';\nimport { useCSSVariable } from 'uniwind';\nimport { ArrowDownIcon, ArrowUpIcon } from '@/components/ui/icons';\nimport { Text } from '@/components/ui/text';\nimport { cn } from '@/lib/cn';\nimport { Avatar } from '@/components/ui/avatar';\nimport { podiumHeights, rankEntries, type Ranked } from '@/components/ui/leaderboard-rank';\n\nexport { rankEntries } from '@/components/ui/leaderboard-rank';\nexport type { Ranked as LeaderboardRanked } from '@/components/ui/leaderboard-rank';\n\n/** Milliseconds for a podium bar to grow. */\nconst GROW_DURATION = 620;\n/** Delay between bars. Third rises first and first last, so the leader lands on the beat. */\nconst GROW_STAGGER = 110;\n\nconst leaderboardVariants = tv({\n slots: {\n root: 'w-full gap-4',\n podium: 'flex-row items-end gap-2 px-1',\n column: 'flex-1 items-center',\n person: 'items-center gap-1 pb-2',\n rankBadge:\n 'absolute -bottom-1.5 h-5 min-w-5 items-center justify-center self-center rounded-full border-2 border-background px-1',\n bar: 'w-full items-center overflow-hidden rounded-t-2xl pt-2.5',\n card: 'flex-1 items-center gap-1.5 rounded-2xl border border-border bg-card px-2 pb-3 pt-4',\n list: 'overflow-hidden rounded-2xl border border-border bg-card',\n row: 'min-h-14 flex-row items-center gap-3 px-3 py-2.5',\n rowDivider: 'ml-14 h-px bg-border',\n rank: 'w-7 text-center text-sm font-semibold tabular-nums text-muted-foreground',\n rowText: 'flex-1 gap-0.5',\n value: 'text-sm font-semibold tabular-nums text-foreground',\n change: 'min-w-10 flex-row items-center justify-end gap-0.5',\n gap: 'flex-row items-center justify-center gap-1 py-1.5',\n gapDot: 'h-1 w-1 rounded-full bg-muted-foreground/40',\n empty: 'items-center rounded-2xl border border-dashed border-border px-4 py-8',\n },\n});\n\n/**\n * Colour by podium place. A lookup rather than a `tv()` variant: place is\n * derived from the data, not something a caller passes, and a variant here\n * would be documented as a prop on `Leaderboard`.\n *\n * One hue stepped down in strength, so the podium reads as a single series\n * and follows the theme's primary rather than borrowing medal colours that\n * would clash with half the themes.\n */\nconst PLACE: Record<1 | 2 | 3, { bar: string; badge: string }> = {\n 1: { bar: 'bg-primary', badge: 'bg-primary' },\n 2: { bar: 'bg-primary/40', badge: 'bg-foreground' },\n 3: { bar: 'bg-primary/16', badge: 'bg-foreground' },\n};\n\nexport interface LeaderboardEntry {\n /** Stable identity, used for keys and `highlightId`. */\n id: string;\n /** Shown on the row and read out first after the rank. */\n name: string;\n /** What the board is ranked by. */\n value: number;\n /** Image for the avatar. A string is taken as a URI. Initials stand in without one. */\n avatar?: ImageSourcePropType | string;\n /** A second line under the name — a team, a level, a city. */\n subtitle?: string;\n /**\n * Places moved since the last period. Positive is up the board, negative\n * is down, 0 is unchanged. Leave it out to show no movement column.\n */\n change?: number;\n}\n\nexport type LeaderboardPodium = 'bars' | 'cards' | 'none';\n\nexport interface LeaderboardProps extends Omit {\n className?: string;\n /** The entries, in any order. Places are worked out from `value`. */\n data: readonly LeaderboardEntry[];\n /**\n * How the top three are drawn. `bars` stands them on a podium whose bars\n * are scaled to their values; `cards` gives each a card, with first raised\n * between the other two; `none` puts everyone in the list.\n */\n podium?: LeaderboardPodium;\n /**\n * `desc` ranks the highest value first. `asc` ranks the lowest first, for a\n * time or a golf score.\n */\n order?: 'desc' | 'asc';\n /**\n * What the podium bar heights follow. `value` draws each against the\n * leader's value; `rank` steps them by place. Always `rank` when `order` is\n * `asc`, where the winning value is the smallest.\n */\n podiumScale?: 'value' | 'rank';\n /** Height of the tallest podium bar, in points. */\n podiumHeight?: number;\n /** Show at most this many entries, podium included. */\n limit?: number;\n /**\n * The entry to mark — usually the person looking at the board. Their row is\n * tinted, and if `limit` cuts them off it is pinned at the bottom with their\n * real place.\n */\n highlightId?: string;\n /** Turns a value into its label. Defaults to grouped digits: 12,480. */\n formatValue?: (value: number) => string;\n /** Word read after the value by a screen reader, e.g. \"points\". */\n unit?: string;\n /** Makes every row and podium place pressable. */\n onPressEntry?: (entry: LeaderboardEntry, rank: number) => void;\n /** Shown when `data` has nothing to rank. */\n emptyText?: string;\n}\n\nfunction initials(name: string): string {\n const parts = name.trim().split(/\\s+/).filter(Boolean);\n const first = parts[0]?.[0] ?? '';\n const last = parts.length > 1 ? (parts[parts.length - 1]?.[0] ?? '') : '';\n return (first + last).toUpperCase() || '?';\n}\n\nfunction toSource(avatar: LeaderboardEntry['avatar']): ImageSourcePropType | undefined {\n return typeof avatar === 'string' ? { uri: avatar } : avatar;\n}\n\nconst defaultFormat = (value: number) => value.toLocaleString('en-US');\n\nfunction describe(\n { entry, rank, tied }: Ranked,\n label: string,\n unit: string | undefined\n): string {\n const place = tied ? `Rank ${rank}, tied` : `Rank ${rank}`;\n const parts = [place, entry.name, unit ? `${label} ${unit}` : label];\n if (entry.subtitle) parts.splice(2, 0, entry.subtitle);\n if (entry.change) {\n const n = Math.abs(entry.change);\n parts.push(`${entry.change > 0 ? 'up' : 'down'} ${n} ${n === 1 ? 'place' : 'places'}`);\n } else if (entry.change === 0) {\n parts.push('no change');\n }\n return parts.join(', ');\n}\n\n/** A pressable when the board is, a plain view when it is not. */\nfunction Slot({\n onPress,\n label,\n className,\n children,\n}: {\n onPress?: () => void;\n label: string;\n className?: string;\n children: React.ReactNode;\n}) {\n if (onPress) {\n return (\n \n {children}\n \n );\n }\n return (\n \n {children}\n \n );\n}\n\nfunction PodiumBar({\n place,\n height,\n delay,\n rank,\n}: {\n place: 1 | 2 | 3;\n height: number;\n delay: number;\n rank: number;\n}) {\n const slots = leaderboardVariants();\n const reduced = useReducedMotion();\n const grown = useSharedValue(reduced ? height : 0);\n\n useEffect(() => {\n grown.value = reduced\n ? height\n : withDelay(\n delay,\n withTiming(height, { duration: GROW_DURATION, easing: Easing.out(Easing.cubic) })\n );\n }, [grown, height, delay, reduced]);\n\n const style = useAnimatedStyle(() => ({ height: grown.value }));\n // The numeral fades in once the bar is tall enough to hold it, so it never\n // overflows a bar that is still a few points high.\n const numeral = useAnimatedStyle(() => ({\n opacity: Math.min(1, Math.max(0, (grown.value - 28) / 24)),\n }));\n\n return (\n \n \n \n {rank}\n \n \n \n );\n}\n\nfunction RankBadge({ place, rank }: { place: 1 | 2 | 3; rank: number }) {\n const slots = leaderboardVariants();\n return (\n \n \n {rank}\n \n \n );\n}\n\nfunction ChangeLabel({ change }: { change: number | undefined }) {\n const slots = leaderboardVariants();\n const success = useCSSVariable('--color-success');\n const destructive = useCSSVariable('--color-destructive');\n if (change === undefined) return null;\n\n if (change === 0) {\n return (\n \n \n –\n \n \n );\n }\n\n const up = change > 0;\n const tint = up ? success : destructive;\n const color = typeof tint === 'string' ? tint : undefined;\n const Arrow = up ? ArrowUpIcon : ArrowDownIcon;\n return (\n \n \n \n {Math.abs(change)}\n \n \n );\n}\n\n/**\n * Ranked entries with a podium for the top three and a list for the rest.\n *\n * Renders every row it is given rather than virtualising them: a board is\n * read from the top, and past a few dozen rows nobody is reading it. Use\n * `limit` for a long board, and `highlightId` to keep the viewer's own row in\n * sight when the limit cuts them off.\n */\nconst LeaderboardRoot = forwardRef(function Leaderboard(\n {\n className,\n data,\n podium = 'bars',\n order = 'desc',\n podiumScale = 'value',\n podiumHeight = 132,\n limit,\n highlightId,\n formatValue = defaultFormat,\n unit,\n onPressEntry,\n emptyText = 'No entries yet',\n ...props\n },\n ref\n) {\n const slots = leaderboardVariants();\n const ranked = useMemo(() => rankEntries(data, order), [data, order]);\n const shown = limit != null ? ranked.slice(0, Math.max(0, limit)) : ranked;\n\n // The viewer's own row, when the limit has cut it off.\n const pinned =\n highlightId != null && !shown.some((r) => r.entry.id === highlightId)\n ? ranked.find((r) => r.entry.id === highlightId)\n : undefined;\n\n const top = podium === 'none' ? [] : shown.slice(0, 3);\n const rest = podium === 'none' ? shown : shown.slice(3);\n\n const press = (item: Ranked) =>\n onPressEntry ? () => onPressEntry(item.entry, item.rank) : undefined;\n\n if (ranked.length === 0) {\n return (\n \n \n \n {emptyText}\n \n \n \n );\n }\n\n // Second, first, third. With fewer than three the missing seats are left\n // out rather than drawn empty.\n const seats = [top[1], top[0], top[2]]\n .map((item, slot) => (item ? { item, place: ([2, 1, 3] as const)[slot]! } : null))\n .filter((seat): seat is { item: Ranked; place: 1 | 2 | 3 } => !!seat);\n\n const heights = podiumHeights(\n seats.map((s) => s.item.entry.value),\n seats.map((s) => s.item.rank),\n order === 'asc' ? 'rank' : podiumScale\n );\n\n const renderRow = (item: Ranked, index: number) => {\n const label = formatValue(item.entry.value);\n const highlighted = item.entry.id === highlightId;\n return (\n \n {index > 0 ? : null}\n \n \n {item.rank}\n \n \n \n \n {item.entry.name}\n \n {item.entry.subtitle ? (\n \n {item.entry.subtitle}\n \n ) : null}\n \n {label}\n \n \n \n );\n };\n\n return (\n \n {podium === 'bars' && seats.length > 0 ? (\n \n {seats.map(({ item, place }, i) => {\n const label = formatValue(item.entry.value);\n return (\n \n \n \n \n \n \n \n {item.entry.name}\n \n \n {label}\n \n \n \n \n );\n })}\n \n ) : null}\n\n {podium === 'cards' && seats.length > 0 ? (\n \n {seats.map(({ item, place }) => {\n const label = formatValue(item.entry.value);\n return (\n \n \n \n \n \n \n {item.entry.name}\n \n \n {label}\n \n {item.entry.subtitle ? (\n \n {item.entry.subtitle}\n \n ) : null}\n \n );\n })}\n \n ) : null}\n\n {rest.length > 0 || pinned ? (\n \n {rest.map(renderRow)}\n {pinned ? (\n <>\n {rest.length > 0 ? (\n \n \n \n \n \n ) : null}\n {renderRow(pinned, 0)}\n \n ) : null}\n \n ) : null}\n \n );\n});\n\nexport const Leaderboard = LeaderboardRoot;\n" + "content": "/**\n * Leaderboard — entries ranked by a value, with the top three on a podium.\n *\n * ```tsx\n * \n * ```\n *\n * Data in, ranks out. The caller passes values and the component sorts and\n * places them, so ties are numbered the same way everywhere and a row can\n * never show a place its value does not earn. See `leaderboard-rank.ts`.\n *\n * The podium is laid out 2-1-3, first in the middle and highest, because that\n * is the arrangement people already read a top three in. Its bars are drawn\n * to scale against the leader by default: a close race looks close and a\n * runaway looks like one, which a fixed staircase of heights cannot show.\n *\n * Every podium column and every row is one accessible element. A screen\n * reader hears \"Rank 2, Omar Haddad, 11,930 steps\" once, rather than the\n * rank, the name and the value as three stops in a grid it cannot see.\n */\nimport { forwardRef, useEffect, useMemo } from 'react';\nimport {\n Pressable,\n View,\n type ImageSourcePropType,\n type ViewProps,\n} from 'react-native';\nimport Animated, {\n useAnimatedStyle,\n useReducedMotion,\n useSharedValue,\n withDelay,\n withTiming,\n Easing,\n} from 'react-native-reanimated';\nimport { tv } from 'tailwind-variants';\nimport { useCSSVariable } from 'uniwind';\nimport { ArrowDownIcon, ArrowUpIcon } from '@/components/ui/icons';\nimport { Text } from '@/components/ui/text';\nimport { cn } from '@/lib/cn';\nimport { Avatar } from '@/components/ui/avatar';\nimport { podiumHeights, rankEntries, type Ranked } from '@/components/ui/leaderboard-rank';\nimport type { ViewRef } from '@/components/ui/refs';\n\nexport { rankEntries } from '@/components/ui/leaderboard-rank';\nexport type { Ranked as LeaderboardRanked } from '@/components/ui/leaderboard-rank';\n\n/** Milliseconds for a podium bar to grow. */\nconst GROW_DURATION = 620;\n/** Delay between bars. Third rises first and first last, so the leader lands on the beat. */\nconst GROW_STAGGER = 110;\n\nconst leaderboardVariants = tv({\n slots: {\n root: 'w-full gap-4',\n podium: 'flex-row items-end gap-2 px-1',\n column: 'flex-1 items-center',\n person: 'items-center gap-1 pb-2',\n rankBadge:\n 'absolute -bottom-1.5 h-5 min-w-5 items-center justify-center self-center rounded-full border-2 border-background px-1',\n bar: 'w-full items-center overflow-hidden rounded-t-2xl pt-2.5',\n card: 'flex-1 items-center gap-1.5 rounded-2xl border border-border bg-card px-2 pb-3 pt-4',\n list: 'overflow-hidden rounded-2xl border border-border bg-card',\n row: 'min-h-14 flex-row items-center gap-3 px-3 py-2.5',\n rowDivider: 'ml-14 h-px bg-border',\n rank: 'w-7 text-center text-sm font-semibold tabular-nums text-muted-foreground',\n rowText: 'flex-1 gap-0.5',\n value: 'text-sm font-semibold tabular-nums text-foreground',\n change: 'min-w-10 flex-row items-center justify-end gap-0.5',\n gap: 'flex-row items-center justify-center gap-1 py-1.5',\n gapDot: 'h-1 w-1 rounded-full bg-muted-foreground/40',\n empty: 'items-center rounded-2xl border border-dashed border-border px-4 py-8',\n },\n});\n\n/**\n * Colour by podium place. A lookup rather than a `tv()` variant: place is\n * derived from the data, not something a caller passes, and a variant here\n * would be documented as a prop on `Leaderboard`.\n *\n * One hue stepped down in strength, so the podium reads as a single series\n * and follows the theme's primary rather than borrowing medal colours that\n * would clash with half the themes.\n */\nconst PLACE: Record<1 | 2 | 3, { bar: string; badge: string }> = {\n 1: { bar: 'bg-primary', badge: 'bg-primary' },\n 2: { bar: 'bg-primary/40', badge: 'bg-foreground' },\n 3: { bar: 'bg-primary/16', badge: 'bg-foreground' },\n};\n\nexport interface LeaderboardEntry {\n /** Stable identity, used for keys and `highlightId`. */\n id: string;\n /** Shown on the row and read out first after the rank. */\n name: string;\n /** What the board is ranked by. */\n value: number;\n /** Image for the avatar. A string is taken as a URI. Initials stand in without one. */\n avatar?: ImageSourcePropType | string;\n /** A second line under the name — a team, a level, a city. */\n subtitle?: string;\n /**\n * Places moved since the last period. Positive is up the board, negative\n * is down, 0 is unchanged. Leave it out to show no movement column.\n */\n change?: number;\n}\n\nexport type LeaderboardPodium = 'bars' | 'cards' | 'none';\n\nexport interface LeaderboardProps extends Omit {\n className?: string;\n /** The entries, in any order. Places are worked out from `value`. */\n data: readonly LeaderboardEntry[];\n /**\n * How the top three are drawn. `bars` stands them on a podium whose bars\n * are scaled to their values; `cards` gives each a card, with first raised\n * between the other two; `none` puts everyone in the list.\n */\n podium?: LeaderboardPodium;\n /**\n * `desc` ranks the highest value first. `asc` ranks the lowest first, for a\n * time or a golf score.\n */\n order?: 'desc' | 'asc';\n /**\n * What the podium bar heights follow. `value` draws each against the\n * leader's value; `rank` steps them by place. Always `rank` when `order` is\n * `asc`, where the winning value is the smallest.\n */\n podiumScale?: 'value' | 'rank';\n /** Height of the tallest podium bar, in points. */\n podiumHeight?: number;\n /** Show at most this many entries, podium included. */\n limit?: number;\n /**\n * The entry to mark — usually the person looking at the board. Their row is\n * tinted, and if `limit` cuts them off it is pinned at the bottom with their\n * real place.\n */\n highlightId?: string;\n /** Turns a value into its label. Defaults to grouped digits: 12,480. */\n formatValue?: (value: number) => string;\n /** Word read after the value by a screen reader, e.g. \"points\". */\n unit?: string;\n /** Makes every row and podium place pressable. */\n onPressEntry?: (entry: LeaderboardEntry, rank: number) => void;\n /** Shown when `data` has nothing to rank. */\n emptyText?: string;\n}\n\nfunction initials(name: string): string {\n const parts = name.trim().split(/\\s+/).filter(Boolean);\n const first = parts[0]?.[0] ?? '';\n const last = parts.length > 1 ? (parts[parts.length - 1]?.[0] ?? '') : '';\n return (first + last).toUpperCase() || '?';\n}\n\nfunction toSource(avatar: LeaderboardEntry['avatar']): ImageSourcePropType | undefined {\n return typeof avatar === 'string' ? { uri: avatar } : avatar;\n}\n\nconst defaultFormat = (value: number) => value.toLocaleString('en-US');\n\nfunction describe(\n { entry, rank, tied }: Ranked,\n label: string,\n unit: string | undefined\n): string {\n const place = tied ? `Rank ${rank}, tied` : `Rank ${rank}`;\n const parts = [place, entry.name, unit ? `${label} ${unit}` : label];\n if (entry.subtitle) parts.splice(2, 0, entry.subtitle);\n if (entry.change) {\n const n = Math.abs(entry.change);\n parts.push(`${entry.change > 0 ? 'up' : 'down'} ${n} ${n === 1 ? 'place' : 'places'}`);\n } else if (entry.change === 0) {\n parts.push('no change');\n }\n return parts.join(', ');\n}\n\n/** A pressable when the board is, a plain view when it is not. */\nfunction Slot({\n onPress,\n label,\n className,\n children,\n}: {\n onPress?: () => void;\n label: string;\n className?: string;\n children: React.ReactNode;\n}) {\n if (onPress) {\n return (\n \n {children}\n \n );\n }\n return (\n \n {children}\n \n );\n}\n\nfunction PodiumBar({\n place,\n height,\n delay,\n rank,\n}: {\n place: 1 | 2 | 3;\n height: number;\n delay: number;\n rank: number;\n}) {\n const slots = leaderboardVariants();\n const reduced = useReducedMotion();\n const grown = useSharedValue(reduced ? height : 0);\n\n useEffect(() => {\n grown.value = reduced\n ? height\n : withDelay(\n delay,\n withTiming(height, { duration: GROW_DURATION, easing: Easing.out(Easing.cubic) })\n );\n }, [grown, height, delay, reduced]);\n\n const style = useAnimatedStyle(() => ({ height: grown.value }));\n // The numeral fades in once the bar is tall enough to hold it, so it never\n // overflows a bar that is still a few points high.\n const numeral = useAnimatedStyle(() => ({\n opacity: Math.min(1, Math.max(0, (grown.value - 28) / 24)),\n }));\n\n return (\n \n \n \n {rank}\n \n \n \n );\n}\n\nfunction RankBadge({ place, rank }: { place: 1 | 2 | 3; rank: number }) {\n const slots = leaderboardVariants();\n return (\n \n \n {rank}\n \n \n );\n}\n\nfunction ChangeLabel({ change }: { change: number | undefined }) {\n const slots = leaderboardVariants();\n const success = useCSSVariable('--color-success');\n const destructive = useCSSVariable('--color-destructive');\n if (change === undefined) return null;\n\n if (change === 0) {\n return (\n \n \n –\n \n \n );\n }\n\n const up = change > 0;\n const tint = up ? success : destructive;\n const color = typeof tint === 'string' ? tint : undefined;\n const Arrow = up ? ArrowUpIcon : ArrowDownIcon;\n return (\n \n \n \n {Math.abs(change)}\n \n \n );\n}\n\n/**\n * Ranked entries with a podium for the top three and a list for the rest.\n *\n * Renders every row it is given rather than virtualising them: a board is\n * read from the top, and past a few dozen rows nobody is reading it. Use\n * `limit` for a long board, and `highlightId` to keep the viewer's own row in\n * sight when the limit cuts them off.\n */\nconst LeaderboardRoot = forwardRef(function Leaderboard(\n {\n className,\n data,\n podium = 'bars',\n order = 'desc',\n podiumScale = 'value',\n podiumHeight = 132,\n limit,\n highlightId,\n formatValue = defaultFormat,\n unit,\n onPressEntry,\n emptyText = 'No entries yet',\n ...props\n },\n ref\n) {\n const slots = leaderboardVariants();\n const ranked = useMemo(() => rankEntries(data, order), [data, order]);\n const shown = limit != null ? ranked.slice(0, Math.max(0, limit)) : ranked;\n\n // The viewer's own row, when the limit has cut it off.\n const pinned =\n highlightId != null && !shown.some((r) => r.entry.id === highlightId)\n ? ranked.find((r) => r.entry.id === highlightId)\n : undefined;\n\n const top = podium === 'none' ? [] : shown.slice(0, 3);\n const rest = podium === 'none' ? shown : shown.slice(3);\n\n const press = (item: Ranked) =>\n onPressEntry ? () => onPressEntry(item.entry, item.rank) : undefined;\n\n if (ranked.length === 0) {\n return (\n \n \n \n {emptyText}\n \n \n \n );\n }\n\n // Second, first, third. With fewer than three the missing seats are left\n // out rather than drawn empty.\n const seats = [top[1], top[0], top[2]]\n .map((item, slot) => (item ? { item, place: ([2, 1, 3] as const)[slot]! } : null))\n .filter((seat): seat is { item: Ranked; place: 1 | 2 | 3 } => !!seat);\n\n const heights = podiumHeights(\n seats.map((s) => s.item.entry.value),\n seats.map((s) => s.item.rank),\n order === 'asc' ? 'rank' : podiumScale\n );\n\n const renderRow = (item: Ranked, index: number) => {\n const label = formatValue(item.entry.value);\n const highlighted = item.entry.id === highlightId;\n return (\n \n {index > 0 ? : null}\n \n \n {item.rank}\n \n \n \n \n {item.entry.name}\n \n {item.entry.subtitle ? (\n \n {item.entry.subtitle}\n \n ) : null}\n \n {label}\n \n \n \n );\n };\n\n return (\n \n {podium === 'bars' && seats.length > 0 ? (\n \n {seats.map(({ item, place }, i) => {\n const label = formatValue(item.entry.value);\n return (\n \n \n \n \n \n \n \n {item.entry.name}\n \n \n {label}\n \n \n \n \n );\n })}\n \n ) : null}\n\n {podium === 'cards' && seats.length > 0 ? (\n \n {seats.map(({ item, place }) => {\n const label = formatValue(item.entry.value);\n return (\n \n \n \n \n \n \n {item.entry.name}\n \n \n {label}\n \n {item.entry.subtitle ? (\n \n {item.entry.subtitle}\n \n ) : null}\n \n );\n })}\n \n ) : null}\n\n {rest.length > 0 || pinned ? (\n \n {rest.map(renderRow)}\n {pinned ? (\n <>\n {rest.length > 0 ? (\n \n \n \n \n \n ) : null}\n {renderRow(pinned, 0)}\n \n ) : null}\n \n ) : null}\n \n );\n});\n\nexport const Leaderboard = LeaderboardRoot;\n" }, { "path": "ui/leaderboard-rank.ts", diff --git a/apps/docs/public/r/markdown-editor.json b/apps/docs/public/r/markdown-editor.json index 7db811b93..89c884c14 100644 --- a/apps/docs/public/r/markdown-editor.json +++ b/apps/docs/public/r/markdown-editor.json @@ -7,6 +7,7 @@ "button-group", "cn", "icons", + "refs", "response", "text", "textarea" @@ -19,7 +20,7 @@ { "path": "ui/markdown-editor.tsx", "type": "registry:ui", - "content": "/**\n * MarkdownEditor — a field for writing markdown, and a way to see it rendered.\n *\n * ```tsx\n * const [draft, setDraft] = useState('');\n *\n * \n * ```\n *\n * ## Writing and reading are two modes, not two panes\n *\n * Side by side is how this is done on a desktop, and it does not survive the\n * trip to a phone: two columns of a phone's width are two columns too narrow to\n * read, and the keyboard covers the bottom half of the screen exactly when the\n * writer is using it. So there is one pane and a switch — write, or read what\n * you wrote. The toolbar carries the switch, because the toolbar is the one\n * thing on screen in both modes.\n *\n * ## The toolbar edits the selection\n *\n * Every button here is a function of the text and where the caret is in it, and\n * the answer that matters is not which characters get inserted but where the\n * caret lands afterwards. Bolding a selected phrase leaves it selected, so it\n * can be italicised next; bolding nothing puts the caret between the new\n * markers, where the writing is about to go; and pressing the same button again\n * takes the markers off. Those rules live in `markdown-transforms.ts` as pure\n * functions over `(text, selection)`, away from anything to do with a field.\n *\n * ## Why it renders through Response\n *\n * The preview is `Response`, the same reader that renders a model's answer, so\n * markdown means the same thing everywhere in the library and there is one\n * parser to be right rather than two to keep in step. It renders through\n * `Typography`, `CodeBlock` and `Table`, which is to say through the app's own\n * type and colours — a preview that looked like a document viewer would be\n * showing the writer something they are not going to ship.\n *\n * Nothing here reaches for a platform markdown renderer. `Text` in SwiftUI can\n * parse markdown and Jetpack Compose's cannot, so a platform-backed editor\n * would render on one of the two and be a plain string on the other.\n *\n * Works controlled (`value` + `onValueChange`) or uncontrolled (`defaultValue`).\n */\nimport {\n Fragment,\n createContext,\n forwardRef,\n useCallback,\n useContext,\n useImperativeHandle,\n useMemo,\n useRef,\n useState,\n type ReactNode,\n} from 'react';\nimport {\n Pressable,\n ScrollView,\n View,\n type NativeSyntheticEvent,\n type TextInput,\n type TextInputSelectionChangeEventData,\n type ViewProps,\n} from 'react-native';\nimport { tv } from 'tailwind-variants';\nimport {\n BoldIcon,\n CodeIcon,\n EyeIcon,\n HeadingIcon,\n ImageIcon,\n ItalicIcon,\n LinkIcon,\n ListIcon,\n ListOrderedIcon,\n PencilIcon,\n QuoteIcon,\n} from '@/components/ui/icons';\nimport { Text } from '@/components/ui/text';\nimport { cn } from '@/lib/cn';\nimport { Button } from '@/components/ui/button';\nimport { ButtonGroup } from '@/components/ui/button-group';\nimport { Response } from '@/components/ui/response';\nimport { Textarea, type TextareaProps } from '@/components/ui/textarea';\nimport {\n continueList,\n hasFence,\n hasLinePrefix,\n hasOrderedList,\n hasWrap,\n insertImage,\n insertLink,\n toggleFence,\n toggleLinePrefix,\n toggleOrderedList,\n toggleWrap,\n type EditResult,\n type EditorSelection,\n} from '@/components/ui/markdown-transforms';\n\nexport type { EditorSelection, EditResult } from '@/components/ui/markdown-transforms';\n\n/** Which pane the editor is showing. */\nexport type MarkdownEditorMode = 'write' | 'preview';\n\n/** A formatting action the toolbar can offer. */\nexport type MarkdownEditorAction =\n | 'bold'\n | 'italic'\n | 'heading'\n | 'quote'\n | 'code'\n | 'bulletList'\n | 'orderedList'\n | 'link'\n | 'image';\n\n/** The actions a toolbar shows when it is not told which to show. */\nconst DEFAULT_ACTIONS: MarkdownEditorAction[] = [\n 'bold',\n 'italic',\n 'heading',\n 'bulletList',\n 'orderedList',\n 'quote',\n 'code',\n 'link',\n];\n\n/**\n * The same actions in the capsule, grouped.\n *\n * Eight rather than nine: the capsule is a fixed row of targets with hairlines\n * between its groups, and `quote` is the one a writer reaches for least. It is\n * still available — pass `actions` and it appears in whatever grouping the\n * families below produce.\n */\nconst DEFAULT_PILL_ACTIONS: MarkdownEditorAction[] = [\n 'bold',\n 'italic',\n 'heading',\n 'link',\n 'image',\n 'orderedList',\n 'bulletList',\n 'code',\n];\n\n/**\n * Which family each action belongs to, which is what the capsule's hairlines\n * separate: what the words look like, what is being put into the document, and\n * what shape the block is.\n */\nconst FAMILY: Record = {\n bold: 'inline',\n italic: 'inline',\n heading: 'inline',\n link: 'insert',\n image: 'insert',\n orderedList: 'insert',\n bulletList: 'insert',\n code: 'block',\n quote: 'block',\n};\n\n/**\n * What each action does, what it is called, and what it looks like.\n *\n * One table rather than a switch in the toolbar and another in the context:\n * adding an action should be adding a row here, and nowhere else.\n */\nconst ACTIONS: Record<\n MarkdownEditorAction,\n {\n label: string;\n icon: (size: number) => ReactNode;\n apply: (text: string, selection: EditorSelection) => EditResult;\n /**\n * Whether this action is already in effect where the caret is.\n *\n * The toolbar marks those buttons, which is what makes \"pressing twice\n * undoes it\" visible rather than something the writer has to discover. An\n * action that only ever inserts — a link, an image — has no such state and\n * says so by leaving this out.\n */\n isActive?: (text: string, selection: EditorSelection) => boolean;\n }\n> = {\n bold: {\n label: 'Bold',\n icon: (size) => ,\n apply: (text, selection) => toggleWrap(text, selection, '**'),\n isActive: (text, selection) => hasWrap(text, selection, '**'),\n },\n italic: {\n label: 'Italic',\n icon: (size) => ,\n apply: (text, selection) => toggleWrap(text, selection, '_'),\n isActive: (text, selection) => hasWrap(text, selection, '_'),\n },\n heading: {\n label: 'Heading',\n icon: (size) => ,\n apply: (text, selection) => toggleLinePrefix(text, selection, '## '),\n isActive: (text, selection) => hasLinePrefix(text, selection, '## '),\n },\n quote: {\n label: 'Quote',\n icon: (size) => ,\n apply: (text, selection) => toggleLinePrefix(text, selection, '> '),\n isActive: (text, selection) => hasLinePrefix(text, selection, '> '),\n },\n code: {\n label: 'Code block',\n icon: (size) => ,\n apply: toggleFence,\n isActive: hasFence,\n },\n bulletList: {\n label: 'Bulleted list',\n icon: (size) => ,\n apply: (text, selection) => toggleLinePrefix(text, selection, '- '),\n isActive: (text, selection) => hasLinePrefix(text, selection, '- '),\n },\n orderedList: {\n label: 'Numbered list',\n icon: (size) => ,\n apply: toggleOrderedList,\n isActive: hasOrderedList,\n },\n link: {\n label: 'Link',\n icon: (size) => ,\n apply: insertLink,\n },\n image: {\n label: 'Image',\n icon: (size) => ,\n apply: insertImage,\n },\n};\n\nconst markdownEditorVariants = tv({\n slots: {\n root: 'w-full gap-2',\n toolbar: 'flex-row items-center justify-between gap-2',\n /**\n * The capsule holding the formatting buttons in the `pill` toolbar.\n *\n * `shrink` and `overflow-hidden` are load-bearing. Eight 36pt targets, two\n * hairlines and the round button beside them come to more than a phone is\n * wide, and a row that cannot shrink does not wrap — it runs off the edge\n * of the screen taking the last two actions with it. The capsule gives up\n * width instead, and scrolls whatever no longer fits.\n */\n capsule:\n 'min-w-0 shrink flex-row items-center overflow-hidden rounded-full border border-input bg-popover px-1.5 shadow-lg',\n /** One hairline between two groups of them. */\n divider: 'mx-1 h-5 w-px bg-border',\n /** The round button outside the capsule, which switches the pane. */\n escape:\n 'h-11 w-11 shrink-0 items-center justify-center rounded-full border border-input bg-popover shadow-lg',\n // The preview stands in the field's place, so it takes the field's shape.\n // A rendered draft that sat on the page with no edge around it would not\n // read as the same object the writing was in.\n preview: 'w-full rounded-xl border border-input bg-popover px-3 py-2.5',\n empty: 'py-6 text-center',\n },\n variants: {\n /**\n * How the toolbar is drawn.\n *\n * `bar` is the row: formatting on one side, the write/preview switch on the\n * other, and room between them for a word count or a submit. `pill` is the\n * floating capsule — icon-only, grouped by hairlines, with the pane switch\n * as a round button beside it. The capsule has no room for anything else,\n * so a toolbar with children wants the bar.\n */\n variant: {\n bar: {},\n pill: { toolbar: 'justify-center gap-2' },\n },\n },\n defaultVariants: { variant: 'bar' },\n});\n\nexport type MarkdownEditorToolbarVariant = 'bar' | 'pill';\n\ninterface MarkdownEditorContextValue {\n value: string;\n mode: MarkdownEditorMode;\n setMode: (mode: MarkdownEditorMode) => void;\n /** Runs a transform against the live text and selection. */\n run: (action: MarkdownEditorAction) => void;\n /**\n * Which actions are already in effect where the caret is. Recomputed when\n * the caret moves rather than on every keystroke — the toolbar is the only\n * thing that reads it, and it has eight buttons.\n */\n active: ReadonlySet;\n /** What the field should be showing as selected, once, after an edit. */\n pending: EditorSelection | undefined;\n setSelection: (selection: EditorSelection) => void;\n clearPending: () => void;\n onChangeText: (next: string) => void;\n /** Registers the field, so the root's ref can focus it. */\n registerField: (field: TextInput | null) => void;\n disabled: boolean;\n}\n\nconst MarkdownEditorContext = createContext(null);\n\nfunction useMarkdownEditor(part: string): MarkdownEditorContextValue {\n const ctx = useContext(MarkdownEditorContext);\n if (!ctx) throw new Error(`${part} must be used inside .`);\n return ctx;\n}\n\n/**\n * What a `ref` on the editor gives you.\n *\n * The same transforms the toolbar runs, so a caller drawing their own controls\n * — a keyboard accessory, a context menu, a single Bold button somewhere else\n * on the screen — does not have to reimplement any of them. Every one applies\n * to the live selection and leaves the caret where the toolbar would.\n */\nexport interface MarkdownEditorHandle {\n /** The container, for measuring and for anything a `View` ref is good for. */\n readonly view: View | null;\n /** Runs one of the toolbar's actions against the current selection. */\n apply: (action: MarkdownEditorAction) => void;\n /** Which actions are already in effect where the caret is. */\n getActive: () => MarkdownEditorAction[];\n /** Where the caret is, or what is selected. */\n getSelection: () => EditorSelection;\n /** Moves the caret, or selects a range. */\n setSelection: (selection: EditorSelection) => void;\n /** Puts the caret in the field. Switches to the writing pane if it is not up. */\n focus: () => void;\n blur: () => void;\n /** Switches pane. */\n setMode: (mode: MarkdownEditorMode) => void;\n}\n\nexport interface MarkdownEditorProps extends Omit {\n className?: string;\n /** Controlled text. Leave unset and pass `defaultValue` to run uncontrolled. */\n value?: string;\n /** Starting text when uncontrolled. */\n defaultValue?: string;\n onValueChange?: (value: string) => void;\n /** Controlled pane. */\n mode?: MarkdownEditorMode;\n /** Starting pane when uncontrolled. */\n defaultMode?: MarkdownEditorMode;\n onModeChange?: (mode: MarkdownEditorMode) => void;\n /**\n * Stop the field being edited and the formatting actions being pressed. The\n * switch between the panes stays live: a draft nobody may edit is still a\n * draft somebody may want to read rendered.\n */\n disabled?: boolean;\n /**\n * The parts, in the order they should stack. Left out, the editor draws its\n * toolbar, its field and its preview in that order — which is the whole\n * component, and the reason it usually needs no children.\n */\n children?: ReactNode;\n /** Forwarded to the field when the editor draws its own. */\n placeholder?: string;\n /** Height of the field, in lines. Forwarded to the field the editor draws. */\n rows?: number;\n /**\n * Lift the editor above the keyboard. Forwarded to the field the editor\n * draws, and off by default because it changes which component renders the\n * container — so it cannot be toggled at runtime without remounting the\n * field and dropping focus.\n */\n avoidKeyboard?: boolean;\n /**\n * Continue a list when Return is pressed inside one, and end it when Return\n * is pressed on an item with nothing in it. On by default: a list that stops\n * numbering itself after the first item is a list the writer finishes by\n * hand.\n */\n continueLists?: boolean;\n}\n\nconst MarkdownEditorRoot = forwardRef(\n (\n {\n className,\n value: valueProp,\n defaultValue = '',\n onValueChange,\n mode: modeProp,\n defaultMode = 'write',\n onModeChange,\n disabled = false,\n children,\n placeholder,\n rows,\n avoidKeyboard,\n continueLists = true,\n ...props\n },\n ref\n ) => {\n const slots = markdownEditorVariants();\n const viewRef = useRef(null);\n const fieldRef = useRef(null);\n\n const [internalValue, setInternalValue] = useState(defaultValue);\n const value = valueProp ?? internalValue;\n\n const [internalMode, setInternalMode] = useState(defaultMode);\n const mode = modeProp ?? internalMode;\n\n /*\n * The live selection is a ref rather than state.\n *\n * It changes on every caret move — every keystroke, every tap in the text —\n * and nothing on screen depends on it. Holding it in state would re-render\n * the field, and therefore the preview under it, once per character typed.\n */\n const selectionRef = useRef({ start: 0, end: 0 });\n\n /*\n * What the field should show as selected after a toolbar edit, held for\n * exactly one render.\n *\n * A `selection` prop that stays set is a field the writer cannot move the\n * caret in — every tap would be pulled back to wherever the last edit put\n * it. So it is set once, applied, and cleared by the very selection change\n * it causes.\n */\n const [pending, setPending] = useState(undefined);\n\n const valueRef = useRef(value);\n valueRef.current = value;\n\n const commit = useCallback(\n (next: string) => {\n if (valueProp === undefined) setInternalValue(next);\n onValueChange?.(next);\n },\n [valueProp, onValueChange]\n );\n\n const setMode = useCallback(\n (next: MarkdownEditorMode) => {\n if (modeProp === undefined) setInternalMode(next);\n onModeChange?.(next);\n },\n [modeProp, onModeChange]\n );\n\n /*\n * Which actions are already in effect, held in state because the toolbar\n * draws from it.\n *\n * Recomputed when the caret moves or the text changes through an edit, not\n * on every keystroke: eight predicates over the whole document on every\n * character typed is work nobody asked for, and typing a letter cannot\n * change what is applied at the caret. Toggling a marker can, which is why\n * `run` recomputes it too.\n */\n const [active, setActive] = useState>(\n () => new Set()\n );\n\n const readActive = useCallback((text: string, selection: EditorSelection) => {\n const next = new Set();\n for (const key of Object.keys(ACTIONS) as MarkdownEditorAction[]) {\n if (ACTIONS[key].isActive?.(text, selection)) next.add(key);\n }\n setActive((current) =>\n current.size === next.size && [...next].every((key) => current.has(key))\n ? current\n : next\n );\n }, []);\n\n const run = useCallback(\n (action: MarkdownEditorAction) => {\n const result = ACTIONS[action].apply(valueRef.current, selectionRef.current);\n // Kept locally too: the parent may be slow to hand the new text back,\n // and the next press must not be computed against the old string.\n valueRef.current = result.text;\n selectionRef.current = result.selection;\n commit(result.text);\n setPending(result.selection);\n readActive(result.text, result.selection);\n },\n [commit, readActive]\n );\n\n /*\n * A list continues itself when Return lands inside one.\n *\n * Read off the text change rather than off `onKeyPress`, because a key\n * event here cannot be prevented — the field applies the newline whatever\n * the handler does, so acting on the key would insert the marker *and* a\n * second line break. Watching the change instead means the newline is\n * already in the string, and what happens is a rewrite of it: the marker\n * goes in behind it, or, on an item with nothing in it, the marker comes\n * off and the list ends.\n *\n * One inserted character only. A paste that happens to contain a newline\n * is not somebody pressing Return.\n */\n const applyChange = useCallback(\n (next: string) => {\n const previous = valueRef.current;\n\n if (continueLists && next.length === previous.length + 1) {\n let at = 0;\n while (at < previous.length && previous[at] === next[at]) at += 1;\n\n if (next[at] === '\\n') {\n const result = continueList(previous, { start: at, end: at });\n if (result) {\n valueRef.current = result.text;\n selectionRef.current = result.selection;\n commit(result.text);\n setPending(result.selection);\n readActive(result.text, result.selection);\n return;\n }\n }\n }\n\n valueRef.current = next;\n commit(next);\n },\n [continueLists, commit, readActive]\n );\n\n const registerField = useCallback((field: TextInput | null) => {\n fieldRef.current = field;\n }, []);\n\n useImperativeHandle(\n ref,\n () => ({\n get view() {\n return viewRef.current;\n },\n apply: run,\n getActive: () => [...active],\n getSelection: () => ({ ...selectionRef.current }),\n setSelection: (selection) => {\n selectionRef.current = selection;\n setPending(selection);\n readActive(valueRef.current, selection);\n },\n focus: () => {\n // The field is unmounted while the preview is up, so focusing it has\n // to bring it back first — otherwise this is a silent no-op that\n // looks like a broken ref.\n setMode('write');\n fieldRef.current?.focus();\n },\n blur: () => fieldRef.current?.blur(),\n setMode,\n }),\n [run, active, readActive, setMode]\n );\n\n const context = useMemo(\n () => ({\n value,\n mode,\n setMode,\n run,\n active,\n pending,\n setSelection: (selection) => {\n selectionRef.current = selection;\n readActive(valueRef.current, selection);\n },\n clearPending: () => setPending(undefined),\n onChangeText: applyChange,\n registerField,\n disabled,\n }),\n [value, mode, setMode, run, active, pending, applyChange, readActive, registerField, disabled]\n );\n\n return (\n \n \n {children ?? (\n <>\n \n \n \n \n )}\n \n \n );\n }\n);\n\nMarkdownEditorRoot.displayName = 'MarkdownEditor';\n\n/* -------------------------------------------------------------------------- *\n * Toolbar\n * -------------------------------------------------------------------------- */\n\nexport interface MarkdownEditorToolbarProps extends Omit {\n className?: string;\n /**\n * How the toolbar is drawn. `bar` is the full-width row, with room on it for\n * a word count or a submit. `pill` is the floating capsule: icon-only,\n * grouped by hairlines, with the pane switch as a round button beside it.\n */\n variant?: MarkdownEditorToolbarVariant;\n /**\n * Which formatting actions to offer, in the order given. The capsule groups\n * whatever it is given by family — what the words look like, what is being\n * put into the document, what shape the block is — and draws a hairline\n * where the family changes.\n */\n actions?: MarkdownEditorAction[];\n /**\n * Show the write/preview switch. On by default — a preview nobody can reach\n * is a pane that does not exist.\n */\n showModeSwitch?: boolean;\n /**\n * Anything else to put on the row: a word count, a save state, a submit.\n * The capsule has no room for these, so a toolbar with children wants `bar`.\n */\n children?: ReactNode;\n}\n\n/**\n * The formatting actions, and the switch between writing and reading.\n *\n * The formatting run is hidden rather than disabled while previewing. Its\n * buttons act on a selection, and in the preview there is no selection for them\n * to act on — a row of controls that are present but inert is a row that has to\n * be tried before it can be understood.\n *\n * An action already in effect where the caret is draws as pressed. Every button\n * here is a toggle, and a toggle that looks the same in both states is a toggle\n * nobody discovers is one.\n */\nconst MarkdownEditorToolbar = forwardRef(\n (\n {\n className,\n variant = 'bar',\n actions,\n showModeSwitch = true,\n children,\n ...props\n },\n ref\n ) => {\n const { mode, setMode, run, active, disabled } = useMarkdownEditor(\n 'MarkdownEditor.Toolbar'\n );\n const slots = markdownEditorVariants({ variant });\n const pill = variant === 'pill';\n const shown = actions ?? (pill ? DEFAULT_PILL_ACTIONS : DEFAULT_ACTIONS);\n\n const button = (action: MarkdownEditorAction, size: number) => (\n run(action)}\n className={pill ? 'h-9 w-9 rounded-full px-0' : 'px-2'}\n >\n {ACTIONS[action].icon(size)}\n \n );\n\n if (pill) {\n return (\n \n {mode === 'write' ? (\n \n \n {shown.map((action, index) => (\n \n {/* A hairline wherever the family changes, and nowhere\n else: a divider between every pair is eight dividers,\n which is a row of eight separate things rather than\n three groups. */}\n {index > 0 && FAMILY[action] !== FAMILY[shown[index - 1]!] ? (\n \n ) : null}\n {button(action, 18)}\n \n ))}\n \n \n ) : null}\n\n {showModeSwitch ? (\n /* Outside the capsule, and round rather than a segment of it. The\n capsule is what the writing is done with; this is the way out of\n it, and a control that leaves somewhere should not look like one\n of the things you do while you are there. */\n setMode(mode === 'write' ? 'preview' : 'write')}\n >\n {mode === 'write' ? : }\n \n ) : null}\n \n );\n }\n\n return (\n \n {mode === 'write' ? (\n \n {shown.map((action) => button(action, 16))}\n \n ) : (\n \n )}\n\n \n {children}\n {showModeSwitch ? (\n \n }\n onPress={() => setMode('write')}\n >\n Write\n \n }\n onPress={() => setMode('preview')}\n >\n Preview\n \n \n ) : null}\n \n \n );\n }\n);\n\nMarkdownEditorToolbar.displayName = 'MarkdownEditor.Toolbar';\n\n/* -------------------------------------------------------------------------- *\n * Input\n * -------------------------------------------------------------------------- */\n\nexport interface MarkdownEditorInputProps\n extends Omit {\n className?: string;\n}\n\n/**\n * The field the markdown is written in — a `Textarea`, tracking its selection.\n *\n * Renders nothing while the preview is up. Unmounted rather than hidden: a\n * field kept alive behind the preview keeps the keyboard up with it.\n */\nconst MarkdownEditorInput = forwardRef(\n ({ className, rows = 8, ...props }, ref) => {\n const {\n value,\n mode,\n pending,\n setSelection,\n clearPending,\n onChangeText,\n registerField,\n disabled,\n } = useMarkdownEditor('MarkdownEditor.Input');\n\n /*\n * The field is registered so the editor's own ref can focus it, and\n * forwarded so a caller's ref still works. Both, rather than either.\n */\n const attach = useCallback(\n (field: TextInput | null) => {\n registerField(field);\n if (typeof ref === 'function') ref(field);\n else if (ref) ref.current = field;\n },\n [ref, registerField]\n );\n\n /*\n * The forced selection is cleared only once it has actually arrived.\n *\n * Clearing on the first selection change of any kind was the bug: the new\n * `value` landing produces one of its own, and it fires first — so the\n * caret was released before it had been put anywhere, and ended up at the\n * end of the document instead of between the markers just inserted.\n */\n const handleSelectionChange = useCallback(\n (event: NativeSyntheticEvent) => {\n const { selection } = event.nativeEvent;\n setSelection(selection);\n if (\n pending &&\n selection.start === pending.start &&\n selection.end === pending.end\n ) {\n clearPending();\n }\n },\n [setSelection, clearPending, pending]\n );\n\n if (mode !== 'write') return null;\n\n return (\n \n );\n }\n);\n\nMarkdownEditorInput.displayName = 'MarkdownEditor.Input';\n\n/* -------------------------------------------------------------------------- *\n * Preview\n * -------------------------------------------------------------------------- */\n\nexport interface MarkdownEditorPreviewProps extends Omit {\n className?: string;\n /** What to show when there is nothing written yet. */\n emptyText?: string;\n}\n\n/**\n * The draft, rendered.\n *\n * Renders nothing while the field is up, which is what makes the two one pane\n * rather than two.\n */\nconst MarkdownEditorPreview = forwardRef(\n ({ className, emptyText = 'Nothing to preview yet.', ...props }, ref) => {\n const { value, mode } = useMarkdownEditor('MarkdownEditor.Preview');\n const slots = markdownEditorVariants();\n\n if (mode !== 'preview') return null;\n\n return (\n \n {value.trim() ? (\n {value}\n ) : (\n \n {emptyText}\n \n )}\n \n );\n }\n);\n\nMarkdownEditorPreview.displayName = 'MarkdownEditor.Preview';\n\nexport const MarkdownEditor = Object.assign(MarkdownEditorRoot, {\n Toolbar: MarkdownEditorToolbar,\n Input: MarkdownEditorInput,\n Preview: MarkdownEditorPreview,\n});\n" + "content": "/**\n * MarkdownEditor — a field for writing markdown, and a way to see it rendered.\n *\n * ```tsx\n * const [draft, setDraft] = useState('');\n *\n * \n * ```\n *\n * ## Writing and reading are two modes, not two panes\n *\n * Side by side is how this is done on a desktop, and it does not survive the\n * trip to a phone: two columns of a phone's width are two columns too narrow to\n * read, and the keyboard covers the bottom half of the screen exactly when the\n * writer is using it. So there is one pane and a switch — write, or read what\n * you wrote. The toolbar carries the switch, because the toolbar is the one\n * thing on screen in both modes.\n *\n * ## The toolbar edits the selection\n *\n * Every button here is a function of the text and where the caret is in it, and\n * the answer that matters is not which characters get inserted but where the\n * caret lands afterwards. Bolding a selected phrase leaves it selected, so it\n * can be italicised next; bolding nothing puts the caret between the new\n * markers, where the writing is about to go; and pressing the same button again\n * takes the markers off. Those rules live in `markdown-transforms.ts` as pure\n * functions over `(text, selection)`, away from anything to do with a field.\n *\n * ## Why it renders through Response\n *\n * The preview is `Response`, the same reader that renders a model's answer, so\n * markdown means the same thing everywhere in the library and there is one\n * parser to be right rather than two to keep in step. It renders through\n * `Typography`, `CodeBlock` and `Table`, which is to say through the app's own\n * type and colours — a preview that looked like a document viewer would be\n * showing the writer something they are not going to ship.\n *\n * Nothing here reaches for a platform markdown renderer. `Text` in SwiftUI can\n * parse markdown and Jetpack Compose's cannot, so a platform-backed editor\n * would render on one of the two and be a plain string on the other.\n *\n * Works controlled (`value` + `onValueChange`) or uncontrolled (`defaultValue`).\n */\nimport {\n Fragment,\n createContext,\n forwardRef,\n useCallback,\n useContext,\n useImperativeHandle,\n useMemo,\n useRef,\n useState,\n type ReactNode,\n} from 'react';\nimport {\n Pressable,\n ScrollView,\n View,\n type NativeSyntheticEvent,\n type TextInput,\n type TextInputSelectionChangeEventData,\n type ViewProps,\n} from 'react-native';\nimport { tv } from 'tailwind-variants';\nimport {\n BoldIcon,\n CodeIcon,\n EyeIcon,\n HeadingIcon,\n ImageIcon,\n ItalicIcon,\n LinkIcon,\n ListIcon,\n ListOrderedIcon,\n PencilIcon,\n QuoteIcon,\n} from '@/components/ui/icons';\nimport { Text } from '@/components/ui/text';\nimport { cn } from '@/lib/cn';\nimport { Button } from '@/components/ui/button';\nimport { ButtonGroup } from '@/components/ui/button-group';\nimport { Response } from '@/components/ui/response';\nimport { Textarea, type TextareaProps } from '@/components/ui/textarea';\nimport {\n continueList,\n hasFence,\n hasLinePrefix,\n hasOrderedList,\n hasWrap,\n insertImage,\n insertLink,\n toggleFence,\n toggleLinePrefix,\n toggleOrderedList,\n toggleWrap,\n type EditResult,\n type EditorSelection,\n} from '@/components/ui/markdown-transforms';\nimport type { TextInputRef, ViewRef } from '@/components/ui/refs';\n\nexport type { EditorSelection, EditResult } from '@/components/ui/markdown-transforms';\n\n/** Which pane the editor is showing. */\nexport type MarkdownEditorMode = 'write' | 'preview';\n\n/** A formatting action the toolbar can offer. */\nexport type MarkdownEditorAction =\n | 'bold'\n | 'italic'\n | 'heading'\n | 'quote'\n | 'code'\n | 'bulletList'\n | 'orderedList'\n | 'link'\n | 'image';\n\n/** The actions a toolbar shows when it is not told which to show. */\nconst DEFAULT_ACTIONS: MarkdownEditorAction[] = [\n 'bold',\n 'italic',\n 'heading',\n 'bulletList',\n 'orderedList',\n 'quote',\n 'code',\n 'link',\n];\n\n/**\n * The same actions in the capsule, grouped.\n *\n * Eight rather than nine: the capsule is a fixed row of targets with hairlines\n * between its groups, and `quote` is the one a writer reaches for least. It is\n * still available — pass `actions` and it appears in whatever grouping the\n * families below produce.\n */\nconst DEFAULT_PILL_ACTIONS: MarkdownEditorAction[] = [\n 'bold',\n 'italic',\n 'heading',\n 'link',\n 'image',\n 'orderedList',\n 'bulletList',\n 'code',\n];\n\n/**\n * Which family each action belongs to, which is what the capsule's hairlines\n * separate: what the words look like, what is being put into the document, and\n * what shape the block is.\n */\nconst FAMILY: Record = {\n bold: 'inline',\n italic: 'inline',\n heading: 'inline',\n link: 'insert',\n image: 'insert',\n orderedList: 'insert',\n bulletList: 'insert',\n code: 'block',\n quote: 'block',\n};\n\n/**\n * What each action does, what it is called, and what it looks like.\n *\n * One table rather than a switch in the toolbar and another in the context:\n * adding an action should be adding a row here, and nowhere else.\n */\nconst ACTIONS: Record<\n MarkdownEditorAction,\n {\n label: string;\n icon: (size: number) => ReactNode;\n apply: (text: string, selection: EditorSelection) => EditResult;\n /**\n * Whether this action is already in effect where the caret is.\n *\n * The toolbar marks those buttons, which is what makes \"pressing twice\n * undoes it\" visible rather than something the writer has to discover. An\n * action that only ever inserts — a link, an image — has no such state and\n * says so by leaving this out.\n */\n isActive?: (text: string, selection: EditorSelection) => boolean;\n }\n> = {\n bold: {\n label: 'Bold',\n icon: (size) => ,\n apply: (text, selection) => toggleWrap(text, selection, '**'),\n isActive: (text, selection) => hasWrap(text, selection, '**'),\n },\n italic: {\n label: 'Italic',\n icon: (size) => ,\n apply: (text, selection) => toggleWrap(text, selection, '_'),\n isActive: (text, selection) => hasWrap(text, selection, '_'),\n },\n heading: {\n label: 'Heading',\n icon: (size) => ,\n apply: (text, selection) => toggleLinePrefix(text, selection, '## '),\n isActive: (text, selection) => hasLinePrefix(text, selection, '## '),\n },\n quote: {\n label: 'Quote',\n icon: (size) => ,\n apply: (text, selection) => toggleLinePrefix(text, selection, '> '),\n isActive: (text, selection) => hasLinePrefix(text, selection, '> '),\n },\n code: {\n label: 'Code block',\n icon: (size) => ,\n apply: toggleFence,\n isActive: hasFence,\n },\n bulletList: {\n label: 'Bulleted list',\n icon: (size) => ,\n apply: (text, selection) => toggleLinePrefix(text, selection, '- '),\n isActive: (text, selection) => hasLinePrefix(text, selection, '- '),\n },\n orderedList: {\n label: 'Numbered list',\n icon: (size) => ,\n apply: toggleOrderedList,\n isActive: hasOrderedList,\n },\n link: {\n label: 'Link',\n icon: (size) => ,\n apply: insertLink,\n },\n image: {\n label: 'Image',\n icon: (size) => ,\n apply: insertImage,\n },\n};\n\nconst markdownEditorVariants = tv({\n slots: {\n root: 'w-full gap-2',\n toolbar: 'flex-row items-center justify-between gap-2',\n /**\n * The capsule holding the formatting buttons in the `pill` toolbar.\n *\n * `shrink` and `overflow-hidden` are load-bearing. Eight 36pt targets, two\n * hairlines and the round button beside them come to more than a phone is\n * wide, and a row that cannot shrink does not wrap — it runs off the edge\n * of the screen taking the last two actions with it. The capsule gives up\n * width instead, and scrolls whatever no longer fits.\n */\n capsule:\n 'min-w-0 shrink flex-row items-center overflow-hidden rounded-full border border-input bg-popover px-1.5 shadow-lg',\n /** One hairline between two groups of them. */\n divider: 'mx-1 h-5 w-px bg-border',\n /** The round button outside the capsule, which switches the pane. */\n escape:\n 'h-11 w-11 shrink-0 items-center justify-center rounded-full border border-input bg-popover shadow-lg',\n // The preview stands in the field's place, so it takes the field's shape.\n // A rendered draft that sat on the page with no edge around it would not\n // read as the same object the writing was in.\n preview: 'w-full rounded-xl border border-input bg-popover px-3 py-2.5',\n empty: 'py-6 text-center',\n },\n variants: {\n /**\n * How the toolbar is drawn.\n *\n * `bar` is the row: formatting on one side, the write/preview switch on the\n * other, and room between them for a word count or a submit. `pill` is the\n * floating capsule — icon-only, grouped by hairlines, with the pane switch\n * as a round button beside it. The capsule has no room for anything else,\n * so a toolbar with children wants the bar.\n */\n variant: {\n bar: {},\n pill: { toolbar: 'justify-center gap-2' },\n },\n },\n defaultVariants: { variant: 'bar' },\n});\n\nexport type MarkdownEditorToolbarVariant = 'bar' | 'pill';\n\ninterface MarkdownEditorContextValue {\n value: string;\n mode: MarkdownEditorMode;\n setMode: (mode: MarkdownEditorMode) => void;\n /** Runs a transform against the live text and selection. */\n run: (action: MarkdownEditorAction) => void;\n /**\n * Which actions are already in effect where the caret is. Recomputed when\n * the caret moves rather than on every keystroke — the toolbar is the only\n * thing that reads it, and it has eight buttons.\n */\n active: ReadonlySet;\n /** What the field should be showing as selected, once, after an edit. */\n pending: EditorSelection | undefined;\n setSelection: (selection: EditorSelection) => void;\n clearPending: () => void;\n onChangeText: (next: string) => void;\n /** Registers the field, so the root's ref can focus it. */\n registerField: (field: TextInput | null) => void;\n disabled: boolean;\n}\n\nconst MarkdownEditorContext = createContext(null);\n\nfunction useMarkdownEditor(part: string): MarkdownEditorContextValue {\n const ctx = useContext(MarkdownEditorContext);\n if (!ctx) throw new Error(`${part} must be used inside .`);\n return ctx;\n}\n\n/**\n * What a `ref` on the editor gives you.\n *\n * The same transforms the toolbar runs, so a caller drawing their own controls\n * — a keyboard accessory, a context menu, a single Bold button somewhere else\n * on the screen — does not have to reimplement any of them. Every one applies\n * to the live selection and leaves the caret where the toolbar would.\n */\nexport interface MarkdownEditorHandle {\n /** The container, for measuring and for anything a `View` ref is good for. */\n readonly view: View | null;\n /** Runs one of the toolbar's actions against the current selection. */\n apply: (action: MarkdownEditorAction) => void;\n /** Which actions are already in effect where the caret is. */\n getActive: () => MarkdownEditorAction[];\n /** Where the caret is, or what is selected. */\n getSelection: () => EditorSelection;\n /** Moves the caret, or selects a range. */\n setSelection: (selection: EditorSelection) => void;\n /** Puts the caret in the field. Switches to the writing pane if it is not up. */\n focus: () => void;\n blur: () => void;\n /** Switches pane. */\n setMode: (mode: MarkdownEditorMode) => void;\n}\n\nexport interface MarkdownEditorProps extends Omit {\n className?: string;\n /** Controlled text. Leave unset and pass `defaultValue` to run uncontrolled. */\n value?: string;\n /** Starting text when uncontrolled. */\n defaultValue?: string;\n onValueChange?: (value: string) => void;\n /** Controlled pane. */\n mode?: MarkdownEditorMode;\n /** Starting pane when uncontrolled. */\n defaultMode?: MarkdownEditorMode;\n onModeChange?: (mode: MarkdownEditorMode) => void;\n /**\n * Stop the field being edited and the formatting actions being pressed. The\n * switch between the panes stays live: a draft nobody may edit is still a\n * draft somebody may want to read rendered.\n */\n disabled?: boolean;\n /**\n * The parts, in the order they should stack. Left out, the editor draws its\n * toolbar, its field and its preview in that order — which is the whole\n * component, and the reason it usually needs no children.\n */\n children?: ReactNode;\n /** Forwarded to the field when the editor draws its own. */\n placeholder?: string;\n /** Height of the field, in lines. Forwarded to the field the editor draws. */\n rows?: number;\n /**\n * Lift the editor above the keyboard. Forwarded to the field the editor\n * draws, and off by default because it changes which component renders the\n * container — so it cannot be toggled at runtime without remounting the\n * field and dropping focus.\n */\n avoidKeyboard?: boolean;\n /**\n * Continue a list when Return is pressed inside one, and end it when Return\n * is pressed on an item with nothing in it. On by default: a list that stops\n * numbering itself after the first item is a list the writer finishes by\n * hand.\n */\n continueLists?: boolean;\n}\n\nconst MarkdownEditorRoot = forwardRef(\n (\n {\n className,\n value: valueProp,\n defaultValue = '',\n onValueChange,\n mode: modeProp,\n defaultMode = 'write',\n onModeChange,\n disabled = false,\n children,\n placeholder,\n rows,\n avoidKeyboard,\n continueLists = true,\n ...props\n },\n ref\n ) => {\n const slots = markdownEditorVariants();\n const viewRef = useRef(null);\n const fieldRef = useRef(null);\n\n const [internalValue, setInternalValue] = useState(defaultValue);\n const value = valueProp ?? internalValue;\n\n const [internalMode, setInternalMode] = useState(defaultMode);\n const mode = modeProp ?? internalMode;\n\n /*\n * The live selection is a ref rather than state.\n *\n * It changes on every caret move — every keystroke, every tap in the text —\n * and nothing on screen depends on it. Holding it in state would re-render\n * the field, and therefore the preview under it, once per character typed.\n */\n const selectionRef = useRef({ start: 0, end: 0 });\n\n /*\n * What the field should show as selected after a toolbar edit, held for\n * exactly one render.\n *\n * A `selection` prop that stays set is a field the writer cannot move the\n * caret in — every tap would be pulled back to wherever the last edit put\n * it. So it is set once, applied, and cleared by the very selection change\n * it causes.\n */\n const [pending, setPending] = useState(undefined);\n\n const valueRef = useRef(value);\n valueRef.current = value;\n\n const commit = useCallback(\n (next: string) => {\n if (valueProp === undefined) setInternalValue(next);\n onValueChange?.(next);\n },\n [valueProp, onValueChange]\n );\n\n const setMode = useCallback(\n (next: MarkdownEditorMode) => {\n if (modeProp === undefined) setInternalMode(next);\n onModeChange?.(next);\n },\n [modeProp, onModeChange]\n );\n\n /*\n * Which actions are already in effect, held in state because the toolbar\n * draws from it.\n *\n * Recomputed when the caret moves or the text changes through an edit, not\n * on every keystroke: eight predicates over the whole document on every\n * character typed is work nobody asked for, and typing a letter cannot\n * change what is applied at the caret. Toggling a marker can, which is why\n * `run` recomputes it too.\n */\n const [active, setActive] = useState>(\n () => new Set()\n );\n\n const readActive = useCallback((text: string, selection: EditorSelection) => {\n const next = new Set();\n for (const key of Object.keys(ACTIONS) as MarkdownEditorAction[]) {\n if (ACTIONS[key].isActive?.(text, selection)) next.add(key);\n }\n setActive((current) =>\n current.size === next.size && [...next].every((key) => current.has(key))\n ? current\n : next\n );\n }, []);\n\n const run = useCallback(\n (action: MarkdownEditorAction) => {\n const result = ACTIONS[action].apply(valueRef.current, selectionRef.current);\n // Kept locally too: the parent may be slow to hand the new text back,\n // and the next press must not be computed against the old string.\n valueRef.current = result.text;\n selectionRef.current = result.selection;\n commit(result.text);\n setPending(result.selection);\n readActive(result.text, result.selection);\n },\n [commit, readActive]\n );\n\n /*\n * A list continues itself when Return lands inside one.\n *\n * Read off the text change rather than off `onKeyPress`, because a key\n * event here cannot be prevented — the field applies the newline whatever\n * the handler does, so acting on the key would insert the marker *and* a\n * second line break. Watching the change instead means the newline is\n * already in the string, and what happens is a rewrite of it: the marker\n * goes in behind it, or, on an item with nothing in it, the marker comes\n * off and the list ends.\n *\n * One inserted character only. A paste that happens to contain a newline\n * is not somebody pressing Return.\n */\n const applyChange = useCallback(\n (next: string) => {\n const previous = valueRef.current;\n\n if (continueLists && next.length === previous.length + 1) {\n let at = 0;\n while (at < previous.length && previous[at] === next[at]) at += 1;\n\n if (next[at] === '\\n') {\n const result = continueList(previous, { start: at, end: at });\n if (result) {\n valueRef.current = result.text;\n selectionRef.current = result.selection;\n commit(result.text);\n setPending(result.selection);\n readActive(result.text, result.selection);\n return;\n }\n }\n }\n\n valueRef.current = next;\n commit(next);\n },\n [continueLists, commit, readActive]\n );\n\n const registerField = useCallback((field: TextInput | null) => {\n fieldRef.current = field;\n }, []);\n\n useImperativeHandle(\n ref,\n () => ({\n get view() {\n return viewRef.current;\n },\n apply: run,\n getActive: () => [...active],\n getSelection: () => ({ ...selectionRef.current }),\n setSelection: (selection) => {\n selectionRef.current = selection;\n setPending(selection);\n readActive(valueRef.current, selection);\n },\n focus: () => {\n // The field is unmounted while the preview is up, so focusing it has\n // to bring it back first — otherwise this is a silent no-op that\n // looks like a broken ref.\n setMode('write');\n fieldRef.current?.focus();\n },\n blur: () => fieldRef.current?.blur(),\n setMode,\n }),\n [run, active, readActive, setMode]\n );\n\n const context = useMemo(\n () => ({\n value,\n mode,\n setMode,\n run,\n active,\n pending,\n setSelection: (selection) => {\n selectionRef.current = selection;\n readActive(valueRef.current, selection);\n },\n clearPending: () => setPending(undefined),\n onChangeText: applyChange,\n registerField,\n disabled,\n }),\n [value, mode, setMode, run, active, pending, applyChange, readActive, registerField, disabled]\n );\n\n return (\n \n \n {children ?? (\n <>\n \n \n \n \n )}\n \n \n );\n }\n);\n\nMarkdownEditorRoot.displayName = 'MarkdownEditor';\n\n/* -------------------------------------------------------------------------- *\n * Toolbar\n * -------------------------------------------------------------------------- */\n\nexport interface MarkdownEditorToolbarProps extends Omit {\n className?: string;\n /**\n * How the toolbar is drawn. `bar` is the full-width row, with room on it for\n * a word count or a submit. `pill` is the floating capsule: icon-only,\n * grouped by hairlines, with the pane switch as a round button beside it.\n */\n variant?: MarkdownEditorToolbarVariant;\n /**\n * Which formatting actions to offer, in the order given. The capsule groups\n * whatever it is given by family — what the words look like, what is being\n * put into the document, what shape the block is — and draws a hairline\n * where the family changes.\n */\n actions?: MarkdownEditorAction[];\n /**\n * Show the write/preview switch. On by default — a preview nobody can reach\n * is a pane that does not exist.\n */\n showModeSwitch?: boolean;\n /**\n * Anything else to put on the row: a word count, a save state, a submit.\n * The capsule has no room for these, so a toolbar with children wants `bar`.\n */\n children?: ReactNode;\n}\n\n/**\n * The formatting actions, and the switch between writing and reading.\n *\n * The formatting run is hidden rather than disabled while previewing. Its\n * buttons act on a selection, and in the preview there is no selection for them\n * to act on — a row of controls that are present but inert is a row that has to\n * be tried before it can be understood.\n *\n * An action already in effect where the caret is draws as pressed. Every button\n * here is a toggle, and a toggle that looks the same in both states is a toggle\n * nobody discovers is one.\n */\nconst MarkdownEditorToolbar = forwardRef(\n (\n {\n className,\n variant = 'bar',\n actions,\n showModeSwitch = true,\n children,\n ...props\n },\n ref\n ) => {\n const { mode, setMode, run, active, disabled } = useMarkdownEditor(\n 'MarkdownEditor.Toolbar'\n );\n const slots = markdownEditorVariants({ variant });\n const pill = variant === 'pill';\n const shown = actions ?? (pill ? DEFAULT_PILL_ACTIONS : DEFAULT_ACTIONS);\n\n const button = (action: MarkdownEditorAction, size: number) => (\n run(action)}\n className={pill ? 'h-9 w-9 rounded-full px-0' : 'px-2'}\n >\n {ACTIONS[action].icon(size)}\n \n );\n\n if (pill) {\n return (\n \n {mode === 'write' ? (\n \n \n {shown.map((action, index) => (\n \n {/* A hairline wherever the family changes, and nowhere\n else: a divider between every pair is eight dividers,\n which is a row of eight separate things rather than\n three groups. */}\n {index > 0 && FAMILY[action] !== FAMILY[shown[index - 1]!] ? (\n \n ) : null}\n {button(action, 18)}\n \n ))}\n \n \n ) : null}\n\n {showModeSwitch ? (\n /* Outside the capsule, and round rather than a segment of it. The\n capsule is what the writing is done with; this is the way out of\n it, and a control that leaves somewhere should not look like one\n of the things you do while you are there. */\n setMode(mode === 'write' ? 'preview' : 'write')}\n >\n {mode === 'write' ? : }\n \n ) : null}\n \n );\n }\n\n return (\n \n {mode === 'write' ? (\n \n {shown.map((action) => button(action, 16))}\n \n ) : (\n \n )}\n\n \n {children}\n {showModeSwitch ? (\n \n }\n onPress={() => setMode('write')}\n >\n Write\n \n }\n onPress={() => setMode('preview')}\n >\n Preview\n \n \n ) : null}\n \n \n );\n }\n);\n\nMarkdownEditorToolbar.displayName = 'MarkdownEditor.Toolbar';\n\n/* -------------------------------------------------------------------------- *\n * Input\n * -------------------------------------------------------------------------- */\n\nexport interface MarkdownEditorInputProps\n extends Omit {\n className?: string;\n}\n\n/**\n * The field the markdown is written in — a `Textarea`, tracking its selection.\n *\n * Renders nothing while the preview is up. Unmounted rather than hidden: a\n * field kept alive behind the preview keeps the keyboard up with it.\n */\nconst MarkdownEditorInput = forwardRef(\n ({ className, rows = 8, ...props }, ref) => {\n const {\n value,\n mode,\n pending,\n setSelection,\n clearPending,\n onChangeText,\n registerField,\n disabled,\n } = useMarkdownEditor('MarkdownEditor.Input');\n\n /*\n * The field is registered so the editor's own ref can focus it, and\n * forwarded so a caller's ref still works. Both, rather than either.\n */\n const attach = useCallback(\n (field: TextInput | null) => {\n registerField(field);\n if (typeof ref === 'function') ref(field);\n else if (ref) ref.current = field;\n },\n [ref, registerField]\n );\n\n /*\n * The forced selection is cleared only once it has actually arrived.\n *\n * Clearing on the first selection change of any kind was the bug: the new\n * `value` landing produces one of its own, and it fires first — so the\n * caret was released before it had been put anywhere, and ended up at the\n * end of the document instead of between the markers just inserted.\n */\n const handleSelectionChange = useCallback(\n (event: NativeSyntheticEvent) => {\n const { selection } = event.nativeEvent;\n setSelection(selection);\n if (\n pending &&\n selection.start === pending.start &&\n selection.end === pending.end\n ) {\n clearPending();\n }\n },\n [setSelection, clearPending, pending]\n );\n\n if (mode !== 'write') return null;\n\n return (\n \n );\n }\n);\n\nMarkdownEditorInput.displayName = 'MarkdownEditor.Input';\n\n/* -------------------------------------------------------------------------- *\n * Preview\n * -------------------------------------------------------------------------- */\n\nexport interface MarkdownEditorPreviewProps extends Omit {\n className?: string;\n /** What to show when there is nothing written yet. */\n emptyText?: string;\n}\n\n/**\n * The draft, rendered.\n *\n * Renders nothing while the field is up, which is what makes the two one pane\n * rather than two.\n */\nconst MarkdownEditorPreview = forwardRef(\n ({ className, emptyText = 'Nothing to preview yet.', ...props }, ref) => {\n const { value, mode } = useMarkdownEditor('MarkdownEditor.Preview');\n const slots = markdownEditorVariants();\n\n if (mode !== 'preview') return null;\n\n return (\n \n {value.trim() ? (\n {value}\n ) : (\n \n {emptyText}\n \n )}\n \n );\n }\n);\n\nMarkdownEditorPreview.displayName = 'MarkdownEditor.Preview';\n\nexport const MarkdownEditor = Object.assign(MarkdownEditorRoot, {\n Toolbar: MarkdownEditorToolbar,\n Input: MarkdownEditorInput,\n Preview: MarkdownEditorPreview,\n});\n" }, { "path": "ui/markdown-transforms.ts", diff --git a/apps/docs/public/r/marker.json b/apps/docs/public/r/marker.json index cb733cc51..9da7ec175 100644 --- a/apps/docs/public/r/marker.json +++ b/apps/docs/public/r/marker.json @@ -6,6 +6,7 @@ "animated-pressable", "cn", "icons", + "refs", "separator", "shimmer", "text" @@ -19,7 +20,7 @@ { "path": "ui/marker.tsx", "type": "registry:ui", - "content": "/**\n * Marker — an inline note in a conversation.\n *\n * Transcripts are not only messages. Between the turns sit things the\n * conversation did rather than said: a tool that ran, a file that was read, a\n * model that changed, the point where yesterday ended. Those are not messages\n * and should not look like one — a bubble implies a speaker, and none of these\n * have one. A marker is the quieter row that carries them: small, muted, and\n * aligned to the transcript rather than to a side of it.\n *\n * ```tsx\n * \n * \n * Explored 4 files\n * \n * ```\n *\n * `shimmer` on the content is for the row that is still happening — the sweep\n * runs through the glyphs while a step is in flight, and is dropped the moment\n * it finishes.\n */\nimport { createContext, forwardRef, useContext, type ReactNode } from 'react';\nimport { View, type Text as RNText, type ViewProps } from 'react-native';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport { useCSSVariable } from 'uniwind';\nimport { IconColorProvider } from '@/components/ui/icons';\nimport { AnimatedPressable, type AnimatedPressableProps } from '@/components/ui/animated-pressable';\nimport { Text, type TextProps, textChildren } from '@/components/ui/text';\nimport { cn } from '@/lib/cn';\nimport { Separator } from '@/components/ui/separator';\nimport { Shimmer } from '@/components/ui/shimmer';\n\ntype MarkerVariant = 'default' | 'border' | 'separator';\n\nconst markerVariants = tv({\n slots: {\n root: 'w-full flex-row items-center gap-2 py-1.5',\n content: 'text-sm text-muted-foreground',\n },\n variants: {\n variant: {\n /** The plain row: an icon and a line of muted text. */\n default: {},\n /** Same row, closed by a hairline — for a step that ends a section. */\n border: { root: 'border-b border-border pb-2.5' },\n /**\n * A centred label with a rule running out to each edge. The rules are\n * `Separator`s rather than borders so they follow the same token and\n * thickness as every other rule in the app.\n */\n separator: { root: 'gap-3 py-3', content: 'text-xs uppercase tracking-wider' },\n },\n disabled: {\n true: { root: 'opacity-[0.64]' },\n },\n },\n defaultVariants: {\n variant: 'default',\n },\n});\n\n/** The parts read the variant rather than being told it twice. */\nconst MarkerContext = createContext('default');\n\nexport interface MarkerProps\n extends Omit,\n Omit, 'disabled'> {\n className?: string;\n disabled?: boolean;\n /**\n * `default` is the inline status row. `border` closes it with a hairline.\n * `separator` centres the content between two rules — the \"Yesterday\"\n * divider shape.\n */\n variant?: MarkerVariant;\n children?: ReactNode;\n}\n\n/**\n * Renders as a pressable when given `onPress` and as a plain view otherwise,\n * so a marker that does nothing does not announce itself as a button.\n */\nconst MarkerRoot = forwardRef(\n ({ className, variant = 'default', disabled, children, onPress, ...props }, ref) => {\n const { root } = markerVariants({ variant, disabled: !!disabled });\n\n // The rules belong to the root, not the content: the content has to stay a\n // single centred child for `flex-1` on either side to split the remainder.\n const inner =\n variant === 'separator' ? (\n <>\n \n {textChildren(children)}\n \n \n ) : (\n children\n );\n\n const body = !onPress ? (\n \n {inner}\n \n ) : (\n \n {inner}\n \n );\n\n return {body};\n }\n);\nMarkerRoot.displayName = 'Marker';\n\nexport interface MarkerIconProps extends ViewProps {\n className?: string;\n children?: ReactNode;\n}\n\n/**\n * Decorative leading slot. Hidden from screen readers — the icon repeats what\n * the content already says, and announcing it twice is noise.\n */\nconst MarkerIcon = forwardRef(\n ({ className, children, ...props }, ref) => {\n // Icons here take the muted foreground, so a marker reads as chrome next to\n // a message rather than competing with it.\n const muted = useCSSVariable('--color-muted-foreground');\n\n return (\n \n \n {textChildren(children)}\n \n \n );\n }\n);\nMarkerIcon.displayName = 'Marker.Icon';\n\nexport interface MarkerContentProps extends TextProps {\n className?: string;\n /**\n * Sweep a highlight through the text, for a step that is still running.\n * Drop it when the step finishes — a marker that shimmers forever reads as\n * a stuck one.\n */\n shimmer?: boolean;\n children?: ReactNode;\n}\n\nconst MarkerContent = forwardRef(\n ({ className, shimmer, children, ...props }, ref) => {\n const variant = useContext(MarkerContext);\n const { content } = markerVariants({ variant });\n\n if (shimmer) {\n // Shimmer owns the text so it can mask the sweep to the glyphs; the\n // class goes to the text inside it rather than to the wrapping view.\n return {children};\n }\n\n return (\n \n {children}\n \n );\n }\n);\nMarkerContent.displayName = 'Marker.Content';\n\nexport const Marker = Object.assign(MarkerRoot, {\n Icon: MarkerIcon,\n Content: MarkerContent,\n});\n" + "content": "/**\n * Marker — an inline note in a conversation.\n *\n * Transcripts are not only messages. Between the turns sit things the\n * conversation did rather than said: a tool that ran, a file that was read, a\n * model that changed, the point where yesterday ended. Those are not messages\n * and should not look like one — a bubble implies a speaker, and none of these\n * have one. A marker is the quieter row that carries them: small, muted, and\n * aligned to the transcript rather than to a side of it.\n *\n * ```tsx\n * \n * \n * Explored 4 files\n * \n * ```\n *\n * `shimmer` on the content is for the row that is still happening — the sweep\n * runs through the glyphs while a step is in flight, and is dropped the moment\n * it finishes.\n */\nimport { createContext, forwardRef, useContext, type ReactNode } from 'react';\nimport { View, type Text as RNText, type ViewProps } from 'react-native';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport { useCSSVariable } from 'uniwind';\nimport { IconColorProvider } from '@/components/ui/icons';\nimport { AnimatedPressable, type AnimatedPressableProps } from '@/components/ui/animated-pressable';\nimport { Text, type TextProps, textChildren } from '@/components/ui/text';\nimport { cn } from '@/lib/cn';\nimport { Separator } from '@/components/ui/separator';\nimport { Shimmer } from '@/components/ui/shimmer';\nimport type { TextRef, ViewRef } from '@/components/ui/refs';\n\ntype MarkerVariant = 'default' | 'border' | 'separator';\n\nconst markerVariants = tv({\n slots: {\n root: 'w-full flex-row items-center gap-2 py-1.5',\n content: 'text-sm text-muted-foreground',\n },\n variants: {\n variant: {\n /** The plain row: an icon and a line of muted text. */\n default: {},\n /** Same row, closed by a hairline — for a step that ends a section. */\n border: { root: 'border-b border-border pb-2.5' },\n /**\n * A centred label with a rule running out to each edge. The rules are\n * `Separator`s rather than borders so they follow the same token and\n * thickness as every other rule in the app.\n */\n separator: { root: 'gap-3 py-3', content: 'text-xs uppercase tracking-wider' },\n },\n disabled: {\n true: { root: 'opacity-[0.64]' },\n },\n },\n defaultVariants: {\n variant: 'default',\n },\n});\n\n/** The parts read the variant rather than being told it twice. */\nconst MarkerContext = createContext('default');\n\nexport interface MarkerProps\n extends Omit,\n Omit, 'disabled'> {\n className?: string;\n disabled?: boolean;\n /**\n * `default` is the inline status row. `border` closes it with a hairline.\n * `separator` centres the content between two rules — the \"Yesterday\"\n * divider shape.\n */\n variant?: MarkerVariant;\n children?: ReactNode;\n}\n\n/**\n * Renders as a pressable when given `onPress` and as a plain view otherwise,\n * so a marker that does nothing does not announce itself as a button.\n */\nconst MarkerRoot = forwardRef(\n ({ className, variant = 'default', disabled, children, onPress, ...props }, ref) => {\n const { root } = markerVariants({ variant, disabled: !!disabled });\n\n // The rules belong to the root, not the content: the content has to stay a\n // single centred child for `flex-1` on either side to split the remainder.\n const inner =\n variant === 'separator' ? (\n <>\n \n {textChildren(children)}\n \n \n ) : (\n children\n );\n\n const body = !onPress ? (\n \n {inner}\n \n ) : (\n \n {inner}\n \n );\n\n return {body};\n }\n);\nMarkerRoot.displayName = 'Marker';\n\nexport interface MarkerIconProps extends ViewProps {\n className?: string;\n children?: ReactNode;\n}\n\n/**\n * Decorative leading slot. Hidden from screen readers — the icon repeats what\n * the content already says, and announcing it twice is noise.\n */\nconst MarkerIcon = forwardRef(\n ({ className, children, ...props }, ref) => {\n // Icons here take the muted foreground, so a marker reads as chrome next to\n // a message rather than competing with it.\n const muted = useCSSVariable('--color-muted-foreground');\n\n return (\n \n \n {textChildren(children)}\n \n \n );\n }\n);\nMarkerIcon.displayName = 'Marker.Icon';\n\nexport interface MarkerContentProps extends TextProps {\n className?: string;\n /**\n * Sweep a highlight through the text, for a step that is still running.\n * Drop it when the step finishes — a marker that shimmers forever reads as\n * a stuck one.\n */\n shimmer?: boolean;\n children?: ReactNode;\n}\n\nconst MarkerContent = forwardRef(\n ({ className, shimmer, children, ...props }, ref) => {\n const variant = useContext(MarkerContext);\n const { content } = markerVariants({ variant });\n\n if (shimmer) {\n // Shimmer owns the text so it can mask the sweep to the glyphs; the\n // class goes to the text inside it rather than to the wrapping view.\n return {children};\n }\n\n return (\n \n {children}\n \n );\n }\n);\nMarkerContent.displayName = 'Marker.Content';\n\nexport const Marker = Object.assign(MarkerRoot, {\n Icon: MarkerIcon,\n Content: MarkerContent,\n});\n" } ] } diff --git a/apps/docs/public/r/message.json b/apps/docs/public/r/message.json index 50f4b9fd6..16e05a949 100644 --- a/apps/docs/public/r/message.json +++ b/apps/docs/public/r/message.json @@ -6,6 +6,7 @@ "animated-pressable", "cn", "icons", + "refs", "text" ], "dependencies": [ @@ -17,7 +18,7 @@ { "path": "ui/message.tsx", "type": "registry:ui", - "content": "/**\n * Message — a chat turn: avatar, bubble, and the metadata around it.\n *\n * The pieces are separate because conversations differ in which ones they\n * need. A support thread wants a sender name and a read receipt; an assistant\n * transcript wants neither, but does want the bubble to carry tool output and\n * actions. Composing them beats a component with a dozen optional props.\n *\n * `align` is the only thing the root decides, and everything downstream reads\n * it from context: which side the row sits on, which way the avatar goes,\n * which corner of the bubble is squared off, and which colour it takes.\n *\n * ```tsx\n * \n * \n * \n * How can I help?\n * \n * \n * \n * ```\n */\nimport {\n Children,\n cloneElement,\n createContext,\n forwardRef,\n isValidElement,\n useContext,\n type ReactNode,\n} from 'react';\nimport { View, type GestureResponderEvent, type ViewProps } from 'react-native';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport { useCSSVariable } from 'uniwind';\nimport { IconColorProvider } from '@/components/ui/icons';\nimport { AnimatedPressable } from '@/components/ui/animated-pressable';\nimport { Text, type TextProps, textChildren } from '@/components/ui/text';\nimport { cn } from '@/lib/cn';\n\nconst messageVariants = tv({\n slots: {\n root: 'w-full flex-row items-end gap-2',\n avatar: 'shrink-0',\n content: 'max-w-[85%] gap-1',\n header: 'text-xs font-medium text-muted-foreground',\n bubble: 'rounded-2xl px-3.5 py-2.5',\n bubbleContent: 'text-base',\n footer: 'text-xs text-muted-foreground',\n group: 'w-full gap-1',\n },\n variants: {\n align: {\n start: {\n root: 'flex-row justify-start',\n content: 'items-start',\n // A squared corner on the side the message comes from points the\n // bubble back at its sender. Logical, so \"the side it comes from\"\n // stays true when the script runs the other way.\n bubble: 'rounded-es-md bg-muted',\n bubbleContent: 'text-foreground',\n header: 'text-start',\n footer: 'text-start',\n },\n end: {\n root: 'flex-row-reverse justify-start',\n content: 'items-end',\n bubble: 'rounded-ee-md bg-primary',\n bubbleContent: 'text-primary-foreground',\n header: 'text-end',\n footer: 'text-end',\n },\n },\n },\n defaultVariants: {\n align: 'start',\n },\n});\n\ntype Align = 'start' | 'end';\n\ninterface MessageContextValue {\n align: Align;\n /** True when the message is not the first in its group. */\n stacked: boolean;\n}\n\nconst MessageContext = createContext({\n align: 'start',\n stacked: false,\n});\n\n/** Reads the alignment set by the nearest Message. */\nfunction useMessageContext() {\n return useContext(MessageContext);\n}\n\nconst GroupContext = createContext<{ align: Align } | null>(null);\n\nexport interface MessageProps\n extends ViewProps,\n VariantProps {\n className?: string;\n /**\n * Which side of the conversation this turn belongs to. `end` is the\n * outgoing side — the person holding the device.\n */\n align?: Align;\n /**\n * Continuation of the message above it: tighter spacing, and the avatar slot\n * is reserved but left empty so bubbles stay aligned. `Message.Group` sets\n * this for you.\n */\n stacked?: boolean;\n /**\n * Fires on a long press anywhere on the turn — the gesture a chat uses to\n * surface per-message actions (copy, reply, react). Open a menu from it; the\n * component only exposes the press. When set, the whole row gains press\n * feedback. A plain tap should still do nothing, so there is no `onPress`.\n */\n onLongPress?: (event: GestureResponderEvent) => void;\n children?: ReactNode;\n}\n\nconst MessageRoot = forwardRef(\n ({ className, align, stacked = false, onLongPress, children, ...props }, ref) => {\n const group = useContext(GroupContext);\n const resolvedAlign = align ?? group?.align ?? 'start';\n const { root } = messageVariants({ align: resolvedAlign });\n\n const body = onLongPress ? (\n // A long-press target, not a button — a tap does nothing, so no role is\n // announced. The feedback matches every other pressable in the library.\n \n {textChildren(children)}\n \n ) : (\n \n {textChildren(children)}\n \n );\n\n return (\n \n {body}\n \n );\n }\n);\nMessageRoot.displayName = 'Message';\n\nexport interface MessageGroupProps extends ViewProps {\n className?: string;\n /** Alignment applied to every Message inside that does not set its own. */\n align?: Align;\n children?: ReactNode;\n}\n\n/**\n * Consecutive turns from one sender. Tightens the spacing between them and\n * marks every message after the first as `stacked`, so only the first shows an\n * avatar and the rest stay aligned with it.\n */\nconst MessageGroup = forwardRef(\n ({ className, align = 'start', children, ...props }, ref) => {\n const { group } = messageVariants({ align });\n\n // Every turn after the first is a continuation, so it drops its avatar and\n // sits tight against the one above. A child that sets `stacked` itself\n // wins — grouping is a default, not a rule.\n const stackedChildren = Children.map(children, (child, index) =>\n isValidElement(child) && child.props.stacked === undefined\n ? cloneElement(child, { stacked: index > 0 })\n : child\n );\n\n return (\n \n \n {stackedChildren}\n \n \n );\n }\n);\nMessageGroup.displayName = 'Message.Group';\n\nexport interface MessageAvatarProps extends ViewProps {\n className?: string;\n children?: ReactNode;\n}\n\n/**\n * Slot for the sender's avatar. On a stacked message the slot is rendered but\n * left empty, which keeps the bubbles in a column instead of letting the\n * second one slide under the first one's avatar.\n */\nconst MessageAvatar = forwardRef(\n ({ className, children, ...props }, ref) => {\n const { stacked } = useMessageContext();\n const { avatar } = messageVariants();\n\n return (\n \n {stacked ? {textChildren(children)} : children}\n \n );\n }\n);\nMessageAvatar.displayName = 'Message.Avatar';\n\nexport interface MessageContentProps extends ViewProps {\n className?: string;\n children?: ReactNode;\n}\n\n/** Column holding the header, bubble and footer. */\nconst MessageContent = forwardRef(\n ({ className, children, ...props }, ref) => {\n const { align } = useMessageContext();\n const { content } = messageVariants({ align });\n\n return (\n \n {textChildren(children)}\n \n );\n }\n);\nMessageContent.displayName = 'Message.Content';\n\nexport interface MessageHeaderProps extends TextProps {\n className?: string;\n}\n\n/** Sender name or timestamp above the bubble. */\nconst MessageHeader = forwardRef, MessageHeaderProps>(\n ({ className, ...props }, ref) => {\n const { align } = useMessageContext();\n const { header } = messageVariants({ align });\n\n return ;\n }\n);\nMessageHeader.displayName = 'Message.Header';\n\nexport interface MessageBubbleProps extends ViewProps {\n className?: string;\n children?: ReactNode;\n}\n\n/** The speech bubble. Takes its colour and squared corner from `align`. */\nconst MessageBubble = forwardRef(\n ({ className, children, ...props }, ref) => {\n const { align } = useMessageContext();\n const { bubble } = messageVariants({ align });\n\n /*\n * A sent bubble is painted in the primary colour, and the theme's text\n * colour is often that same colour — so anything inside it that resolves\n * the foreground for itself draws black on black. Text has always been\n * handled, through `Message.BubbleContent`; an icon or a waveform dropped\n * into a bubble had no way to know. Publishing the bubble's own foreground\n * here fixes all of them at once, the same way Button does for its label.\n */\n const foreground = useCSSVariable(\n align === 'end' ? '--color-primary-foreground' : '--color-foreground'\n );\n\n return (\n \n \n {textChildren(children)}\n \n \n );\n }\n);\nMessageBubble.displayName = 'Message.Bubble';\n\nexport interface MessageBubbleContentProps extends TextProps {\n className?: string;\n}\n\n/** Text inside the bubble, in whichever colour reads against it. */\nconst MessageBubbleContent = forwardRef<\n React.ElementRef,\n MessageBubbleContentProps\n>(({ className, ...props }, ref) => {\n const { align } = useMessageContext();\n const { bubbleContent } = messageVariants({ align });\n\n return ;\n});\nMessageBubbleContent.displayName = 'Message.BubbleContent';\n\nexport interface MessageFooterProps extends TextProps {\n className?: string;\n}\n\n/** Delivery state, timestamp or actions below the bubble. */\nconst MessageFooter = forwardRef, MessageFooterProps>(\n ({ className, ...props }, ref) => {\n const { align } = useMessageContext();\n const { footer } = messageVariants({ align });\n\n return ;\n }\n);\nMessageFooter.displayName = 'Message.Footer';\n\nexport interface MessageActionsProps extends ViewProps {\n className?: string;\n children?: ReactNode;\n}\n\n/** Row of controls under the bubble — copy, retry, feedback. */\nconst MessageActions = forwardRef(\n ({ className, children, ...props }, ref) => {\n const { align } = useMessageContext();\n\n return (\n \n {textChildren(children)}\n \n );\n }\n);\nMessageActions.displayName = 'Message.Actions';\n\nexport const Message = Object.assign(MessageRoot, {\n Group: MessageGroup,\n Avatar: MessageAvatar,\n Content: MessageContent,\n Header: MessageHeader,\n Bubble: MessageBubble,\n BubbleContent: MessageBubbleContent,\n Footer: MessageFooter,\n Actions: MessageActions,\n});\n" + "content": "/**\n * Message — a chat turn: avatar, bubble, and the metadata around it.\n *\n * The pieces are separate because conversations differ in which ones they\n * need. A support thread wants a sender name and a read receipt; an assistant\n * transcript wants neither, but does want the bubble to carry tool output and\n * actions. Composing them beats a component with a dozen optional props.\n *\n * `align` is the only thing the root decides, and everything downstream reads\n * it from context: which side the row sits on, which way the avatar goes,\n * which corner of the bubble is squared off, and which colour it takes.\n *\n * ```tsx\n * \n * \n * \n * How can I help?\n * \n * \n * \n * ```\n */\nimport {\n Children,\n cloneElement,\n createContext,\n forwardRef,\n isValidElement,\n useContext,\n type ReactNode,\n} from 'react';\nimport { View, type GestureResponderEvent, type ViewProps } from 'react-native';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport { useCSSVariable } from 'uniwind';\nimport { IconColorProvider } from '@/components/ui/icons';\nimport { AnimatedPressable } from '@/components/ui/animated-pressable';\nimport { Text, type TextProps, textChildren } from '@/components/ui/text';\nimport { cn } from '@/lib/cn';\nimport type { TextRef, ViewRef } from '@/components/ui/refs';\n\nconst messageVariants = tv({\n slots: {\n root: 'w-full flex-row items-end gap-2',\n avatar: 'shrink-0',\n content: 'max-w-[85%] gap-1',\n header: 'text-xs font-medium text-muted-foreground',\n bubble: 'rounded-2xl px-3.5 py-2.5',\n bubbleContent: 'text-base',\n footer: 'text-xs text-muted-foreground',\n group: 'w-full gap-1',\n },\n variants: {\n align: {\n start: {\n root: 'flex-row justify-start',\n content: 'items-start',\n // A squared corner on the side the message comes from points the\n // bubble back at its sender. Logical, so \"the side it comes from\"\n // stays true when the script runs the other way.\n bubble: 'rounded-es-md bg-muted',\n bubbleContent: 'text-foreground',\n header: 'text-start',\n footer: 'text-start',\n },\n end: {\n root: 'flex-row-reverse justify-start',\n content: 'items-end',\n bubble: 'rounded-ee-md bg-primary',\n bubbleContent: 'text-primary-foreground',\n header: 'text-end',\n footer: 'text-end',\n },\n },\n },\n defaultVariants: {\n align: 'start',\n },\n});\n\ntype Align = 'start' | 'end';\n\ninterface MessageContextValue {\n align: Align;\n /** True when the message is not the first in its group. */\n stacked: boolean;\n}\n\nconst MessageContext = createContext({\n align: 'start',\n stacked: false,\n});\n\n/** Reads the alignment set by the nearest Message. */\nfunction useMessageContext() {\n return useContext(MessageContext);\n}\n\nconst GroupContext = createContext<{ align: Align } | null>(null);\n\nexport interface MessageProps\n extends ViewProps,\n VariantProps {\n className?: string;\n /**\n * Which side of the conversation this turn belongs to. `end` is the\n * outgoing side — the person holding the device.\n */\n align?: Align;\n /**\n * Continuation of the message above it: tighter spacing, and the avatar slot\n * is reserved but left empty so bubbles stay aligned. `Message.Group` sets\n * this for you.\n */\n stacked?: boolean;\n /**\n * Fires on a long press anywhere on the turn — the gesture a chat uses to\n * surface per-message actions (copy, reply, react). Open a menu from it; the\n * component only exposes the press. When set, the whole row gains press\n * feedback. A plain tap should still do nothing, so there is no `onPress`.\n */\n onLongPress?: (event: GestureResponderEvent) => void;\n children?: ReactNode;\n}\n\nconst MessageRoot = forwardRef(\n ({ className, align, stacked = false, onLongPress, children, ...props }, ref) => {\n const group = useContext(GroupContext);\n const resolvedAlign = align ?? group?.align ?? 'start';\n const { root } = messageVariants({ align: resolvedAlign });\n\n const body = onLongPress ? (\n // A long-press target, not a button — a tap does nothing, so no role is\n // announced. The feedback matches every other pressable in the library.\n \n {textChildren(children)}\n \n ) : (\n \n {textChildren(children)}\n \n );\n\n return (\n \n {body}\n \n );\n }\n);\nMessageRoot.displayName = 'Message';\n\nexport interface MessageGroupProps extends ViewProps {\n className?: string;\n /** Alignment applied to every Message inside that does not set its own. */\n align?: Align;\n children?: ReactNode;\n}\n\n/**\n * Consecutive turns from one sender. Tightens the spacing between them and\n * marks every message after the first as `stacked`, so only the first shows an\n * avatar and the rest stay aligned with it.\n */\nconst MessageGroup = forwardRef(\n ({ className, align = 'start', children, ...props }, ref) => {\n const { group } = messageVariants({ align });\n\n // Every turn after the first is a continuation, so it drops its avatar and\n // sits tight against the one above. A child that sets `stacked` itself\n // wins — grouping is a default, not a rule.\n const stackedChildren = Children.map(children, (child, index) =>\n isValidElement(child) && child.props.stacked === undefined\n ? cloneElement(child, { stacked: index > 0 })\n : child\n );\n\n return (\n \n \n {stackedChildren}\n \n \n );\n }\n);\nMessageGroup.displayName = 'Message.Group';\n\nexport interface MessageAvatarProps extends ViewProps {\n className?: string;\n children?: ReactNode;\n}\n\n/**\n * Slot for the sender's avatar. On a stacked message the slot is rendered but\n * left empty, which keeps the bubbles in a column instead of letting the\n * second one slide under the first one's avatar.\n */\nconst MessageAvatar = forwardRef(\n ({ className, children, ...props }, ref) => {\n const { stacked } = useMessageContext();\n const { avatar } = messageVariants();\n\n return (\n \n {stacked ? {textChildren(children)} : children}\n \n );\n }\n);\nMessageAvatar.displayName = 'Message.Avatar';\n\nexport interface MessageContentProps extends ViewProps {\n className?: string;\n children?: ReactNode;\n}\n\n/** Column holding the header, bubble and footer. */\nconst MessageContent = forwardRef(\n ({ className, children, ...props }, ref) => {\n const { align } = useMessageContext();\n const { content } = messageVariants({ align });\n\n return (\n \n {textChildren(children)}\n \n );\n }\n);\nMessageContent.displayName = 'Message.Content';\n\nexport interface MessageHeaderProps extends TextProps {\n className?: string;\n}\n\n/** Sender name or timestamp above the bubble. */\nconst MessageHeader = forwardRef(\n ({ className, ...props }, ref) => {\n const { align } = useMessageContext();\n const { header } = messageVariants({ align });\n\n return ;\n }\n);\nMessageHeader.displayName = 'Message.Header';\n\nexport interface MessageBubbleProps extends ViewProps {\n className?: string;\n children?: ReactNode;\n}\n\n/** The speech bubble. Takes its colour and squared corner from `align`. */\nconst MessageBubble = forwardRef(\n ({ className, children, ...props }, ref) => {\n const { align } = useMessageContext();\n const { bubble } = messageVariants({ align });\n\n /*\n * A sent bubble is painted in the primary colour, and the theme's text\n * colour is often that same colour — so anything inside it that resolves\n * the foreground for itself draws black on black. Text has always been\n * handled, through `Message.BubbleContent`; an icon or a waveform dropped\n * into a bubble had no way to know. Publishing the bubble's own foreground\n * here fixes all of them at once, the same way Button does for its label.\n */\n const foreground = useCSSVariable(\n align === 'end' ? '--color-primary-foreground' : '--color-foreground'\n );\n\n return (\n \n \n {textChildren(children)}\n \n \n );\n }\n);\nMessageBubble.displayName = 'Message.Bubble';\n\nexport interface MessageBubbleContentProps extends TextProps {\n className?: string;\n}\n\n/** Text inside the bubble, in whichever colour reads against it. */\nconst MessageBubbleContent = forwardRef<\n TextRef,\n MessageBubbleContentProps\n>(({ className, ...props }, ref) => {\n const { align } = useMessageContext();\n const { bubbleContent } = messageVariants({ align });\n\n return ;\n});\nMessageBubbleContent.displayName = 'Message.BubbleContent';\n\nexport interface MessageFooterProps extends TextProps {\n className?: string;\n}\n\n/** Delivery state, timestamp or actions below the bubble. */\nconst MessageFooter = forwardRef(\n ({ className, ...props }, ref) => {\n const { align } = useMessageContext();\n const { footer } = messageVariants({ align });\n\n return ;\n }\n);\nMessageFooter.displayName = 'Message.Footer';\n\nexport interface MessageActionsProps extends ViewProps {\n className?: string;\n children?: ReactNode;\n}\n\n/** Row of controls under the bubble — copy, retry, feedback. */\nconst MessageActions = forwardRef(\n ({ className, children, ...props }, ref) => {\n const { align } = useMessageContext();\n\n return (\n \n {textChildren(children)}\n \n );\n }\n);\nMessageActions.displayName = 'Message.Actions';\n\nexport const Message = Object.assign(MessageRoot, {\n Group: MessageGroup,\n Avatar: MessageAvatar,\n Content: MessageContent,\n Header: MessageHeader,\n Bubble: MessageBubble,\n BubbleContent: MessageBubbleContent,\n Footer: MessageFooter,\n Actions: MessageActions,\n});\n" } ] } diff --git a/apps/docs/public/r/meter.json b/apps/docs/public/r/meter.json index 991983a93..4ada281b0 100644 --- a/apps/docs/public/r/meter.json +++ b/apps/docs/public/r/meter.json @@ -3,6 +3,7 @@ "type": "registry:ui", "description": "A measurement on a fixed scale, coloured by where it falls.", "registryDependencies": [ + "refs", "text" ], "dependencies": [ @@ -14,7 +15,7 @@ { "path": "ui/meter.tsx", "type": "registry:ui", - "content": "import { forwardRef, useEffect } from 'react';\nimport { View, type LayoutChangeEvent, type ViewProps } from 'react-native';\nimport Animated, {\n useAnimatedStyle,\n useReducedMotion,\n useSharedValue,\n withSpring,\n withTiming,\n type SharedValue,\n} from 'react-native-reanimated';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport { Text } from '@/components/ui/text';\nimport {\n colorFor,\n litSegments,\n meterSemantics,\n normalizeScale,\n normalizeSegments,\n type MeterColor,\n type MeterThreshold,\n} from '@/components/ui/meter-scale';\n\nconst SPRING = {\n damping: 20,\n stiffness: 180,\n mass: 0.6,\n overshootClamping: true,\n} as const;\n/** Milliseconds for a segment to light or go out. */\nconst SEGMENT_DURATION = 180;\n/** How faint an unlit segment sits. Present, but plainly not counted. */\nconst SEGMENT_UNLIT = 0.22;\n\nconst meterVariants = tv({\n slots: {\n root: 'w-full gap-2',\n header: 'flex-row items-center justify-between gap-2',\n track: 'w-full overflow-hidden rounded-full',\n indicator: 'h-full rounded-full',\n segments: 'w-full flex-row overflow-hidden',\n segment: 'flex-1 rounded-full',\n },\n variants: {\n color: {\n primary: { track: 'bg-primary/16', indicator: 'bg-primary', segment: 'bg-primary' },\n success: { track: 'bg-success/16', indicator: 'bg-success', segment: 'bg-success' },\n warning: { track: 'bg-warning/16', indicator: 'bg-warning', segment: 'bg-warning' },\n destructive: {\n track: 'bg-destructive/16',\n indicator: 'bg-destructive',\n segment: 'bg-destructive',\n },\n info: { track: 'bg-info/16', indicator: 'bg-info', segment: 'bg-info' },\n muted: {\n track: 'bg-muted',\n indicator: 'bg-muted-foreground',\n segment: 'bg-muted-foreground',\n },\n },\n size: {\n sm: { track: 'h-1.5', segments: 'h-1.5 gap-1', segment: 'h-1.5' },\n md: { track: 'h-2', segments: 'h-2 gap-1.5', segment: 'h-2' },\n lg: { track: 'h-3', segments: 'h-3 gap-1.5', segment: 'h-3' },\n },\n },\n defaultVariants: {\n color: 'primary',\n size: 'md',\n },\n});\n\ntype MeterVariantProps = VariantProps;\n\nexport interface MeterProps\n extends Omit,\n MeterVariantProps {\n className?: string;\n /** The measurement. Values outside the scale are clamped to its ends. */\n value: number;\n /**\n * The bottom of the scale — the value at which the bar reads as empty.\n * Defaults to `0`.\n */\n minValue?: number;\n /**\n * The top of the scale — the value at which the bar reads as full. Defaults\n * to `100`, so a bare percentage needs neither bound set.\n */\n maxValue?: number;\n /**\n * What is being measured. Also the accessibility label, and a meter without\n * one announces a number with nothing attached to it — \"seventy-five\n * percent\" of what is not an answer. Pass `accessibilityLabel` instead only\n * where the caption is drawn some other way.\n */\n label?: string;\n /** Draw the value above the track, opposite the label. */\n showValueLabel?: boolean;\n /**\n * Text for the value label. Overrides the formatted value — use it for a\n * word where a number reads worse: `Strong`, `Almost full`.\n *\n * It is spoken whether or not it is drawn, so it can give a screen reader\n * better words than the caption without changing the caption. Pair it with\n * `showValueLabel` to draw it too.\n */\n valueLabel?: string;\n /**\n * How to write the value, through `Intl.NumberFormat`. A `percent` style\n * formats how far up the scale the value sits; every other style formats\n * the value itself, so `{ style: 'unit', unit: 'gigabyte' }` against a\n * `maxValue` of `256` reads `64 GB` rather than a percentage of it. Falls\n * back to a rounded percent when omitted.\n */\n formatOptions?: Intl.NumberFormatOptions;\n /**\n * Points on the scale where the colour changes, each `{ from, color }`. The\n * highest one the reading has reached wins, so the order you list them in\n * does not matter; below all of them the `color` prop applies. Non-finite\n * `from` values are ignored.\n *\n * This is the difference between a meter and a bar: the colour is a\n * judgement about the reading. Which direction is bad is yours to say —\n * thresholds climbing to `destructive` suit a disk filling up, thresholds\n * falling to it suit a battery running down.\n */\n thresholds?: MeterThreshold[];\n /**\n * Draw the scale as this many discrete blocks instead of one continuous\n * bar. Blocks are all or nothing, which is the point of them: four blocks\n * say \"three out of four\" where a bar says \"about seventy percent\", and a\n * password is not seventy percent strong.\n *\n * Fractional counts round down and counts above 100 clamp to 100. Invalid,\n * non-positive, or sub-one counts use the continuous bar. Any value above\n * the floor lights at least one block, so a reading that is not empty never\n * looks it.\n */\n segments?: number;\n /** Extra classes for the fill, or for a lit segment. */\n indicatorClassName?: string;\n /** Extra classes for the label + value row. */\n headerClassName?: string;\n}\n\ninterface SegmentProps {\n index: number;\n /** How many blocks are lit, as a shared value so lighting stays off the JS thread. */\n lit: SharedValue;\n className: string;\n reducedMotion: boolean;\n}\n\n/**\n * One block of a segmented meter. It reads the lit count rather than a boolean\n * so the whole row animates from one shared value, without a render per block.\n */\nfunction Segment({ index, lit, className, reducedMotion }: SegmentProps) {\n const style = useAnimatedStyle(() => {\n const on = lit.value > index ? 1 : SEGMENT_UNLIT;\n return {\n opacity: reducedMotion\n ? on\n : withTiming(on, { duration: SEGMENT_DURATION }),\n };\n });\n\n return ;\n}\n\n/**\n * A measurement on a fixed scale: disk used, battery left, a score, a password's\n * strength. The fill is driven on the UI thread, so a value change costs one\n * render and nothing per frame.\n *\n * A meter is not a progress bar. Progress is a task moving towards an end that\n * finishing is the point of; a meter is a reading that happens to sit where it\n * sits, and may go back down. Reach for\n * [Progress](/docs/components/progress) when something is completing.\n *\n * `thresholds` paint the bar by where the reading falls, and `segments` draws\n * the scale as discrete blocks for readings that are counted rather than\n * measured.\n *\n * The value is read against `minValue` / `maxValue`, which default to 0 and\n * 100 — so a percentage needs neither. Set them and the meter speaks in\n * whatever is being measured: bytes, degrees, points. Nothing has to be\n * converted to a percent on the way in, and the screen reader is told the real\n * scale rather than a derived one.\n *\n * React Native has no meter role, so this announces as a progress bar with its\n * value, range and unit attached. That is the closest the platform offers, and\n * it is why `label` matters: the role will not distinguish a reading from a\n * task, and the name is what does.\n */\nexport const Meter = forwardRef(\n (\n {\n className,\n indicatorClassName,\n headerClassName,\n value,\n minValue = 0,\n maxValue = 100,\n label,\n showValueLabel = false,\n valueLabel,\n formatOptions,\n thresholds,\n segments,\n color,\n size,\n accessibilityLabel,\n ...props\n },\n ref\n ) => {\n const scale = normalizeScale(value, minValue, maxValue);\n const { value: held, fraction } = scale;\n const segmentCount = normalizeSegments(segments);\n const resolvedColor = colorFor(held, color ?? 'primary', thresholds);\n const slots = meterVariants({ color: resolvedColor, size });\n const reducedMotion = useReducedMotion();\n\n const trackWidth = useSharedValue(0);\n const progress = useSharedValue(fraction);\n // Any reading above the floor lights a block, so \"a little\" never looks\n // like \"none\". Rounding down would leave the first quarter of a\n // four-block meter dark, which is the reading it is least able to afford.\n const litCount = litSegments(fraction, segmentCount);\n const lit = useSharedValue(litCount);\n\n // Reduce motion lands on the value instead of springing to it — the\n // number is the information, and the overshoot is decoration.\n useEffect(() => {\n progress.value = reducedMotion ? fraction : withSpring(fraction, SPRING);\n }, [fraction, progress, reducedMotion]);\n\n useEffect(() => {\n lit.value = litCount;\n }, [lit, litCount]);\n\n const onLayout = (event: LayoutChangeEvent) => {\n const width = event.nativeEvent.layout.width;\n trackWidth.value = Number.isFinite(width) && width > 0 ? width : 0;\n };\n\n const fillStyle = useAnimatedStyle(() => ({\n width: trackWidth.value * progress.value,\n }));\n\n const semantics = meterSemantics({\n value: held,\n fraction,\n // The normalized ends, not the raw props: an invalid scale is repaired\n // for the bar, and the spoken reading has to describe the same one.\n minValue: scale.min,\n maxValue: scale.max,\n label,\n accessibilityLabel,\n valueLabel,\n formatOptions,\n });\n const spoken = semantics.text;\n const showValue = showValueLabel;\n const hasHeader = label != null || showValue;\n\n const accessibility = {\n accessibilityRole: 'progressbar' as const,\n accessibilityLabel: semantics.label,\n // `text` carries the unit or word and intentionally overrides the\n // platform's generic rendering of this numeric range.\n accessibilityValue: semantics.value,\n };\n\n const bar =\n segmentCount > 0 ? (\n \n {Array.from({ length: segmentCount }, (_, index) => (\n \n ))}\n \n ) : (\n \n \n \n );\n\n if (!hasHeader) return bar;\n\n return (\n \n \n {label != null ? (\n \n {label}\n \n ) : (\n \n )}\n {showValue ? (\n \n {spoken}\n \n ) : null}\n \n {bar}\n \n );\n }\n);\n\nMeter.displayName = 'Meter';\n\nexport type { MeterColor, MeterThreshold };\n" + "content": "import { forwardRef, useEffect } from 'react';\nimport { View, type LayoutChangeEvent, type ViewProps } from 'react-native';\nimport Animated, {\n useAnimatedStyle,\n useReducedMotion,\n useSharedValue,\n withSpring,\n withTiming,\n type SharedValue,\n} from 'react-native-reanimated';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport { Text } from '@/components/ui/text';\nimport {\n colorFor,\n litSegments,\n meterSemantics,\n normalizeScale,\n normalizeSegments,\n type MeterColor,\n type MeterThreshold,\n} from '@/components/ui/meter-scale';\nimport type { ViewRef } from '@/components/ui/refs';\n\nconst SPRING = {\n damping: 20,\n stiffness: 180,\n mass: 0.6,\n overshootClamping: true,\n} as const;\n/** Milliseconds for a segment to light or go out. */\nconst SEGMENT_DURATION = 180;\n/** How faint an unlit segment sits. Present, but plainly not counted. */\nconst SEGMENT_UNLIT = 0.22;\n\nconst meterVariants = tv({\n slots: {\n root: 'w-full gap-2',\n header: 'flex-row items-center justify-between gap-2',\n track: 'w-full overflow-hidden rounded-full',\n indicator: 'h-full rounded-full',\n segments: 'w-full flex-row overflow-hidden',\n segment: 'flex-1 rounded-full',\n },\n variants: {\n color: {\n primary: { track: 'bg-primary/16', indicator: 'bg-primary', segment: 'bg-primary' },\n success: { track: 'bg-success/16', indicator: 'bg-success', segment: 'bg-success' },\n warning: { track: 'bg-warning/16', indicator: 'bg-warning', segment: 'bg-warning' },\n destructive: {\n track: 'bg-destructive/16',\n indicator: 'bg-destructive',\n segment: 'bg-destructive',\n },\n info: { track: 'bg-info/16', indicator: 'bg-info', segment: 'bg-info' },\n muted: {\n track: 'bg-muted',\n indicator: 'bg-muted-foreground',\n segment: 'bg-muted-foreground',\n },\n },\n size: {\n sm: { track: 'h-1.5', segments: 'h-1.5 gap-1', segment: 'h-1.5' },\n md: { track: 'h-2', segments: 'h-2 gap-1.5', segment: 'h-2' },\n lg: { track: 'h-3', segments: 'h-3 gap-1.5', segment: 'h-3' },\n },\n },\n defaultVariants: {\n color: 'primary',\n size: 'md',\n },\n});\n\ntype MeterVariantProps = VariantProps;\n\nexport interface MeterProps\n extends Omit,\n MeterVariantProps {\n className?: string;\n /** The measurement. Values outside the scale are clamped to its ends. */\n value: number;\n /**\n * The bottom of the scale — the value at which the bar reads as empty.\n * Defaults to `0`.\n */\n minValue?: number;\n /**\n * The top of the scale — the value at which the bar reads as full. Defaults\n * to `100`, so a bare percentage needs neither bound set.\n */\n maxValue?: number;\n /**\n * What is being measured. Also the accessibility label, and a meter without\n * one announces a number with nothing attached to it — \"seventy-five\n * percent\" of what is not an answer. Pass `accessibilityLabel` instead only\n * where the caption is drawn some other way.\n */\n label?: string;\n /** Draw the value above the track, opposite the label. */\n showValueLabel?: boolean;\n /**\n * Text for the value label. Overrides the formatted value — use it for a\n * word where a number reads worse: `Strong`, `Almost full`.\n *\n * It is spoken whether or not it is drawn, so it can give a screen reader\n * better words than the caption without changing the caption. Pair it with\n * `showValueLabel` to draw it too.\n */\n valueLabel?: string;\n /**\n * How to write the value, through `Intl.NumberFormat`. A `percent` style\n * formats how far up the scale the value sits; every other style formats\n * the value itself, so `{ style: 'unit', unit: 'gigabyte' }` against a\n * `maxValue` of `256` reads `64 GB` rather than a percentage of it. Falls\n * back to a rounded percent when omitted.\n */\n formatOptions?: Intl.NumberFormatOptions;\n /**\n * Points on the scale where the colour changes, each `{ from, color }`. The\n * highest one the reading has reached wins, so the order you list them in\n * does not matter; below all of them the `color` prop applies. Non-finite\n * `from` values are ignored.\n *\n * This is the difference between a meter and a bar: the colour is a\n * judgement about the reading. Which direction is bad is yours to say —\n * thresholds climbing to `destructive` suit a disk filling up, thresholds\n * falling to it suit a battery running down.\n */\n thresholds?: MeterThreshold[];\n /**\n * Draw the scale as this many discrete blocks instead of one continuous\n * bar. Blocks are all or nothing, which is the point of them: four blocks\n * say \"three out of four\" where a bar says \"about seventy percent\", and a\n * password is not seventy percent strong.\n *\n * Fractional counts round down and counts above 100 clamp to 100. Invalid,\n * non-positive, or sub-one counts use the continuous bar. Any value above\n * the floor lights at least one block, so a reading that is not empty never\n * looks it.\n */\n segments?: number;\n /** Extra classes for the fill, or for a lit segment. */\n indicatorClassName?: string;\n /** Extra classes for the label + value row. */\n headerClassName?: string;\n}\n\ninterface SegmentProps {\n index: number;\n /** How many blocks are lit, as a shared value so lighting stays off the JS thread. */\n lit: SharedValue;\n className: string;\n reducedMotion: boolean;\n}\n\n/**\n * One block of a segmented meter. It reads the lit count rather than a boolean\n * so the whole row animates from one shared value, without a render per block.\n */\nfunction Segment({ index, lit, className, reducedMotion }: SegmentProps) {\n const style = useAnimatedStyle(() => {\n const on = lit.value > index ? 1 : SEGMENT_UNLIT;\n return {\n opacity: reducedMotion\n ? on\n : withTiming(on, { duration: SEGMENT_DURATION }),\n };\n });\n\n return ;\n}\n\n/**\n * A measurement on a fixed scale: disk used, battery left, a score, a password's\n * strength. The fill is driven on the UI thread, so a value change costs one\n * render and nothing per frame.\n *\n * A meter is not a progress bar. Progress is a task moving towards an end that\n * finishing is the point of; a meter is a reading that happens to sit where it\n * sits, and may go back down. Reach for\n * [Progress](/docs/components/progress) when something is completing.\n *\n * `thresholds` paint the bar by where the reading falls, and `segments` draws\n * the scale as discrete blocks for readings that are counted rather than\n * measured.\n *\n * The value is read against `minValue` / `maxValue`, which default to 0 and\n * 100 — so a percentage needs neither. Set them and the meter speaks in\n * whatever is being measured: bytes, degrees, points. Nothing has to be\n * converted to a percent on the way in, and the screen reader is told the real\n * scale rather than a derived one.\n *\n * React Native has no meter role, so this announces as a progress bar with its\n * value, range and unit attached. That is the closest the platform offers, and\n * it is why `label` matters: the role will not distinguish a reading from a\n * task, and the name is what does.\n */\nexport const Meter = forwardRef(\n (\n {\n className,\n indicatorClassName,\n headerClassName,\n value,\n minValue = 0,\n maxValue = 100,\n label,\n showValueLabel = false,\n valueLabel,\n formatOptions,\n thresholds,\n segments,\n color,\n size,\n accessibilityLabel,\n ...props\n },\n ref\n ) => {\n const scale = normalizeScale(value, minValue, maxValue);\n const { value: held, fraction } = scale;\n const segmentCount = normalizeSegments(segments);\n const resolvedColor = colorFor(held, color ?? 'primary', thresholds);\n const slots = meterVariants({ color: resolvedColor, size });\n const reducedMotion = useReducedMotion();\n\n const trackWidth = useSharedValue(0);\n const progress = useSharedValue(fraction);\n // Any reading above the floor lights a block, so \"a little\" never looks\n // like \"none\". Rounding down would leave the first quarter of a\n // four-block meter dark, which is the reading it is least able to afford.\n const litCount = litSegments(fraction, segmentCount);\n const lit = useSharedValue(litCount);\n\n // Reduce motion lands on the value instead of springing to it — the\n // number is the information, and the overshoot is decoration.\n useEffect(() => {\n progress.value = reducedMotion ? fraction : withSpring(fraction, SPRING);\n }, [fraction, progress, reducedMotion]);\n\n useEffect(() => {\n lit.value = litCount;\n }, [lit, litCount]);\n\n const onLayout = (event: LayoutChangeEvent) => {\n const width = event.nativeEvent.layout.width;\n trackWidth.value = Number.isFinite(width) && width > 0 ? width : 0;\n };\n\n const fillStyle = useAnimatedStyle(() => ({\n width: trackWidth.value * progress.value,\n }));\n\n const semantics = meterSemantics({\n value: held,\n fraction,\n // The normalized ends, not the raw props: an invalid scale is repaired\n // for the bar, and the spoken reading has to describe the same one.\n minValue: scale.min,\n maxValue: scale.max,\n label,\n accessibilityLabel,\n valueLabel,\n formatOptions,\n });\n const spoken = semantics.text;\n const showValue = showValueLabel;\n const hasHeader = label != null || showValue;\n\n const accessibility = {\n accessibilityRole: 'progressbar' as const,\n accessibilityLabel: semantics.label,\n // `text` carries the unit or word and intentionally overrides the\n // platform's generic rendering of this numeric range.\n accessibilityValue: semantics.value,\n };\n\n const bar =\n segmentCount > 0 ? (\n \n {Array.from({ length: segmentCount }, (_, index) => (\n \n ))}\n \n ) : (\n \n \n \n );\n\n if (!hasHeader) return bar;\n\n return (\n \n \n {label != null ? (\n \n {label}\n \n ) : (\n \n )}\n {showValue ? (\n \n {spoken}\n \n ) : null}\n \n {bar}\n \n );\n }\n);\n\nMeter.displayName = 'Meter';\n\nexport type { MeterColor, MeterThreshold };\n" }, { "path": "ui/meter-scale.ts", diff --git a/apps/docs/public/r/number-input.json b/apps/docs/public/r/number-input.json index ab2806f90..bc7c9eea8 100644 --- a/apps/docs/public/r/number-input.json +++ b/apps/docs/public/r/number-input.json @@ -7,6 +7,7 @@ "haptics", "icons", "label", + "refs", "text" ], "dependencies": [ @@ -18,7 +19,7 @@ { "path": "ui/number-input.tsx", "type": "registry:ui", - "content": "/**\n * NumberInput — a value stepped by two buttons or typed by hand.\n *\n * A number is not a slider: past a handful of steps, dragging to 47 is worse\n * than tapping + a few times or just typing it. So the control keeps both\n * doors open — a − and a + at the ends for nudging, and an editable field in\n * the middle for jumping straight to a value — and reconciles them through a\n * single clamped, step-snapped number.\n *\n * The two ends are not just tap targets. Hold one down and it repeats, after a\n * short delay and then faster, the way a native stepper does — so walking from\n * 0 to 200 is a press, not two hundred of them. The repeat reads the live\n * value from a ref rather than a closure, so it never strides off a stale\n * number, and it stops at a bound on its own rather than waiting for a\n * release a disabled button may never send.\n *\n * Typing is allowed to be briefly invalid — an empty field, a lone `-`, a\n * trailing `.` — because clamping every keystroke fights the person mid-number.\n * The field commits (parses, snaps, clamps) on blur, and falls back to the\n * last good value when what is left cannot be read as a number.\n *\n * ```tsx\n * \n * \n * ```\n *\n * Runs controlled (`value` + `onValueChange`) or uncontrolled (`defaultValue`).\n */\nimport { forwardRef, useCallback, useEffect, useRef, useState } from 'react';\nimport { TextInput, View, type TextInputProps, type TextStyle } from 'react-native';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport { useCSSVariable } from 'uniwind';\nimport { MinusIcon, PlusIcon } from '@/components/ui/icons';\nimport { AnimatedPressable } from '@/components/ui/animated-pressable';\nimport { Text } from '@/components/ui/text';\nimport { selectionTick } from '@/lib/haptics';\nimport { Label } from '@/components/ui/label';\nimport { normalize, precisionOf } from '@/components/ui/number-input-math';\nimport { createRepeater } from '@/components/ui/number-input-repeat';\n\n/** Wait before a held button starts repeating — a tap must not trip it. */\nconst REPEAT_DELAY = 400;\n/** Interval between repeats once holding — fast enough to cover ground. */\nconst REPEAT_INTERVAL = 80;\n\nconst numberInputVariants = tv({\n slots: {\n container: 'w-full gap-1.5',\n control: 'w-full flex-row items-center overflow-hidden rounded-lg border',\n button: 'h-full items-center justify-center',\n // The field takes the full height so the digits centre against the same\n // box the two glyphs do, rather than against their own line's height.\n field: 'h-full flex-1 text-center font-medium text-foreground',\n description: 'text-sm text-muted-foreground',\n error: 'text-sm text-destructive',\n },\n variants: {\n variant: {\n // Matches Input: outline draws its own edge on the page; filled sits\n // inside a card, where a second border reads as a seam.\n outline: { control: 'border-input bg-background' },\n filled: { control: 'border-transparent bg-muted' },\n },\n size: {\n sm: { control: 'h-10', button: 'w-10' },\n md: { control: 'h-12', button: 'w-12' },\n lg: { control: 'h-14', button: 'w-14' },\n },\n invalid: {\n true: { control: 'border-destructive' },\n },\n disabled: {\n true: { container: 'opacity-[0.64]' },\n },\n },\n defaultVariants: {\n variant: 'outline',\n size: 'md',\n },\n});\n\n/** Icon size per control size — the glyph scales with its target. */\nconst ICON_SIZE: Record<'sm' | 'md' | 'lg', number> = { sm: 16, md: 18, lg: 20 };\n\n/**\n * The field's job is not to be a box of its own: it has to put the number on\n * exactly the axis the − and the + sit on, and in the exact middle between\n * them. An editable field does not do that unhelped — it arrives with padding\n * and, on Android, with font padding above and below the line, all of which\n * shift the digits off centre. Zeroing both and centring the line in the full\n * height leaves geometry as the only thing placing the number.\n *\n * Tabular figures on top of that, so the digits keep the same advance widths\n * while the value counts — a 1 that is narrower than a 7 makes a centred\n * number appear to shuffle sideways as it steps.\n */\nconst FIELD_BASE: TextStyle = {\n paddingVertical: 0,\n paddingHorizontal: 0,\n includeFontPadding: false,\n textAlignVertical: 'center',\n fontVariant: ['tabular-nums'],\n};\n\n/**\n * Font size is set here rather than by a text class because those bring a line\n * height with them: a 16px number in a 24px line box. The extra leading is\n * added above the digits, so centring that box leaves the number sitting a\n * couple of pixels below the − and the + it is meant to line up with — close\n * enough to look like a mistake rather than a difference. Sized this way the\n * line box is the font's own, and centring it centres the digits.\n */\nconst FIELD_STYLE: Record<'sm' | 'md' | 'lg', TextStyle> = {\n sm: { ...FIELD_BASE, fontSize: 14 },\n md: { ...FIELD_BASE, fontSize: 16 },\n lg: { ...FIELD_BASE, fontSize: 16 },\n};\n\ntype NumberInputVariantProps = VariantProps;\n\nexport interface NumberInputProps\n extends Omit,\n Pick {\n className?: string;\n containerClassName?: string;\n /** Controlled value. Leave unset and pass `defaultValue` to run uncontrolled. */\n value?: number;\n /** Starting value when uncontrolled. Defaults to `min`, or 0. */\n defaultValue?: number;\n /** Lower bound. The decrement button disables here. */\n min?: number;\n /** Upper bound. The increment button disables here. */\n max?: number;\n /** Nudge per press, and the granularity the value snaps to. */\n step?: number;\n /** Fires whenever the committed value changes. */\n onValueChange?: (value: number) => void;\n /**\n * Format the displayed number — units, currency, grouping. The field shows\n * this string; typing still reads a bare number back. Defaults to the value\n * rounded to `step`'s precision.\n */\n formatValue?: (value: number) => string;\n /** Let the middle field be typed into. When false it is display-only. */\n editable?: boolean;\n disabled?: boolean;\n /** A label above the control. Doubles as the accessibility label. */\n label?: string;\n /** Helper text below the control. Hidden while an error shows. */\n description?: string;\n /** Error message. When set, the control renders in its invalid state. */\n errorMessage?: string;\n /** Marks the field required — an asterisk on the label, and the a11y state. */\n isRequired?: boolean;\n /**\n * Tick the haptic engine on each step. Needs the optional `expo-haptics`,\n * and is silent without it.\n */\n haptics?: boolean;\n}\n\n/**\n * A number stepped by buttons or typed by hand, reconciled to one clamped,\n * step-snapped value. See the file header for why it keeps both affordances.\n */\nexport const NumberInput = forwardRef(\n (\n {\n className,\n containerClassName,\n value: controlledValue,\n defaultValue,\n min = -Infinity,\n max = Infinity,\n step = 1,\n onValueChange,\n formatValue,\n editable = true,\n variant = 'outline',\n size = 'md',\n disabled,\n label,\n description,\n errorMessage,\n isRequired,\n haptics,\n placeholder,\n onFocus,\n onBlur,\n returnKeyType,\n },\n ref\n ) => {\n const isControlled = controlledValue !== undefined;\n const initial = normalize(\n controlledValue ?? defaultValue ?? (isFinite(min) ? min : 0),\n min,\n max,\n step\n );\n const [uncontrolled, setUncontrolled] = useState(initial);\n const value = isControlled ? normalize(controlledValue!, min, max, step) : uncontrolled;\n\n // The repeat timers read the live value through a ref, so a held button\n // keeps counting from where it is rather than from the value it closed over.\n const valueRef = useRef(value);\n valueRef.current = value;\n\n const invalid = !!errorMessage;\n const foreground = useCSSVariable('--color-foreground');\n const iconColor = typeof foreground === 'string' ? foreground : undefined;\n const slots = numberInputVariants({ variant, size, invalid, disabled: !!disabled });\n\n const format = useCallback(\n (v: number) => (formatValue ? formatValue(v) : String(v)),\n [formatValue]\n );\n\n // `text` is what the field shows while focused; it is allowed to be\n // half-typed. Away from focus it always mirrors the committed value.\n const [text, setText] = useState(() => format(value));\n const [focused, setFocused] = useState(false);\n useEffect(() => {\n if (!focused) setText(format(value));\n }, [value, focused, format]);\n\n const commit = useCallback(\n (next: number, tick: boolean) => {\n const clamped = normalize(next, min, max, step);\n if (tick && haptics) selectionTick();\n if (!isControlled) setUncontrolled(clamped);\n if (clamped !== valueRef.current) onValueChange?.(clamped);\n // Advance the ref now, so the next repeat steps from here even if the\n // render carrying this value has not landed yet.\n valueRef.current = clamped;\n },\n [min, max, step, haptics, isControlled, onValueChange]\n );\n\n // --- hold-to-repeat -----------------------------------------------------\n // The timer outlives the render that started it, so it calls the latest\n // `commit` through a ref instead of the one it was created with.\n const commitRef = useRef(commit);\n commitRef.current = commit;\n\n const repeaterRef = useRef | null>(null);\n if (!repeaterRef.current) repeaterRef.current = createRepeater(REPEAT_DELAY, REPEAT_INTERVAL);\n const repeater = repeaterRef.current;\n\n const stopRepeat = repeater.stop;\n useEffect(() => stopRepeat, [stopRepeat]);\n useEffect(() => {\n if (disabled) stopRepeat();\n }, [disabled, stopRepeat]);\n\n const startRepeat = useCallback(\n (direction: 1 | -1) => {\n repeater.start(() => {\n const current = valueRef.current;\n const next = normalize(current + direction * step, min, max, step);\n if (next === current) return false;\n commitRef.current(next, true);\n // Keep going only while there is room for another step.\n return direction > 0 ? next < max : next > min;\n });\n },\n [repeater, step, min, max]\n );\n\n const atMin = value <= min;\n const atMax = value >= max;\n\n const handleEndEditing = useCallback(() => {\n const parsed = parseFloat(text);\n // An empty or unreadable field falls back to the last good value rather\n // than snapping to a surprising 0.\n commit(Number.isNaN(parsed) ? value : parsed, false);\n setFocused(false);\n setText(format(Number.isNaN(parsed) ? value : normalize(parsed, min, max, step)));\n }, [text, value, commit, format, min, max, step]);\n\n return (\n \n {label ? (\n \n ) : null}\n \n {\n if (!disabled && !atMin) startRepeat(-1);\n }}\n onPressOut={stopRepeat}\n accessibilityRole=\"button\"\n accessibilityLabel={`Decrease ${label ?? 'value'}`}\n accessibilityState={{ disabled: disabled || atMin }}\n style={atMin ? { opacity: 0.4 } : undefined}\n >\n {/* Same weight as the +, or the two ends read as different glyphs. */}\n \n \n\n 0 ? 'decimal-pad' : 'number-pad'}\n placeholder={placeholder}\n returnKeyType={returnKeyType}\n selectTextOnFocus\n onChangeText={setText}\n onFocus={(e) => {\n setFocused(true);\n onFocus?.(e);\n }}\n onBlur={(e) => {\n handleEndEditing();\n onBlur?.(e);\n }}\n onSubmitEditing={handleEndEditing}\n accessibilityLabel={label}\n accessibilityState={{ disabled: !!disabled }}\n aria-required={isRequired}\n aria-invalid={invalid}\n className={slots.field()}\n style={FIELD_STYLE[size]}\n />\n\n {\n if (!disabled && !atMax) startRepeat(1);\n }}\n onPressOut={stopRepeat}\n accessibilityRole=\"button\"\n accessibilityLabel={`Increase ${label ?? 'value'}`}\n accessibilityState={{ disabled: disabled || atMax }}\n style={atMax ? { opacity: 0.4 } : undefined}\n >\n \n \n \n {errorMessage ? (\n {errorMessage}\n ) : description ? (\n {description}\n ) : null}\n \n );\n }\n);\n\nNumberInput.displayName = 'NumberInput';\n" + "content": "/**\n * NumberInput — a value stepped by two buttons or typed by hand.\n *\n * A number is not a slider: past a handful of steps, dragging to 47 is worse\n * than tapping + a few times or just typing it. So the control keeps both\n * doors open — a − and a + at the ends for nudging, and an editable field in\n * the middle for jumping straight to a value — and reconciles them through a\n * single clamped, step-snapped number.\n *\n * The two ends are not just tap targets. Hold one down and it repeats, after a\n * short delay and then faster, the way a native stepper does — so walking from\n * 0 to 200 is a press, not two hundred of them. The repeat reads the live\n * value from a ref rather than a closure, so it never strides off a stale\n * number, and it stops at a bound on its own rather than waiting for a\n * release a disabled button may never send.\n *\n * Typing is allowed to be briefly invalid — an empty field, a lone `-`, a\n * trailing `.` — because clamping every keystroke fights the person mid-number.\n * The field commits (parses, snaps, clamps) on blur, and falls back to the\n * last good value when what is left cannot be read as a number.\n *\n * ```tsx\n * \n * \n * ```\n *\n * Runs controlled (`value` + `onValueChange`) or uncontrolled (`defaultValue`).\n */\nimport { forwardRef, useCallback, useEffect, useRef, useState } from 'react';\nimport { TextInput, View, type TextInputProps, type TextStyle } from 'react-native';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport { useCSSVariable } from 'uniwind';\nimport { MinusIcon, PlusIcon } from '@/components/ui/icons';\nimport { AnimatedPressable } from '@/components/ui/animated-pressable';\nimport { Text } from '@/components/ui/text';\nimport { selectionTick } from '@/lib/haptics';\nimport { Label } from '@/components/ui/label';\nimport { normalize, precisionOf } from '@/components/ui/number-input-math';\nimport { createRepeater } from '@/components/ui/number-input-repeat';\nimport type { TextInputRef } from '@/components/ui/refs';\n\n/** Wait before a held button starts repeating — a tap must not trip it. */\nconst REPEAT_DELAY = 400;\n/** Interval between repeats once holding — fast enough to cover ground. */\nconst REPEAT_INTERVAL = 80;\n\nconst numberInputVariants = tv({\n slots: {\n container: 'w-full gap-1.5',\n control: 'w-full flex-row items-center overflow-hidden rounded-lg border',\n button: 'h-full items-center justify-center',\n // The field takes the full height so the digits centre against the same\n // box the two glyphs do, rather than against their own line's height.\n field: 'h-full flex-1 text-center font-medium text-foreground',\n description: 'text-sm text-muted-foreground',\n error: 'text-sm text-destructive',\n },\n variants: {\n variant: {\n // Matches Input: outline draws its own edge on the page; filled sits\n // inside a card, where a second border reads as a seam.\n outline: { control: 'border-input bg-background' },\n filled: { control: 'border-transparent bg-muted' },\n },\n size: {\n sm: { control: 'h-10', button: 'w-10' },\n md: { control: 'h-12', button: 'w-12' },\n lg: { control: 'h-14', button: 'w-14' },\n },\n invalid: {\n true: { control: 'border-destructive' },\n },\n disabled: {\n true: { container: 'opacity-[0.64]' },\n },\n },\n defaultVariants: {\n variant: 'outline',\n size: 'md',\n },\n});\n\n/** Icon size per control size — the glyph scales with its target. */\nconst ICON_SIZE: Record<'sm' | 'md' | 'lg', number> = { sm: 16, md: 18, lg: 20 };\n\n/**\n * The field's job is not to be a box of its own: it has to put the number on\n * exactly the axis the − and the + sit on, and in the exact middle between\n * them. An editable field does not do that unhelped — it arrives with padding\n * and, on Android, with font padding above and below the line, all of which\n * shift the digits off centre. Zeroing both and centring the line in the full\n * height leaves geometry as the only thing placing the number.\n *\n * Tabular figures on top of that, so the digits keep the same advance widths\n * while the value counts — a 1 that is narrower than a 7 makes a centred\n * number appear to shuffle sideways as it steps.\n */\nconst FIELD_BASE: TextStyle = {\n paddingVertical: 0,\n paddingHorizontal: 0,\n includeFontPadding: false,\n textAlignVertical: 'center',\n fontVariant: ['tabular-nums'],\n};\n\n/**\n * Font size is set here rather than by a text class because those bring a line\n * height with them: a 16px number in a 24px line box. The extra leading is\n * added above the digits, so centring that box leaves the number sitting a\n * couple of pixels below the − and the + it is meant to line up with — close\n * enough to look like a mistake rather than a difference. Sized this way the\n * line box is the font's own, and centring it centres the digits.\n */\nconst FIELD_STYLE: Record<'sm' | 'md' | 'lg', TextStyle> = {\n sm: { ...FIELD_BASE, fontSize: 14 },\n md: { ...FIELD_BASE, fontSize: 16 },\n lg: { ...FIELD_BASE, fontSize: 16 },\n};\n\ntype NumberInputVariantProps = VariantProps;\n\nexport interface NumberInputProps\n extends Omit,\n Pick {\n className?: string;\n containerClassName?: string;\n /** Controlled value. Leave unset and pass `defaultValue` to run uncontrolled. */\n value?: number;\n /** Starting value when uncontrolled. Defaults to `min`, or 0. */\n defaultValue?: number;\n /** Lower bound. The decrement button disables here. */\n min?: number;\n /** Upper bound. The increment button disables here. */\n max?: number;\n /** Nudge per press, and the granularity the value snaps to. */\n step?: number;\n /** Fires whenever the committed value changes. */\n onValueChange?: (value: number) => void;\n /**\n * Format the displayed number — units, currency, grouping. The field shows\n * this string; typing still reads a bare number back. Defaults to the value\n * rounded to `step`'s precision.\n */\n formatValue?: (value: number) => string;\n /** Let the middle field be typed into. When false it is display-only. */\n editable?: boolean;\n disabled?: boolean;\n /** A label above the control. Doubles as the accessibility label. */\n label?: string;\n /** Helper text below the control. Hidden while an error shows. */\n description?: string;\n /** Error message. When set, the control renders in its invalid state. */\n errorMessage?: string;\n /** Marks the field required — an asterisk on the label, and the a11y state. */\n isRequired?: boolean;\n /**\n * Tick the haptic engine on each step. Needs the optional `expo-haptics`,\n * and is silent without it.\n */\n haptics?: boolean;\n}\n\n/**\n * A number stepped by buttons or typed by hand, reconciled to one clamped,\n * step-snapped value. See the file header for why it keeps both affordances.\n */\nexport const NumberInput = forwardRef(\n (\n {\n className,\n containerClassName,\n value: controlledValue,\n defaultValue,\n min = -Infinity,\n max = Infinity,\n step = 1,\n onValueChange,\n formatValue,\n editable = true,\n variant = 'outline',\n size = 'md',\n disabled,\n label,\n description,\n errorMessage,\n isRequired,\n haptics,\n placeholder,\n onFocus,\n onBlur,\n returnKeyType,\n },\n ref\n ) => {\n const isControlled = controlledValue !== undefined;\n const initial = normalize(\n controlledValue ?? defaultValue ?? (isFinite(min) ? min : 0),\n min,\n max,\n step\n );\n const [uncontrolled, setUncontrolled] = useState(initial);\n const value = isControlled ? normalize(controlledValue!, min, max, step) : uncontrolled;\n\n // The repeat timers read the live value through a ref, so a held button\n // keeps counting from where it is rather than from the value it closed over.\n const valueRef = useRef(value);\n valueRef.current = value;\n\n const invalid = !!errorMessage;\n const foreground = useCSSVariable('--color-foreground');\n const iconColor = typeof foreground === 'string' ? foreground : undefined;\n const slots = numberInputVariants({ variant, size, invalid, disabled: !!disabled });\n\n const format = useCallback(\n (v: number) => (formatValue ? formatValue(v) : String(v)),\n [formatValue]\n );\n\n // `text` is what the field shows while focused; it is allowed to be\n // half-typed. Away from focus it always mirrors the committed value.\n const [text, setText] = useState(() => format(value));\n const [focused, setFocused] = useState(false);\n useEffect(() => {\n if (!focused) setText(format(value));\n }, [value, focused, format]);\n\n const commit = useCallback(\n (next: number, tick: boolean) => {\n const clamped = normalize(next, min, max, step);\n if (tick && haptics) selectionTick();\n if (!isControlled) setUncontrolled(clamped);\n if (clamped !== valueRef.current) onValueChange?.(clamped);\n // Advance the ref now, so the next repeat steps from here even if the\n // render carrying this value has not landed yet.\n valueRef.current = clamped;\n },\n [min, max, step, haptics, isControlled, onValueChange]\n );\n\n // --- hold-to-repeat -----------------------------------------------------\n // The timer outlives the render that started it, so it calls the latest\n // `commit` through a ref instead of the one it was created with.\n const commitRef = useRef(commit);\n commitRef.current = commit;\n\n const repeaterRef = useRef | null>(null);\n if (!repeaterRef.current) repeaterRef.current = createRepeater(REPEAT_DELAY, REPEAT_INTERVAL);\n const repeater = repeaterRef.current;\n\n const stopRepeat = repeater.stop;\n useEffect(() => stopRepeat, [stopRepeat]);\n useEffect(() => {\n if (disabled) stopRepeat();\n }, [disabled, stopRepeat]);\n\n const startRepeat = useCallback(\n (direction: 1 | -1) => {\n repeater.start(() => {\n const current = valueRef.current;\n const next = normalize(current + direction * step, min, max, step);\n if (next === current) return false;\n commitRef.current(next, true);\n // Keep going only while there is room for another step.\n return direction > 0 ? next < max : next > min;\n });\n },\n [repeater, step, min, max]\n );\n\n const atMin = value <= min;\n const atMax = value >= max;\n\n const handleEndEditing = useCallback(() => {\n const parsed = parseFloat(text);\n // An empty or unreadable field falls back to the last good value rather\n // than snapping to a surprising 0.\n commit(Number.isNaN(parsed) ? value : parsed, false);\n setFocused(false);\n setText(format(Number.isNaN(parsed) ? value : normalize(parsed, min, max, step)));\n }, [text, value, commit, format, min, max, step]);\n\n return (\n \n {label ? (\n \n ) : null}\n \n {\n if (!disabled && !atMin) startRepeat(-1);\n }}\n onPressOut={stopRepeat}\n accessibilityRole=\"button\"\n accessibilityLabel={`Decrease ${label ?? 'value'}`}\n accessibilityState={{ disabled: disabled || atMin }}\n style={atMin ? { opacity: 0.4 } : undefined}\n >\n {/* Same weight as the +, or the two ends read as different glyphs. */}\n \n \n\n 0 ? 'decimal-pad' : 'number-pad'}\n placeholder={placeholder}\n returnKeyType={returnKeyType}\n selectTextOnFocus\n onChangeText={setText}\n onFocus={(e) => {\n setFocused(true);\n onFocus?.(e);\n }}\n onBlur={(e) => {\n handleEndEditing();\n onBlur?.(e);\n }}\n onSubmitEditing={handleEndEditing}\n accessibilityLabel={label}\n accessibilityState={{ disabled: !!disabled }}\n aria-required={isRequired}\n aria-invalid={invalid}\n className={slots.field()}\n style={FIELD_STYLE[size]}\n />\n\n {\n if (!disabled && !atMax) startRepeat(1);\n }}\n onPressOut={stopRepeat}\n accessibilityRole=\"button\"\n accessibilityLabel={`Increase ${label ?? 'value'}`}\n accessibilityState={{ disabled: disabled || atMax }}\n style={atMax ? { opacity: 0.4 } : undefined}\n >\n \n \n \n {errorMessage ? (\n {errorMessage}\n ) : description ? (\n {description}\n ) : null}\n \n );\n }\n);\n\nNumberInput.displayName = 'NumberInput';\n" }, { "path": "ui/number-input-math.ts", diff --git a/apps/docs/public/r/otp-input.json b/apps/docs/public/r/otp-input.json index fb289042a..c798e37da 100644 --- a/apps/docs/public/r/otp-input.json +++ b/apps/docs/public/r/otp-input.json @@ -3,6 +3,7 @@ "type": "registry:ui", "description": "One-time-code field drawn as a row of separate cells.", "registryDependencies": [ + "refs", "text" ], "dependencies": [ @@ -15,7 +16,7 @@ { "path": "ui/otp-input.tsx", "type": "registry:ui", - "content": "/**\n * OtpInput — a one-time-code field drawn as a row of separate cells.\n *\n * The problem this solves is that a code is read and typed one character at a\n * time, and a single text box hides that: you cannot see, at a glance, how\n * many digits are left. So the value is spread across `length` cells, each its\n * own box, and the caret sits in the cell you are about to fill.\n *\n * There is still exactly one text field underneath — a transparent input laid\n * over the whole row — because the software keyboard, autofill and paste all\n * belong to a real `TextInput`, and splitting the value across N inputs fights\n * every one of them. The cells are drawn from the value; the input owns it.\n * Tapping any cell focuses that one input, so the platform never sees the seam.\n *\n * The active cell's border crosses to the focus colour on the UI thread rather\n * than snapping, matching the rest of the field family, and a caret blinks in\n * the cell awaiting input. Each digit zooms in as it lands, so a fast type or a\n * one-tap autofill reads as the code arriving rather than the box redrawing.\n *\n * ```tsx\n * const [code, setCode] = useState('');\n * \n * \n * ```\n */\nimport { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from 'react';\nimport { Pressable, TextInput, View, type TextInputProps } from 'react-native';\nimport Animated, {\n cancelAnimation,\n interpolateColor,\n useAnimatedStyle,\n useSharedValue,\n withRepeat,\n withSequence,\n withTiming,\n ZoomIn,\n} from 'react-native-reanimated';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport { useCSSVariable } from 'uniwind';\nimport { Text } from '@/components/ui/text';\nimport { resolveOtpEditable } from '@/components/ui/otp-input-state';\n\n/** Long enough to read as a transition, short enough not to lag a fast type. */\nconst FOCUS_DURATION = 150;\n/** A caret that is on for the same time it is off reads as a resting cursor. */\nconst CARET_PERIOD = 560;\n\nconst otpVariants = tv({\n slots: {\n container: 'gap-1.5',\n row: 'flex-row items-center',\n cell: 'items-center justify-center rounded-lg border border-input bg-background',\n /* The typed character is drawn by an Animated.Text rather than the Text\n primitive, so its weight class has to be written here for an app's\n --font-normal token to reach it. */\n char: 'font-normal text-foreground',\n caret: 'w-0.5 rounded-full bg-foreground',\n separator: 'h-0.5 rounded-full bg-border',\n error: 'text-sm text-destructive',\n },\n variants: {\n size: {\n sm: { cell: 'h-10 w-9', char: 'text-base', caret: 'h-5', separator: 'w-2' },\n md: { cell: 'h-12 w-11', char: 'text-lg', caret: 'h-6', separator: 'w-2.5' },\n lg: { cell: 'h-14 w-12', char: 'text-xl', caret: 'h-7', separator: 'w-3' },\n },\n disabled: {\n true: { row: 'opacity-[0.64]' },\n },\n },\n defaultVariants: {\n size: 'md',\n },\n});\n\ntype OtpVariantProps = VariantProps;\n\nexport interface OtpInputProps\n extends Omit<\n TextInputProps,\n 'value' | 'defaultValue' | 'onChangeText' | 'maxLength' | 'children'\n >,\n Omit {\n className?: string;\n /** Class applied to every cell — for a taller box or a different radius. */\n cellClassName?: string;\n /** How many cells, i.e. the length of the code. */\n length?: number;\n /** Controlled value. Longer strings and stray characters are trimmed to fit. */\n value?: string;\n /** Starting value when the field manages its own state. */\n defaultValue?: string;\n onChangeText?: (value: string) => void;\n /** Fires once, the moment the last cell is filled. */\n onComplete?: (value: string) => void;\n /**\n * What the keyboard offers and what the field accepts. `numeric` keeps\n * digits only and asks for the number pad; `text` accepts any character.\n */\n type?: 'numeric' | 'text';\n /** Hide each filled character behind a dot, the way a passcode field does. */\n mask?: boolean;\n /** A single character shown, dimmed, in every cell still waiting for input. */\n placeholder?: string;\n /** Draw a separator between groups of this many cells — 3 gives `xxx — xxx`. */\n groupEvery?: number;\n disabled?: boolean;\n /** Tint the field in its error colour and announce it as invalid. */\n isInvalid?: boolean;\n /** Error line under the field. Setting it also puts the field in its invalid state. */\n errorMessage?: string;\n /** Announced by a screen reader as the field's name. */\n accessibilityLabel?: string;\n}\n\ninterface CellProps {\n char: string;\n active: boolean;\n focused: boolean;\n mask: boolean;\n placeholder?: string;\n invalid: boolean;\n className: string;\n charClassName: string;\n caretClassName: string;\n}\n\n/**\n * One box. It owns three animations, all on the UI thread: the border crossing\n * to the focus (or error) colour when it becomes the active cell, the caret\n * blinking while it waits, and the entrance of the character that lands in it.\n */\nfunction OtpCell({\n char,\n active,\n focused,\n mask,\n placeholder,\n invalid,\n className,\n charClassName,\n caretClassName,\n}: CellProps) {\n const restColor = useCSSVariable('--color-input');\n const focusColor = useCSSVariable('--color-ring');\n const errorColor = useCSSVariable('--color-destructive');\n const placeholderColor = useCSSVariable('--color-muted-foreground');\n\n const resting = typeof restColor === 'string' ? restColor : '#e5e5e5';\n const activeColor = invalid\n ? typeof errorColor === 'string'\n ? errorColor\n : '#ef4444'\n : typeof focusColor === 'string'\n ? focusColor\n : '#a3a3a3';\n // An invalid field carries its tint even when it is not the active cell —\n // the error is a fact about the value, not about where the caret happens to be.\n const idle = invalid ? activeColor : resting;\n\n const lit = active && focused;\n\n const border = useSharedValue(lit ? 1 : 0);\n useEffect(() => {\n border.value = withTiming(lit ? 1 : 0, { duration: FOCUS_DURATION });\n }, [lit, border]);\n\n const borderStyle = useAnimatedStyle(() => ({\n borderColor: interpolateColor(border.value, [0, 1], [idle, activeColor]),\n // A ring only when lit — the resting cells keep their hairline.\n borderWidth: 1 + border.value,\n }));\n\n const caret = useSharedValue(0);\n useEffect(() => {\n if (lit && char.length === 0) {\n caret.value = withRepeat(\n withSequence(\n withTiming(1, { duration: 1 }),\n withTiming(1, { duration: CARET_PERIOD }),\n withTiming(0, { duration: CARET_PERIOD })\n ),\n -1,\n false\n );\n } else {\n cancelAnimation(caret);\n caret.value = 0;\n }\n return () => cancelAnimation(caret);\n }, [lit, char.length, caret]);\n\n const caretStyle = useAnimatedStyle(() => ({ opacity: caret.value }));\n\n return (\n \n {char.length > 0 ? (\n \n {mask ? '•' : char}\n \n ) : lit ? (\n \n ) : placeholder ? (\n \n {placeholder}\n \n ) : null}\n \n );\n}\n\n/** Clamp and filter a raw string down to what this field will hold. */\nfunction sanitize(raw: string, length: number, type: 'numeric' | 'text'): string {\n const filtered = type === 'numeric' ? raw.replace(/[^0-9]/g, '') : raw;\n return filtered.slice(0, length);\n}\n\nexport const OtpInput = forwardRef(\n (\n {\n className,\n cellClassName,\n length = 6,\n value: valueProp,\n defaultValue = '',\n onChangeText,\n onComplete,\n type = 'numeric',\n mask = false,\n placeholder,\n groupEvery = 0,\n size,\n disabled,\n editable,\n isInvalid,\n errorMessage,\n accessibilityLabel,\n onFocus,\n onBlur,\n style,\n ...props\n },\n ref\n ) => {\n const controlled = valueProp != null;\n const [internal, setInternal] = useState(() =>\n sanitize(defaultValue, length, type)\n );\n const value = sanitize(controlled ? valueProp! : internal, length, type);\n const invalid = isInvalid || !!errorMessage;\n\n const [focused, setFocused] = useState(false);\n const inputRef = useRef(null);\n\n // A one-shot guard so onComplete fires on the transition to full, not on\n // every keystroke that leaves the field full (e.g. a trailing focus event).\n const completedRef = useRef(false);\n\n const slots = otpVariants({ size, disabled: !!disabled });\n\n const setRef = useCallback(\n (node: TextInput | null) => {\n inputRef.current = node;\n if (typeof ref === 'function') ref(node);\n else if (ref) ref.current = node;\n },\n [ref]\n );\n\n const handleChange = useCallback(\n (raw: string) => {\n const next = sanitize(raw, length, type);\n if (!controlled) setInternal(next);\n onChangeText?.(next);\n if (next.length === length) {\n if (!completedRef.current) {\n completedRef.current = true;\n onComplete?.(next);\n }\n } else {\n completedRef.current = false;\n }\n },\n [controlled, length, type, onChangeText, onComplete]\n );\n\n // Keep the guard honest when the value is driven from outside (a reset, a\n // paste through the controlled prop) rather than through the keyboard.\n useEffect(() => {\n completedRef.current = value.length === length;\n }, [value.length, length]);\n\n const focus = useCallback(() => {\n if (!disabled) inputRef.current?.focus();\n }, [disabled]);\n\n const activeIndex = Math.min(value.length, length - 1);\n\n const cells = useMemo(() => Array.from({ length }, (_, i) => i), [length]);\n\n return (\n \n {/* The row is one big hit target; the field underneath is the a11y node. */}\n \n \n {cells.map((i) => (\n \n 0 ? 'ms-2' : ''} ${cellClassName ?? ''}`,\n })}\n charClassName={slots.char()}\n caretClassName={slots.caret()}\n />\n {groupEvery > 0 &&\n (i + 1) % groupEvery === 0 &&\n i < length - 1 ? (\n \n ) : null}\n \n ))}\n \n\n {/*\n * The real field: one transparent input stretched over the whole row.\n * It carries the keyboard, autofill and paste, and its value is the\n * source the cells are drawn from — there is no per-cell input to keep\n * in sync. `pointerEvents` off so taps fall through to the Pressable,\n * which focuses this exact input.\n */}\n {\n setFocused(true);\n onFocus?.(e);\n }}\n onBlur={(e) => {\n setFocused(false);\n onBlur?.(e);\n }}\n style={[\n {\n position: 'absolute',\n top: 0,\n left: 0,\n right: 0,\n bottom: 0,\n opacity: 0,\n // Taps fall through to the Pressable, which focuses this input —\n // so the caret lands wherever the row is touched.\n pointerEvents: 'none',\n },\n style,\n ]}\n {...props}\n />\n \n\n {errorMessage ? (\n {errorMessage}\n ) : null}\n \n );\n }\n);\n\nOtpInput.displayName = 'OtpInput';\n" + "content": "/**\n * OtpInput — a one-time-code field drawn as a row of separate cells.\n *\n * The problem this solves is that a code is read and typed one character at a\n * time, and a single text box hides that: you cannot see, at a glance, how\n * many digits are left. So the value is spread across `length` cells, each its\n * own box, and the caret sits in the cell you are about to fill.\n *\n * There is still exactly one text field underneath — a transparent input laid\n * over the whole row — because the software keyboard, autofill and paste all\n * belong to a real `TextInput`, and splitting the value across N inputs fights\n * every one of them. The cells are drawn from the value; the input owns it.\n * Tapping any cell focuses that one input, so the platform never sees the seam.\n *\n * The active cell's border crosses to the focus colour on the UI thread rather\n * than snapping, matching the rest of the field family, and a caret blinks in\n * the cell awaiting input. Each digit zooms in as it lands, so a fast type or a\n * one-tap autofill reads as the code arriving rather than the box redrawing.\n *\n * ```tsx\n * const [code, setCode] = useState('');\n * \n * \n * ```\n */\nimport { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from 'react';\nimport { Pressable, TextInput, View, type TextInputProps } from 'react-native';\nimport Animated, {\n cancelAnimation,\n interpolateColor,\n useAnimatedStyle,\n useSharedValue,\n withRepeat,\n withSequence,\n withTiming,\n ZoomIn,\n} from 'react-native-reanimated';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport { useCSSVariable } from 'uniwind';\nimport { Text } from '@/components/ui/text';\nimport { resolveOtpEditable } from '@/components/ui/otp-input-state';\nimport type { TextInputRef } from '@/components/ui/refs';\n\n/** Long enough to read as a transition, short enough not to lag a fast type. */\nconst FOCUS_DURATION = 150;\n/** A caret that is on for the same time it is off reads as a resting cursor. */\nconst CARET_PERIOD = 560;\n\nconst otpVariants = tv({\n slots: {\n container: 'gap-1.5',\n row: 'flex-row items-center',\n cell: 'items-center justify-center rounded-lg border border-input bg-background',\n /* The typed character is drawn by an Animated.Text rather than the Text\n primitive, so its weight class has to be written here for an app's\n --font-normal token to reach it. */\n char: 'font-normal text-foreground',\n caret: 'w-0.5 rounded-full bg-foreground',\n separator: 'h-0.5 rounded-full bg-border',\n error: 'text-sm text-destructive',\n },\n variants: {\n size: {\n sm: { cell: 'h-10 w-9', char: 'text-base', caret: 'h-5', separator: 'w-2' },\n md: { cell: 'h-12 w-11', char: 'text-lg', caret: 'h-6', separator: 'w-2.5' },\n lg: { cell: 'h-14 w-12', char: 'text-xl', caret: 'h-7', separator: 'w-3' },\n },\n disabled: {\n true: { row: 'opacity-[0.64]' },\n },\n },\n defaultVariants: {\n size: 'md',\n },\n});\n\ntype OtpVariantProps = VariantProps;\n\nexport interface OtpInputProps\n extends Omit<\n TextInputProps,\n 'value' | 'defaultValue' | 'onChangeText' | 'maxLength' | 'children'\n >,\n Omit {\n className?: string;\n /** Class applied to every cell — for a taller box or a different radius. */\n cellClassName?: string;\n /** How many cells, i.e. the length of the code. */\n length?: number;\n /** Controlled value. Longer strings and stray characters are trimmed to fit. */\n value?: string;\n /** Starting value when the field manages its own state. */\n defaultValue?: string;\n onChangeText?: (value: string) => void;\n /** Fires once, the moment the last cell is filled. */\n onComplete?: (value: string) => void;\n /**\n * What the keyboard offers and what the field accepts. `numeric` keeps\n * digits only and asks for the number pad; `text` accepts any character.\n */\n type?: 'numeric' | 'text';\n /** Hide each filled character behind a dot, the way a passcode field does. */\n mask?: boolean;\n /** A single character shown, dimmed, in every cell still waiting for input. */\n placeholder?: string;\n /** Draw a separator between groups of this many cells — 3 gives `xxx — xxx`. */\n groupEvery?: number;\n disabled?: boolean;\n /** Tint the field in its error colour and announce it as invalid. */\n isInvalid?: boolean;\n /** Error line under the field. Setting it also puts the field in its invalid state. */\n errorMessage?: string;\n /** Announced by a screen reader as the field's name. */\n accessibilityLabel?: string;\n}\n\ninterface CellProps {\n char: string;\n active: boolean;\n focused: boolean;\n mask: boolean;\n placeholder?: string;\n invalid: boolean;\n className: string;\n charClassName: string;\n caretClassName: string;\n}\n\n/**\n * One box. It owns three animations, all on the UI thread: the border crossing\n * to the focus (or error) colour when it becomes the active cell, the caret\n * blinking while it waits, and the entrance of the character that lands in it.\n */\nfunction OtpCell({\n char,\n active,\n focused,\n mask,\n placeholder,\n invalid,\n className,\n charClassName,\n caretClassName,\n}: CellProps) {\n const restColor = useCSSVariable('--color-input');\n const focusColor = useCSSVariable('--color-ring');\n const errorColor = useCSSVariable('--color-destructive');\n const placeholderColor = useCSSVariable('--color-muted-foreground');\n\n const resting = typeof restColor === 'string' ? restColor : '#e5e5e5';\n const activeColor = invalid\n ? typeof errorColor === 'string'\n ? errorColor\n : '#ef4444'\n : typeof focusColor === 'string'\n ? focusColor\n : '#a3a3a3';\n // An invalid field carries its tint even when it is not the active cell —\n // the error is a fact about the value, not about where the caret happens to be.\n const idle = invalid ? activeColor : resting;\n\n const lit = active && focused;\n\n const border = useSharedValue(lit ? 1 : 0);\n useEffect(() => {\n border.value = withTiming(lit ? 1 : 0, { duration: FOCUS_DURATION });\n }, [lit, border]);\n\n const borderStyle = useAnimatedStyle(() => ({\n borderColor: interpolateColor(border.value, [0, 1], [idle, activeColor]),\n // A ring only when lit — the resting cells keep their hairline.\n borderWidth: 1 + border.value,\n }));\n\n const caret = useSharedValue(0);\n useEffect(() => {\n if (lit && char.length === 0) {\n caret.value = withRepeat(\n withSequence(\n withTiming(1, { duration: 1 }),\n withTiming(1, { duration: CARET_PERIOD }),\n withTiming(0, { duration: CARET_PERIOD })\n ),\n -1,\n false\n );\n } else {\n cancelAnimation(caret);\n caret.value = 0;\n }\n return () => cancelAnimation(caret);\n }, [lit, char.length, caret]);\n\n const caretStyle = useAnimatedStyle(() => ({ opacity: caret.value }));\n\n return (\n \n {char.length > 0 ? (\n \n {mask ? '•' : char}\n \n ) : lit ? (\n \n ) : placeholder ? (\n \n {placeholder}\n \n ) : null}\n \n );\n}\n\n/** Clamp and filter a raw string down to what this field will hold. */\nfunction sanitize(raw: string, length: number, type: 'numeric' | 'text'): string {\n const filtered = type === 'numeric' ? raw.replace(/[^0-9]/g, '') : raw;\n return filtered.slice(0, length);\n}\n\nexport const OtpInput = forwardRef(\n (\n {\n className,\n cellClassName,\n length = 6,\n value: valueProp,\n defaultValue = '',\n onChangeText,\n onComplete,\n type = 'numeric',\n mask = false,\n placeholder,\n groupEvery = 0,\n size,\n disabled,\n editable,\n isInvalid,\n errorMessage,\n accessibilityLabel,\n onFocus,\n onBlur,\n style,\n ...props\n },\n ref\n ) => {\n const controlled = valueProp != null;\n const [internal, setInternal] = useState(() =>\n sanitize(defaultValue, length, type)\n );\n const value = sanitize(controlled ? valueProp! : internal, length, type);\n const invalid = isInvalid || !!errorMessage;\n\n const [focused, setFocused] = useState(false);\n const inputRef = useRef(null);\n\n // A one-shot guard so onComplete fires on the transition to full, not on\n // every keystroke that leaves the field full (e.g. a trailing focus event).\n const completedRef = useRef(false);\n\n const slots = otpVariants({ size, disabled: !!disabled });\n\n const setRef = useCallback(\n (node: TextInput | null) => {\n inputRef.current = node;\n if (typeof ref === 'function') ref(node);\n else if (ref) ref.current = node;\n },\n [ref]\n );\n\n const handleChange = useCallback(\n (raw: string) => {\n const next = sanitize(raw, length, type);\n if (!controlled) setInternal(next);\n onChangeText?.(next);\n if (next.length === length) {\n if (!completedRef.current) {\n completedRef.current = true;\n onComplete?.(next);\n }\n } else {\n completedRef.current = false;\n }\n },\n [controlled, length, type, onChangeText, onComplete]\n );\n\n // Keep the guard honest when the value is driven from outside (a reset, a\n // paste through the controlled prop) rather than through the keyboard.\n useEffect(() => {\n completedRef.current = value.length === length;\n }, [value.length, length]);\n\n const focus = useCallback(() => {\n if (!disabled) inputRef.current?.focus();\n }, [disabled]);\n\n const activeIndex = Math.min(value.length, length - 1);\n\n const cells = useMemo(() => Array.from({ length }, (_, i) => i), [length]);\n\n return (\n \n {/* The row is one big hit target; the field underneath is the a11y node. */}\n \n \n {cells.map((i) => (\n \n 0 ? 'ms-2' : ''} ${cellClassName ?? ''}`,\n })}\n charClassName={slots.char()}\n caretClassName={slots.caret()}\n />\n {groupEvery > 0 &&\n (i + 1) % groupEvery === 0 &&\n i < length - 1 ? (\n \n ) : null}\n \n ))}\n \n\n {/*\n * The real field: one transparent input stretched over the whole row.\n * It carries the keyboard, autofill and paste, and its value is the\n * source the cells are drawn from — there is no per-cell input to keep\n * in sync. `pointerEvents` off so taps fall through to the Pressable,\n * which focuses this exact input.\n */}\n {\n setFocused(true);\n onFocus?.(e);\n }}\n onBlur={(e) => {\n setFocused(false);\n onBlur?.(e);\n }}\n style={[\n {\n position: 'absolute',\n top: 0,\n left: 0,\n right: 0,\n bottom: 0,\n opacity: 0,\n // Taps fall through to the Pressable, which focuses this input —\n // so the caret lands wherever the row is touched.\n pointerEvents: 'none',\n },\n style,\n ]}\n {...props}\n />\n \n\n {errorMessage ? (\n {errorMessage}\n ) : null}\n \n );\n }\n);\n\nOtpInput.displayName = 'OtpInput';\n" }, { "path": "ui/otp-input-state.ts", diff --git a/apps/docs/public/r/page-header.json b/apps/docs/public/r/page-header.json index 48f0cfa8a..754bdf655 100644 --- a/apps/docs/public/r/page-header.json +++ b/apps/docs/public/r/page-header.json @@ -7,6 +7,7 @@ "avatar", "cn", "icons", + "refs", "text" ], "dependencies": [ @@ -19,7 +20,7 @@ { "path": "ui/page-header.tsx", "type": "registry:ui", - "content": "/**\n * PageHeader — the top of a profile screen: a cover, the face over it, who it\n * belongs to, and what you can do about them.\n *\n * Every social app opens a profile the same way, and rebuilding it per screen\n * is how the cover ends up a different height on each one. The parts here are\n * the pieces that arrangement is made of, and the two variants are the two\n * places it lives: `card`, a self-contained surface in a scroll of other\n * cards, and `page`, the header of the screen itself.\n *\n * ```tsx\n * \n * \n * \n * \n * Olivia Rhye\n * @oliviarhye\n * \n * \n * \n * \n * \n * ```\n *\n * The cover in `card` is held off the card's edges and rounded on its top\n * corners only, so its bottom edge meets the content rather than floating\n * above it. The radius pair is `rounded-3xl` on the card and `rounded-2xl` on\n * the cover, one step apart on the theme's own scale — which is the same 8\n * points the card is padded by, so the two curves stay concentric without a\n * hardcoded number that would be wrong in half the themes.\n *\n * The avatar lifts itself over the cover's bottom edge, and it works out\n * whether to: the root looks for a `PageHeader.Cover` among its children and\n * says so through context, because a header with no cover has nothing to\n * overlap and an avatar that lifted anyway would hang off the top of it.\n *\n * The ring around the face is drawn in the surface behind it — the card, or\n * the page — rather than in a border colour, so the face reads as punched out\n * of the cover instead of outlined on top of it.\n */\nimport {\n Children,\n cloneElement,\n createContext,\n forwardRef,\n isValidElement,\n useContext,\n useMemo,\n type ReactNode,\n} from 'react';\nimport {\n Image,\n StyleSheet,\n View,\n type ImageSourcePropType,\n type PressableProps,\n type Text as RNText,\n type ViewProps,\n} from 'react-native';\nimport { LinearGradient } from 'expo-linear-gradient';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport { useCSSVariable } from 'uniwind';\nimport { AnimatedPressable } from '@/components/ui/animated-pressable';\nimport { Text, type TextProps, textChildren } from '@/components/ui/text';\nimport { cn } from '@/lib/cn';\nimport { BadgeCheckIcon, IconColorProvider } from '@/components/ui/icons';\nimport { Avatar, AVATAR_SIZE_POINTS, type AvatarProps, type AvatarSizeName } from '@/components/ui/avatar';\n\n/** Height of a cover that is not given one. Two lines of nothing, and enough\n * of a band for a face to sit half over. */\nconst COVER_HEIGHT = 120;\n\n/** Points of surface drawn around the face. */\nconst AVATAR_RING = 4;\n\n/** The rosette, as a share of the face it sits on. */\nconst BADGE_RATIO = 0.3;\n\n/** The cover gradient, when no image is given. Both are series tokens, so an\n * app that puts its charts on brand puts this on brand with them. */\nconst GRADIENT = ['--color-chart-2', '--color-chart-5'] as const;\nconst GRADIENT_FALLBACK = ['#3b82f6', '#8b5cf6'] as const;\n\nconst pageHeaderVariants = tv({\n slots: {\n root: 'w-full',\n cover: 'overflow-hidden bg-muted',\n avatar: 'self-start',\n ring: 'overflow-hidden rounded-full',\n row: 'flex-row items-center gap-5',\n content: 'gap-1',\n meta: 'flex-row items-center gap-1.5',\n stats: 'flex-row items-center',\n actions: 'flex-row items-center gap-2',\n },\n variants: {\n variant: {\n // `overflow-hidden` clips the cover's own square bottom corners to the\n // card's radius on the way down; `p-2` is what holds the cover off the\n // card's edges, and is the difference between the two radii above.\n card: {\n root: 'overflow-hidden rounded-3xl border border-border bg-card p-2',\n cover: 'rounded-t-2xl',\n ring: 'border-card',\n avatar: 'px-3',\n row: 'px-3',\n content: 'px-3 pt-3',\n actions: 'px-3 pb-2 pt-4',\n },\n // No surface of its own, and the cover runs to the screen's edges.\n page: {\n root: 'bg-background',\n ring: 'border-background',\n avatar: 'px-4',\n row: 'px-4',\n content: 'px-4 pt-3',\n actions: 'px-4 pb-4 pt-4',\n },\n },\n align: {\n start: {},\n center: {\n avatar: 'self-center',\n row: 'justify-center',\n content: 'items-center',\n meta: 'justify-center',\n stats: 'justify-center',\n actions: 'justify-center',\n },\n },\n },\n defaultVariants: {\n variant: 'card',\n align: 'center',\n },\n});\n\ntype PageHeaderVariantProps = VariantProps;\n\n/** Where the header lives: a card in a scroll, or the top of the screen. */\nexport type PageHeaderVariant = NonNullable;\n\n/** Which edge the face and the text line up on. */\nexport type PageHeaderAlign = NonNullable;\n\ninterface PageHeaderContextValue {\n variant: PageHeaderVariant;\n align: PageHeaderAlign;\n hasCover: boolean;\n}\n\nconst PageHeaderContext = createContext(undefined);\n\nfunction usePageHeader(component: string): PageHeaderContextValue {\n const context = useContext(PageHeaderContext);\n if (!context) throw new Error(`${component} must be used within a `);\n return context;\n}\n\n/**\n * True inside a `PageHeader.Row`. The avatar carries the body's inset and its\n * own alignment when it stands alone, and neither when something else is\n * already holding the row it sits in.\n */\nconst PageHeaderRowContext = createContext(false);\n\n/** How a stats row reads its numbers out. */\nconst PageHeaderStatsContext = createContext('stacked');\n\nexport interface PageHeaderProps extends ViewProps {\n className?: string;\n children?: ReactNode;\n}\n\nexport interface PageHeaderRootProps extends PageHeaderProps {\n /**\n * `card` is a surface of its own, with the cover held off its edges. `page`\n * drops the surface and lets the cover run to the screen's edges.\n */\n variant?: PageHeaderVariant;\n /**\n * Which edge the face, the text and the actions line up on. `center` is the\n * profile card; `start` is the screen header, where the name is the first\n * thing on the line rather than the middle of it.\n */\n align?: PageHeaderAlign;\n}\n\nconst PageHeaderRoot = forwardRef(\n ({ className, variant = 'card', align = 'center', children, ...props }, ref) => {\n // Whether the avatar has anything to overlap. Read off the children rather\n // than asked for, because a caller who has written a cover should not also\n // have to say that they have.\n const hasCover = Children.toArray(children).some(\n (child) => isValidElement(child) && child.type === PageHeaderCover\n );\n\n const context = useMemo(\n () => ({ variant, align, hasCover }),\n [variant, align, hasCover]\n );\n\n return (\n \n {children}\n \n );\n }\n);\nPageHeaderRoot.displayName = 'PageHeader';\n\nexport interface PageHeaderCoverProps extends ViewProps {\n className?: string;\n /** The banner. Left out, the cover draws a gradient instead. */\n source?: ImageSourcePropType;\n /** How tall the band is. */\n height?: number;\n /**\n * The gradient, when there is no image. Two colours or more, as real colour\n * strings — this is painted rather than classed.\n */\n colors?: readonly [string, string, ...string[]];\n /** What the banner shows. Left out, it is treated as decoration. */\n alt?: string;\n children?: ReactNode;\n}\n\n/**\n * The banner. An image when given one, a gradient when not — a header with no\n * banner still has to read as a header, and an empty band reads as a bug.\n */\nconst PageHeaderCover = forwardRef(\n ({ className, source, height = COVER_HEIGHT, colors, alt, style, children, ...props }, ref) => {\n const { variant } = usePageHeader('PageHeader.Cover');\n const from = useCSSVariable(GRADIENT[0]);\n const to = useCSSVariable(GRADIENT[1]);\n\n // `useCSSVariable` can hand back something that is not a colour string, and\n // a gradient given one paints nothing at all.\n const ramp =\n colors ??\n ([\n typeof from === 'string' ? from : GRADIENT_FALLBACK[0],\n typeof to === 'string' ? to : GRADIENT_FALLBACK[1],\n ] as const);\n\n return (\n \n {source ? (\n \n ) : (\n \n )}\n {children}\n \n );\n }\n);\nPageHeaderCover.displayName = 'PageHeader.Cover';\n\nexport interface PageHeaderAvatarProps extends Omit {\n className?: string;\n /** How big the face is. */\n size?: AvatarSizeName;\n /**\n * What goes in the ring instead of a face — a logo, a monogram, a live\n * thumbnail. It fills the ring, so give it its own background and padding.\n */\n children?: ReactNode;\n /** Draws the verification rosette in the face's bottom corner. */\n verified?: boolean;\n /**\n * Anything else for that corner — a camera button, a presence dot, a \"+\".\n * Wins over `verified`.\n */\n badge?: ReactNode;\n /**\n * Whether the face lifts over the cover's bottom edge. Set by the presence\n * of a `PageHeader.Cover`; pass it to override that either way.\n */\n overlap?: boolean;\n}\n\n/**\n * The face, in a ring of whatever surface is behind it.\n *\n * The badge is drawn here rather than with `Avatar.Badge` because that one is\n * pinned to the top corner, where an unread count belongs. A verification mark\n * belongs at the bottom, beside the name it is vouching for.\n */\nconst PageHeaderAvatar = forwardRef(\n (\n {\n className,\n size = 'xl',\n verified,\n badge,\n overlap,\n source,\n fallback,\n imageProps,\n children,\n style,\n ...props\n },\n ref\n ) => {\n const { variant, align, hasCover } = usePageHeader('PageHeader.Avatar');\n const inRow = useContext(PageHeaderRowContext);\n const slots = pageHeaderVariants({ variant, align });\n const primary = useCSSVariable('--color-primary');\n const onPrimary = useCSSVariable('--color-primary-foreground');\n\n const diameter = AVATAR_SIZE_POINTS[size] ?? AVATAR_SIZE_POINTS.xl;\n const lifts = overlap ?? hasCover;\n const mark = badge ?? (verified ? (\n \n ) : null);\n\n return (\n \n {/*\n Two views, because the ring clips. A badge hung on the clipping one\n would be cut in half by the circle it is meant to sit against.\n */}\n \n {children ?? (\n \n )}\n \n {mark ? {mark} : null}\n \n );\n }\n);\nPageHeaderAvatar.displayName = 'PageHeader.Avatar';\n\nexport interface PageHeaderRowProps extends PageHeaderProps {\n children?: ReactNode;\n}\n\n/**\n * The face, and whatever sits beside it. For the profile that puts its counts\n * next to the picture rather than under the name.\n */\nconst PageHeaderRow = forwardRef(\n ({ className, children, ...props }, ref) => {\n const { variant, align } = usePageHeader('PageHeader.Row');\n return (\n \n {children}\n \n );\n }\n);\nPageHeaderRow.displayName = 'PageHeader.Row';\n\nexport interface PageHeaderContentProps extends PageHeaderProps {\n children?: ReactNode;\n}\n\n/** The text block: the name, the handle, and anything under them. */\nconst PageHeaderContent = forwardRef(\n ({ className, children, ...props }, ref) => {\n const { variant, align } = usePageHeader('PageHeader.Content');\n return (\n \n {textChildren(children)}\n \n );\n }\n);\nPageHeaderContent.displayName = 'PageHeader.Content';\n\n/** Whose page it is. Announces itself as a heading. */\nconst PageHeaderTitle = forwardRef(({ className, ...props }, ref) => (\n \n));\nPageHeaderTitle.displayName = 'PageHeader.Title';\n\n/** The handle, the email, the bio — the quiet line under the name. */\nconst PageHeaderDescription = forwardRef(({ className, ...props }, ref) => (\n \n));\nPageHeaderDescription.displayName = 'PageHeader.Description';\n\nexport interface PageHeaderMetaProps extends PageHeaderProps {\n /** A glyph before the line. Takes the muted colour without being told. */\n icon?: ReactNode;\n children?: ReactNode;\n}\n\n/**\n * One fact about the account — a link, a location, the month it was opened.\n */\nconst PageHeaderMeta = forwardRef(\n ({ className, icon, children, ...props }, ref) => {\n const { variant, align } = usePageHeader('PageHeader.Meta');\n const muted = useCSSVariable('--color-muted-foreground');\n return (\n \n {icon ? (\n \n {icon}\n \n ) : null}\n {textChildren(children, (text) => (\n \n {text}\n \n ))}\n \n );\n }\n);\nPageHeaderMeta.displayName = 'PageHeader.Meta';\n\n/** How a stats row reads its numbers out. */\nexport type PageHeaderStatsLayout = 'stacked' | 'inline';\n\nexport interface PageHeaderStatsProps extends PageHeaderProps {\n /**\n * `stacked` puts the label under the figure, for a row of counts read as a\n * set. `inline` runs them together — \"533 Followers\" — for counts read as\n * part of a sentence.\n */\n layout?: PageHeaderStatsLayout;\n /** Rule between each count and the next. */\n divided?: boolean;\n children?: ReactNode;\n}\n\n/**\n * The row of counts.\n *\n * Centred and stacked, the counts take equal widths across the full row. Left\n * to their content they are centred as a block but not as figures: \"Followers\"\n * is twice the width of \"Posts\", so the middle count sits off the centre line\n * the name and the buttons are on, and the row reads as very slightly wrong\n * without it being obvious why.\n */\nconst PageHeaderStats = forwardRef(\n ({ className, layout = 'stacked', divided, children, ...props }, ref) => {\n const { variant, align } = usePageHeader('PageHeader.Stats');\n const stacked = layout === 'stacked';\n const measured = stacked && align === 'center';\n\n // React Native has no `:first-child`, so the rule between two counts\n // cannot be a style rule. Place it on every count but the first.\n const items =\n divided || measured\n ? Children.map(children, (child, index) => {\n if (!isValidElement(child)) return child;\n if (child.type !== PageHeaderStat) return child;\n const next: Partial = {};\n if (divided && child.props.divided === undefined) {\n next.divided = index > 0;\n }\n if (measured) {\n next.className = cn('flex-1', child.props.className);\n }\n return cloneElement(child, next);\n })\n : children;\n\n return (\n \n \n {items}\n \n \n );\n }\n);\nPageHeaderStats.displayName = 'PageHeader.Stats';\n\nexport interface PageHeaderStatProps extends Omit {\n className?: string;\n /** The figure. */\n value: ReactNode;\n /** What it counts. */\n label: ReactNode;\n /** Rule before this count. `PageHeader.Stats` sets it; pass it to override. */\n divided?: boolean;\n}\n\n/**\n * One count. Announced as a single thing — \"533 Followers\" — because a figure\n * and its label read apart are two pieces of nothing.\n */\nconst PageHeaderStat = forwardRef(\n ({ className, value, label, divided, onPress, accessibilityLabel, ...props }, ref) => {\n const layout = useContext(PageHeaderStatsContext);\n const inline = layout === 'inline';\n\n const spoken =\n accessibilityLabel ??\n (isSpeakable(value) && isSpeakable(label) ? `${value} ${label}` : undefined);\n\n const classes = cn(\n inline ? 'flex-row items-center gap-1' : 'items-center gap-0.5',\n divided && 'border-s border-border ps-4',\n className\n );\n\n const body = (\n <>\n \n {value}\n \n \n {label}\n \n \n );\n\n if (!onPress) {\n return (\n \n {body}\n \n );\n }\n\n return (\n \n {body}\n \n );\n }\n);\nPageHeaderStat.displayName = 'PageHeader.Stat';\n\n/** Whether a node can be read into an accessibility label as it stands. */\nfunction isSpeakable(node: ReactNode): node is string | number {\n return typeof node === 'string' || typeof node === 'number';\n}\n\nexport interface PageHeaderActionsProps extends PageHeaderProps {\n children?: ReactNode;\n}\n\n/**\n * What you can do about the account. Give the buttons `className=\"flex-1\"` for\n * the pair that splits the width evenly.\n */\nconst PageHeaderActions = forwardRef(\n ({ className, children, ...props }, ref) => {\n const { variant, align } = usePageHeader('PageHeader.Actions');\n return (\n \n {textChildren(children)}\n \n );\n }\n);\nPageHeaderActions.displayName = 'PageHeader.Actions';\n\nexport const PageHeader = Object.assign(PageHeaderRoot, {\n Cover: PageHeaderCover,\n Avatar: PageHeaderAvatar,\n Row: PageHeaderRow,\n Content: PageHeaderContent,\n Title: PageHeaderTitle,\n Description: PageHeaderDescription,\n Meta: PageHeaderMeta,\n Stats: PageHeaderStats,\n Stat: PageHeaderStat,\n Actions: PageHeaderActions,\n});\n" + "content": "/**\n * PageHeader — the top of a profile screen: a cover, the face over it, who it\n * belongs to, and what you can do about them.\n *\n * Every social app opens a profile the same way, and rebuilding it per screen\n * is how the cover ends up a different height on each one. The parts here are\n * the pieces that arrangement is made of, and the two variants are the two\n * places it lives: `card`, a self-contained surface in a scroll of other\n * cards, and `page`, the header of the screen itself.\n *\n * ```tsx\n * \n * \n * \n * \n * Olivia Rhye\n * @oliviarhye\n * \n * \n * \n * \n * \n * ```\n *\n * The cover in `card` is held off the card's edges and rounded on its top\n * corners only, so its bottom edge meets the content rather than floating\n * above it. The radius pair is `rounded-3xl` on the card and `rounded-2xl` on\n * the cover, one step apart on the theme's own scale — which is the same 8\n * points the card is padded by, so the two curves stay concentric without a\n * hardcoded number that would be wrong in half the themes.\n *\n * The avatar lifts itself over the cover's bottom edge, and it works out\n * whether to: the root looks for a `PageHeader.Cover` among its children and\n * says so through context, because a header with no cover has nothing to\n * overlap and an avatar that lifted anyway would hang off the top of it.\n *\n * The ring around the face is drawn in the surface behind it — the card, or\n * the page — rather than in a border colour, so the face reads as punched out\n * of the cover instead of outlined on top of it.\n */\nimport {\n Children,\n cloneElement,\n createContext,\n forwardRef,\n isValidElement,\n useContext,\n useMemo,\n type ReactNode,\n} from 'react';\nimport {\n Image,\n StyleSheet,\n View,\n type ImageSourcePropType,\n type PressableProps,\n type Text as RNText,\n type ViewProps,\n} from 'react-native';\nimport { LinearGradient } from 'expo-linear-gradient';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport { useCSSVariable } from 'uniwind';\nimport { AnimatedPressable } from '@/components/ui/animated-pressable';\nimport { Text, type TextProps, textChildren } from '@/components/ui/text';\nimport { cn } from '@/lib/cn';\nimport { BadgeCheckIcon, IconColorProvider } from '@/components/ui/icons';\nimport { Avatar, AVATAR_SIZE_POINTS, type AvatarProps, type AvatarSizeName } from '@/components/ui/avatar';\nimport type { TextRef, ViewRef } from '@/components/ui/refs';\n\n/** Height of a cover that is not given one. Two lines of nothing, and enough\n * of a band for a face to sit half over. */\nconst COVER_HEIGHT = 120;\n\n/** Points of surface drawn around the face. */\nconst AVATAR_RING = 4;\n\n/** The rosette, as a share of the face it sits on. */\nconst BADGE_RATIO = 0.3;\n\n/** The cover gradient, when no image is given. Both are series tokens, so an\n * app that puts its charts on brand puts this on brand with them. */\nconst GRADIENT = ['--color-chart-2', '--color-chart-5'] as const;\nconst GRADIENT_FALLBACK = ['#3b82f6', '#8b5cf6'] as const;\n\nconst pageHeaderVariants = tv({\n slots: {\n root: 'w-full',\n cover: 'overflow-hidden bg-muted',\n avatar: 'self-start',\n ring: 'overflow-hidden rounded-full',\n row: 'flex-row items-center gap-5',\n content: 'gap-1',\n meta: 'flex-row items-center gap-1.5',\n stats: 'flex-row items-center',\n actions: 'flex-row items-center gap-2',\n },\n variants: {\n variant: {\n // `overflow-hidden` clips the cover's own square bottom corners to the\n // card's radius on the way down; `p-2` is what holds the cover off the\n // card's edges, and is the difference between the two radii above.\n card: {\n root: 'overflow-hidden rounded-3xl border border-border bg-card p-2',\n cover: 'rounded-t-2xl',\n ring: 'border-card',\n avatar: 'px-3',\n row: 'px-3',\n content: 'px-3 pt-3',\n actions: 'px-3 pb-2 pt-4',\n },\n // No surface of its own, and the cover runs to the screen's edges.\n page: {\n root: 'bg-background',\n ring: 'border-background',\n avatar: 'px-4',\n row: 'px-4',\n content: 'px-4 pt-3',\n actions: 'px-4 pb-4 pt-4',\n },\n },\n align: {\n start: {},\n center: {\n avatar: 'self-center',\n row: 'justify-center',\n content: 'items-center',\n meta: 'justify-center',\n stats: 'justify-center',\n actions: 'justify-center',\n },\n },\n },\n defaultVariants: {\n variant: 'card',\n align: 'center',\n },\n});\n\ntype PageHeaderVariantProps = VariantProps;\n\n/** Where the header lives: a card in a scroll, or the top of the screen. */\nexport type PageHeaderVariant = NonNullable;\n\n/** Which edge the face and the text line up on. */\nexport type PageHeaderAlign = NonNullable;\n\ninterface PageHeaderContextValue {\n variant: PageHeaderVariant;\n align: PageHeaderAlign;\n hasCover: boolean;\n}\n\nconst PageHeaderContext = createContext(undefined);\n\nfunction usePageHeader(component: string): PageHeaderContextValue {\n const context = useContext(PageHeaderContext);\n if (!context) throw new Error(`${component} must be used within a `);\n return context;\n}\n\n/**\n * True inside a `PageHeader.Row`. The avatar carries the body's inset and its\n * own alignment when it stands alone, and neither when something else is\n * already holding the row it sits in.\n */\nconst PageHeaderRowContext = createContext(false);\n\n/** How a stats row reads its numbers out. */\nconst PageHeaderStatsContext = createContext('stacked');\n\nexport interface PageHeaderProps extends ViewProps {\n className?: string;\n children?: ReactNode;\n}\n\nexport interface PageHeaderRootProps extends PageHeaderProps {\n /**\n * `card` is a surface of its own, with the cover held off its edges. `page`\n * drops the surface and lets the cover run to the screen's edges.\n */\n variant?: PageHeaderVariant;\n /**\n * Which edge the face, the text and the actions line up on. `center` is the\n * profile card; `start` is the screen header, where the name is the first\n * thing on the line rather than the middle of it.\n */\n align?: PageHeaderAlign;\n}\n\nconst PageHeaderRoot = forwardRef(\n ({ className, variant = 'card', align = 'center', children, ...props }, ref) => {\n // Whether the avatar has anything to overlap. Read off the children rather\n // than asked for, because a caller who has written a cover should not also\n // have to say that they have.\n const hasCover = Children.toArray(children).some(\n (child) => isValidElement(child) && child.type === PageHeaderCover\n );\n\n const context = useMemo(\n () => ({ variant, align, hasCover }),\n [variant, align, hasCover]\n );\n\n return (\n \n {children}\n \n );\n }\n);\nPageHeaderRoot.displayName = 'PageHeader';\n\nexport interface PageHeaderCoverProps extends ViewProps {\n className?: string;\n /** The banner. Left out, the cover draws a gradient instead. */\n source?: ImageSourcePropType;\n /** How tall the band is. */\n height?: number;\n /**\n * The gradient, when there is no image. Two colours or more, as real colour\n * strings — this is painted rather than classed.\n */\n colors?: readonly [string, string, ...string[]];\n /** What the banner shows. Left out, it is treated as decoration. */\n alt?: string;\n children?: ReactNode;\n}\n\n/**\n * The banner. An image when given one, a gradient when not — a header with no\n * banner still has to read as a header, and an empty band reads as a bug.\n */\nconst PageHeaderCover = forwardRef(\n ({ className, source, height = COVER_HEIGHT, colors, alt, style, children, ...props }, ref) => {\n const { variant } = usePageHeader('PageHeader.Cover');\n const from = useCSSVariable(GRADIENT[0]);\n const to = useCSSVariable(GRADIENT[1]);\n\n // `useCSSVariable` can hand back something that is not a colour string, and\n // a gradient given one paints nothing at all.\n const ramp =\n colors ??\n ([\n typeof from === 'string' ? from : GRADIENT_FALLBACK[0],\n typeof to === 'string' ? to : GRADIENT_FALLBACK[1],\n ] as const);\n\n return (\n \n {source ? (\n \n ) : (\n \n )}\n {children}\n \n );\n }\n);\nPageHeaderCover.displayName = 'PageHeader.Cover';\n\nexport interface PageHeaderAvatarProps extends Omit {\n className?: string;\n /** How big the face is. */\n size?: AvatarSizeName;\n /**\n * What goes in the ring instead of a face — a logo, a monogram, a live\n * thumbnail. It fills the ring, so give it its own background and padding.\n */\n children?: ReactNode;\n /** Draws the verification rosette in the face's bottom corner. */\n verified?: boolean;\n /**\n * Anything else for that corner — a camera button, a presence dot, a \"+\".\n * Wins over `verified`.\n */\n badge?: ReactNode;\n /**\n * Whether the face lifts over the cover's bottom edge. Set by the presence\n * of a `PageHeader.Cover`; pass it to override that either way.\n */\n overlap?: boolean;\n}\n\n/**\n * The face, in a ring of whatever surface is behind it.\n *\n * The badge is drawn here rather than with `Avatar.Badge` because that one is\n * pinned to the top corner, where an unread count belongs. A verification mark\n * belongs at the bottom, beside the name it is vouching for.\n */\nconst PageHeaderAvatar = forwardRef(\n (\n {\n className,\n size = 'xl',\n verified,\n badge,\n overlap,\n source,\n fallback,\n imageProps,\n children,\n style,\n ...props\n },\n ref\n ) => {\n const { variant, align, hasCover } = usePageHeader('PageHeader.Avatar');\n const inRow = useContext(PageHeaderRowContext);\n const slots = pageHeaderVariants({ variant, align });\n const primary = useCSSVariable('--color-primary');\n const onPrimary = useCSSVariable('--color-primary-foreground');\n\n const diameter = AVATAR_SIZE_POINTS[size] ?? AVATAR_SIZE_POINTS.xl;\n const lifts = overlap ?? hasCover;\n const mark = badge ?? (verified ? (\n \n ) : null);\n\n return (\n \n {/*\n Two views, because the ring clips. A badge hung on the clipping one\n would be cut in half by the circle it is meant to sit against.\n */}\n \n {children ?? (\n \n )}\n \n {mark ? {mark} : null}\n \n );\n }\n);\nPageHeaderAvatar.displayName = 'PageHeader.Avatar';\n\nexport interface PageHeaderRowProps extends PageHeaderProps {\n children?: ReactNode;\n}\n\n/**\n * The face, and whatever sits beside it. For the profile that puts its counts\n * next to the picture rather than under the name.\n */\nconst PageHeaderRow = forwardRef(\n ({ className, children, ...props }, ref) => {\n const { variant, align } = usePageHeader('PageHeader.Row');\n return (\n \n {children}\n \n );\n }\n);\nPageHeaderRow.displayName = 'PageHeader.Row';\n\nexport interface PageHeaderContentProps extends PageHeaderProps {\n children?: ReactNode;\n}\n\n/** The text block: the name, the handle, and anything under them. */\nconst PageHeaderContent = forwardRef(\n ({ className, children, ...props }, ref) => {\n const { variant, align } = usePageHeader('PageHeader.Content');\n return (\n \n {textChildren(children)}\n \n );\n }\n);\nPageHeaderContent.displayName = 'PageHeader.Content';\n\n/** Whose page it is. Announces itself as a heading. */\nconst PageHeaderTitle = forwardRef(({ className, ...props }, ref) => (\n \n));\nPageHeaderTitle.displayName = 'PageHeader.Title';\n\n/** The handle, the email, the bio — the quiet line under the name. */\nconst PageHeaderDescription = forwardRef(({ className, ...props }, ref) => (\n \n));\nPageHeaderDescription.displayName = 'PageHeader.Description';\n\nexport interface PageHeaderMetaProps extends PageHeaderProps {\n /** A glyph before the line. Takes the muted colour without being told. */\n icon?: ReactNode;\n children?: ReactNode;\n}\n\n/**\n * One fact about the account — a link, a location, the month it was opened.\n */\nconst PageHeaderMeta = forwardRef(\n ({ className, icon, children, ...props }, ref) => {\n const { variant, align } = usePageHeader('PageHeader.Meta');\n const muted = useCSSVariable('--color-muted-foreground');\n return (\n \n {icon ? (\n \n {icon}\n \n ) : null}\n {textChildren(children, (text) => (\n \n {text}\n \n ))}\n \n );\n }\n);\nPageHeaderMeta.displayName = 'PageHeader.Meta';\n\n/** How a stats row reads its numbers out. */\nexport type PageHeaderStatsLayout = 'stacked' | 'inline';\n\nexport interface PageHeaderStatsProps extends PageHeaderProps {\n /**\n * `stacked` puts the label under the figure, for a row of counts read as a\n * set. `inline` runs them together — \"533 Followers\" — for counts read as\n * part of a sentence.\n */\n layout?: PageHeaderStatsLayout;\n /** Rule between each count and the next. */\n divided?: boolean;\n children?: ReactNode;\n}\n\n/**\n * The row of counts.\n *\n * Centred and stacked, the counts take equal widths across the full row. Left\n * to their content they are centred as a block but not as figures: \"Followers\"\n * is twice the width of \"Posts\", so the middle count sits off the centre line\n * the name and the buttons are on, and the row reads as very slightly wrong\n * without it being obvious why.\n */\nconst PageHeaderStats = forwardRef(\n ({ className, layout = 'stacked', divided, children, ...props }, ref) => {\n const { variant, align } = usePageHeader('PageHeader.Stats');\n const stacked = layout === 'stacked';\n const measured = stacked && align === 'center';\n\n // React Native has no `:first-child`, so the rule between two counts\n // cannot be a style rule. Place it on every count but the first.\n const items =\n divided || measured\n ? Children.map(children, (child, index) => {\n if (!isValidElement(child)) return child;\n if (child.type !== PageHeaderStat) return child;\n const next: Partial = {};\n if (divided && child.props.divided === undefined) {\n next.divided = index > 0;\n }\n if (measured) {\n next.className = cn('flex-1', child.props.className);\n }\n return cloneElement(child, next);\n })\n : children;\n\n return (\n \n \n {items}\n \n \n );\n }\n);\nPageHeaderStats.displayName = 'PageHeader.Stats';\n\nexport interface PageHeaderStatProps extends Omit {\n className?: string;\n /** The figure. */\n value: ReactNode;\n /** What it counts. */\n label: ReactNode;\n /** Rule before this count. `PageHeader.Stats` sets it; pass it to override. */\n divided?: boolean;\n}\n\n/**\n * One count. Announced as a single thing — \"533 Followers\" — because a figure\n * and its label read apart are two pieces of nothing.\n */\nconst PageHeaderStat = forwardRef(\n ({ className, value, label, divided, onPress, accessibilityLabel, ...props }, ref) => {\n const layout = useContext(PageHeaderStatsContext);\n const inline = layout === 'inline';\n\n const spoken =\n accessibilityLabel ??\n (isSpeakable(value) && isSpeakable(label) ? `${value} ${label}` : undefined);\n\n const classes = cn(\n inline ? 'flex-row items-center gap-1' : 'items-center gap-0.5',\n divided && 'border-s border-border ps-4',\n className\n );\n\n const body = (\n <>\n \n {value}\n \n \n {label}\n \n \n );\n\n if (!onPress) {\n return (\n \n {body}\n \n );\n }\n\n return (\n \n {body}\n \n );\n }\n);\nPageHeaderStat.displayName = 'PageHeader.Stat';\n\n/** Whether a node can be read into an accessibility label as it stands. */\nfunction isSpeakable(node: ReactNode): node is string | number {\n return typeof node === 'string' || typeof node === 'number';\n}\n\nexport interface PageHeaderActionsProps extends PageHeaderProps {\n children?: ReactNode;\n}\n\n/**\n * What you can do about the account. Give the buttons `className=\"flex-1\"` for\n * the pair that splits the width evenly.\n */\nconst PageHeaderActions = forwardRef(\n ({ className, children, ...props }, ref) => {\n const { variant, align } = usePageHeader('PageHeader.Actions');\n return (\n \n {textChildren(children)}\n \n );\n }\n);\nPageHeaderActions.displayName = 'PageHeader.Actions';\n\nexport const PageHeader = Object.assign(PageHeaderRoot, {\n Cover: PageHeaderCover,\n Avatar: PageHeaderAvatar,\n Row: PageHeaderRow,\n Content: PageHeaderContent,\n Title: PageHeaderTitle,\n Description: PageHeaderDescription,\n Meta: PageHeaderMeta,\n Stats: PageHeaderStats,\n Stat: PageHeaderStat,\n Actions: PageHeaderActions,\n});\n" } ] } diff --git a/apps/docs/public/r/pagination.json b/apps/docs/public/r/pagination.json index cecc98bac..f47ca84d7 100644 --- a/apps/docs/public/r/pagination.json +++ b/apps/docs/public/r/pagination.json @@ -7,6 +7,7 @@ "cn", "haptics", "icons", + "refs", "text", "use-direction" ], @@ -19,7 +20,7 @@ { "path": "ui/pagination.tsx", "type": "registry:ui", - "content": "/**\n * Pagination — moving through a result set one page at a time.\n *\n * The component owns the arithmetic and nothing else. Give it how many pages\n * there are and which one you are on, and it works out which numbers to show,\n * where the gaps fall, and which controls are dead at the ends; it never fetches\n * and never slices, because the data is yours.\n *\n * ```tsx\n * \n * ```\n *\n * Three presentations, because a phone is not a desktop:\n *\n * - `numbers` — the full run, with the middle folded into ellipses. Fits about\n * seven targets across a phone, which is what `siblings` and `boundaries`\n * are tuned for.\n * - `compact` — the two arrows with `3 / 12` between them. Digits and a slash,\n * so there is no sentence to translate.\n * - `simple` — labelled Previous and Next, for a flow you walk rather than\n * jump around in: a wizard, an article, a set of onboarding cards.\n *\n * Every target is at least 44pt in both axes, and the small size keeps that\n * reach with `hitSlop` rather than by growing — the row gets denser, the\n * fingers do not get smaller.\n *\n * An ellipsis is a button, not punctuation. A dead 44pt target in the middle of\n * a row of live ones is a thing people tap and then think is broken, so tapping\n * one jumps `pageJump` pages that way.\n *\n * Given children, the root becomes a row with them on the leading edge and the\n * controls on the trailing one — which is where `Pagination.Status` goes:\n *\n * ```tsx\n * \n * \n * \n * ```\n */\nimport {\n createContext,\n forwardRef,\n useCallback,\n useContext,\n useMemo,\n useState,\n type ReactNode,\n} from 'react';\nimport { View, type ViewProps } from 'react-native';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport { useCSSVariable } from 'uniwind';\nimport { ChevronLeftIcon, ChevronRightIcon, EllipsisIcon } from '@/components/ui/icons';\nimport { useDirection } from '@/hooks/use-direction';\nimport {\n AnimatedPressable,\n type AnimatedPressableProps,\n} from '@/components/ui/animated-pressable';\nimport { Text, type TextProps, textChildren } from '@/components/ui/text';\nimport { cn } from '@/lib/cn';\nimport { selectionTick } from '@/lib/haptics';\n\nexport type PaginationVariant = 'numbers' | 'compact' | 'simple';\nexport type PaginationSize = 'sm' | 'default';\n\n/**\n * What the small size gives back as padding. 44pt is the smallest target a\n * finger reliably hits, and the row is allowed to get tighter than that only\n * because the reach is restored outside the paint — so this is exactly the\n * difference between the small target's 32pt and that 44.\n */\nconst SMALL_HIT_SLOP = { top: 6, bottom: 6, left: 6, right: 6 } as const;\n\nconst paginationVariants = tv({\n slots: {\n root: 'flex-row items-center',\n list: 'flex-row items-center',\n item: 'items-center justify-center rounded-lg',\n itemLabel: 'font-medium tabular-nums',\n ellipsis: 'items-center justify-center',\n status: 'text-muted-foreground tabular-nums',\n summary: 'text-muted-foreground tabular-nums',\n },\n variants: {\n size: {\n default: {\n list: 'gap-1',\n item: 'h-11 min-w-11 px-1',\n itemLabel: 'text-sm',\n ellipsis: 'h-11 w-11',\n status: 'text-sm',\n summary: 'text-sm',\n },\n sm: {\n list: 'gap-0.5',\n item: 'h-8 min-w-8 px-1',\n itemLabel: 'text-xs',\n ellipsis: 'h-8 w-8',\n status: 'text-xs',\n summary: 'text-xs',\n },\n },\n /** Whether the item is the page you are on. */\n current: {\n true: { item: 'bg-primary', itemLabel: 'text-primary-foreground' },\n false: { itemLabel: 'text-foreground' },\n },\n disabled: {\n true: { item: 'opacity-[0.4]' },\n },\n },\n defaultVariants: {\n size: 'default',\n current: false,\n },\n});\n\nconst iconSize: Record = { default: 20, sm: 16 };\n\n/**\n * The state every part reads. A `Pagination.Previous` dropped into a row of\n * your own still knows which page it is on and whether it is at the start,\n * because that answer lives here rather than in the props it was given.\n */\ninterface PaginationContextValue {\n page: number;\n count: number;\n size: PaginationSize;\n disabled: boolean;\n goTo: (page: number) => void;\n}\n\nconst PaginationContext = createContext(null);\n\nfunction usePagination(part: string): PaginationContextValue {\n const context = useContext(PaginationContext);\n if (!context) {\n throw new Error(`${part} must be used inside .`);\n }\n return context;\n}\n\nconst range = (start: number, end: number): number[] =>\n end < start ? [] : Array.from({ length: end - start + 1 }, (_, i) => start + i);\n\nexport type PaginationItemValue = number | 'start-ellipsis' | 'end-ellipsis';\n\n/**\n * Which numbers to draw, and where the gaps go.\n *\n * The run is a fixed width rather than a sliding window: the same number of\n * targets whichever page you are on, so the row does not reflow under the\n * finger as you step through it and the arrow you were aiming at stays put.\n * `boundaries` pins the ends, `siblings` pads the middle, and a gap of exactly\n * one page is drawn as that page instead of an ellipsis — a `…` that hides a\n * single number is longer than the number.\n *\n * Exported because a caller sometimes needs the same run for a control of their\n * own, and two implementations of this would drift.\n */\nexport function paginationRange({\n count,\n page,\n siblings = 1,\n boundaries = 1,\n}: {\n count: number;\n page: number;\n siblings?: number;\n boundaries?: number;\n}): PaginationItemValue[] {\n const startPages = range(1, Math.min(boundaries, count));\n const endPages = range(Math.max(count - boundaries + 1, boundaries + 1), count);\n\n const siblingsStart = Math.max(\n Math.min(page - siblings, count - boundaries - siblings * 2 - 1),\n boundaries + 2\n );\n const siblingsEnd = Math.min(\n Math.max(page + siblings, boundaries + siblings * 2 + 2),\n endPages.length > 0 ? (endPages[0] as number) - 2 : count - 1\n );\n\n return [\n ...startPages,\n ...(siblingsStart > boundaries + 2\n ? (['start-ellipsis'] as PaginationItemValue[])\n : boundaries + 1 < count - boundaries\n ? [boundaries + 1]\n : []),\n ...range(siblingsStart, siblingsEnd),\n ...(siblingsEnd < count - boundaries - 1\n ? (['end-ellipsis'] as PaginationItemValue[])\n : count - boundaries > boundaries\n ? [count - boundaries]\n : []),\n ...endPages,\n ];\n}\n\nexport interface PaginationProps\n extends Omit,\n VariantProps {\n className?: string;\n /**\n * How many pages there are. Pages are numbered from 1, so this is also the\n * last page's number.\n */\n count: number;\n /** The page being shown. Pass it to control the component. */\n page?: number;\n /** The page to start on when the component keeps its own. Defaults to 1. */\n defaultPage?: number;\n /** Called with the page that was asked for, already clamped to `count`. */\n onPageChange?: (page: number) => void;\n /** Which presentation to draw. */\n variant?: PaginationVariant;\n size?: PaginationSize;\n /**\n * How many pages to keep either side of the current one. Raise it on a\n * tablet, where there is room for a longer run.\n */\n siblings?: number;\n /** How many pages to keep pinned at each end of the run. */\n boundaries?: number;\n /**\n * Show the previous and next arrows. Turning them off leaves the numbers\n * alone, so only do it where something else moves the page — a swipe, a\n * scroller reaching its end.\n */\n controls?: boolean;\n /** How far tapping an ellipsis jumps. */\n pageJump?: number;\n /** Greys out and deafens the whole row — for a page that is still loading. */\n disabled?: boolean;\n /** Labels the row for a screen reader. Defaults to \"Pagination\". */\n accessibilityLabel?: string;\n /** Leading content — a `Pagination.Status`, a page-size control of your own. */\n children?: ReactNode;\n}\n\nconst PaginationRoot = forwardRef(\n (\n {\n className,\n count,\n page: controlledPage,\n defaultPage = 1,\n onPageChange,\n variant = 'numbers',\n size = 'default',\n siblings = 1,\n boundaries = 1,\n controls = true,\n pageJump = 5,\n disabled = false,\n accessibilityLabel = 'Pagination',\n children,\n ...props\n },\n ref\n ) => {\n const [internalPage, setInternalPage] = useState(defaultPage);\n const isControlled = controlledPage !== undefined;\n // Clamped on the way out as well as on the way in: `count` can shrink under\n // a controlled page — a filter narrowing the result set — and a page number\n // past the end would light nothing and disable both arrows at once.\n const page = Math.min(Math.max(isControlled ? controlledPage : internalPage, 1), count);\n\n const goTo = useCallback(\n (next: number) => {\n const clamped = Math.min(Math.max(next, 1), count);\n if (clamped === page || disabled) return;\n // On the frame of the press rather than after the fetch: the tick is\n // what ties the feeling to the tap, and the rows arrive whenever they\n // arrive.\n selectionTick();\n if (!isControlled) setInternalPage(clamped);\n onPageChange?.(clamped);\n },\n [count, page, disabled, isControlled, onPageChange]\n );\n\n const context = useMemo(\n () => ({ page, count, size, disabled, goTo }),\n [page, count, size, disabled, goTo]\n );\n\n const items = useMemo(\n () =>\n variant === 'numbers'\n ? paginationRange({ count, page, siblings, boundaries })\n : [],\n [variant, count, page, siblings, boundaries]\n );\n\n const { root, list } = paginationVariants({ size });\n\n return (\n \n \n {textChildren(children, (text) => (\n {text}\n ))}\n\n \n {controls ? : null}\n\n {variant === 'numbers'\n ? items.map((item, index) =>\n typeof item === 'number' ? (\n \n ) : (\n \n )\n )\n : null}\n\n {variant === 'compact' ? : null}\n\n {controls ? : null}\n \n \n \n );\n }\n);\nPaginationRoot.displayName = 'Pagination';\n\nexport interface PaginationItemProps\n extends Omit {\n className?: string;\n /** The page this target goes to. */\n page: number;\n /** Styles the number. */\n labelClassName?: string;\n children?: ReactNode;\n}\n\n/**\n * One numbered target. Announced as a selected button when it is the page you\n * are on, so the current page is spoken as state rather than only painted.\n */\nconst PaginationItem = forwardRef(\n ({ className, labelClassName, page, children, onPress, ...props }, ref) => {\n const { page: current, size, disabled, goTo } = usePagination('Pagination.Item');\n const isCurrent = page === current;\n const { item, itemLabel } = paginationVariants({ size, current: isCurrent, disabled });\n\n return (\n {\n onPress?.(event);\n goTo(page);\n }}\n className={item({ className })}\n >\n {textChildren(children ?? String(page), (text) => (\n {text}\n ))}\n \n );\n }\n);\nPaginationItem.displayName = 'Pagination.Item';\n\nexport interface PaginationPreviousProps extends Omit {\n className?: string;\n /** Write the word beside the arrow, rather than leaving it as a glyph. */\n label?: boolean;\n}\n\nexport interface PaginationNextProps extends Omit {\n className?: string;\n /** Write the word beside the arrow, rather than leaving it as a glyph. */\n label?: boolean;\n}\n\ntype PaginationArrowProps = PaginationPreviousProps;\n\n/**\n * The two arrows, which are the same button pointed the other way.\n *\n * Which glyph is \"back\" is a question about the reading direction, not about\n * the component: in a right-to-left layout the previous page is to the right.\n * Yoga mirrors the row on its own; the arrowhead inside it has to be chosen.\n */\nfunction useArrow(step: -1 | 1, part: string) {\n const { page, count, size, disabled, goTo } = usePagination(part);\n const rtl = useDirection() === 'rtl';\n const color = useCSSVariable('--color-foreground');\n const target = page + step;\n const spent = disabled || target < 1 || target > count;\n const Glyph = (step === -1) === rtl ? ChevronRightIcon : ChevronLeftIcon;\n\n return {\n Glyph,\n size,\n spent,\n color: typeof color === 'string' ? color : '#0a0a0a',\n press: () => goTo(target),\n };\n}\n\nfunction PaginationArrow({\n step,\n part,\n word,\n className,\n label,\n disabled,\n onPress,\n ...props\n}: PaginationArrowProps & { step: -1 | 1; part: string; word: string }) {\n const { Glyph, size, spent, color, press } = useArrow(step, part);\n const isDisabled = Boolean(spent || disabled);\n const { item, itemLabel } = paginationVariants({ size, disabled: isDisabled });\n\n return (\n {\n onPress?.(event);\n press();\n }}\n className={item({\n className: cn('flex-row gap-1', label && (size === 'sm' ? 'px-2' : 'px-3'), className),\n })}\n >\n {step === -1 ? : null}\n {label ? {word} : null}\n {step === 1 ? : null}\n \n );\n}\n\n/** Back one page. Dead on the first page rather than looping to the last. */\nconst PaginationPrevious = ({ ...props }: PaginationPreviousProps) => (\n \n);\nPaginationPrevious.displayName = 'Pagination.Previous';\n\n/** Forward one page. Dead on the last page rather than looping to the first. */\nconst PaginationNext = ({ ...props }: PaginationNextProps) => (\n \n);\nPaginationNext.displayName = 'Pagination.Next';\n\nexport interface PaginationEllipsisProps\n extends Omit {\n className?: string;\n /** Which way the gap runs: `-1` towards page 1, `1` towards the last page. */\n direction?: -1 | 1;\n /** How many pages a tap covers. */\n jump?: number;\n}\n\n/**\n * The gap in the run, and a way across it. Tapping jumps `jump` pages towards\n * the end the gap is on — which is why the two ellipses are told apart: a jump\n * that always went forwards would strand anyone reading the row right to left.\n */\nconst PaginationEllipsis = forwardRef(\n ({ className, direction = 1, jump = 5, disabled: disabledProp, onPress, ...props }, ref) => {\n const { page, size, disabled, goTo } = usePagination('Pagination.Ellipsis');\n const color = useCSSVariable('--color-muted-foreground');\n const { ellipsis } = paginationVariants({ size });\n const isDisabled = Boolean(disabled || disabledProp);\n\n return (\n {\n onPress?.(event);\n goTo(page + direction * jump);\n }}\n className={ellipsis({ className })}\n >\n \n \n );\n }\n);\nPaginationEllipsis.displayName = 'Pagination.Ellipsis';\n\nexport interface PaginationSummaryProps extends TextProps {\n className?: string;\n}\n\n/**\n * `3 / 12` — where you are, in the space two arrows leave between them.\n *\n * Digits and a slash rather than \"Page 3 of 12\", because a phone has no room\n * for the sentence and the sentence would need translating. `Pagination.Status`\n * is the place for words.\n */\nconst PaginationSummary = forwardRef, PaginationSummaryProps>(\n ({ className, ...props }, ref) => {\n const { page, count, size } = usePagination('Pagination.Summary');\n const { summary } = paginationVariants({ size });\n\n return (\n \n {page} / {count}\n \n );\n }\n);\nPaginationSummary.displayName = 'Pagination.Summary';\n\nexport interface PaginationStatusProps extends TextProps {\n className?: string;\n /** Which page the span is counted from. Read from the root when left out. */\n page?: number;\n /** How many rows a page holds. Required for the span to be worked out. */\n pageSize?: number;\n /** How many rows there are altogether. */\n total?: number;\n children?: ReactNode;\n}\n\n/**\n * The line that says how much of the set you are looking at — `1–20 of 240`.\n *\n * Worth having beside a table because a page number alone does not answer the\n * question people actually have, which is how much is left. Given `children`\n * it renders those instead, for a set whose size is not known yet.\n */\nconst PaginationStatus = forwardRef, PaginationStatusProps>(\n ({ className, page: ownPage, pageSize, total, children, ...props }, ref) => {\n const context = useContext(PaginationContext);\n const size = context?.size ?? 'default';\n const page = ownPage ?? context?.page ?? 1;\n const { status } = paginationVariants({ size });\n\n const span = useMemo(() => {\n if (children !== undefined) return null;\n if (pageSize === undefined || total === undefined) return null;\n if (total <= 0) return 'None';\n const from = (page - 1) * pageSize + 1;\n // The last page is nearly always short; counting a full one past the end\n // is the classic off-by-a-page-size.\n const to = Math.min(page * pageSize, total);\n return `${from}–${to} of ${total}`;\n }, [children, page, pageSize, total]);\n\n return (\n \n {span ?? textChildren(children)}\n \n );\n }\n);\nPaginationStatus.displayName = 'Pagination.Status';\n\nexport const Pagination = Object.assign(PaginationRoot, {\n Item: PaginationItem,\n Previous: PaginationPrevious,\n Next: PaginationNext,\n Ellipsis: PaginationEllipsis,\n Summary: PaginationSummary,\n Status: PaginationStatus,\n});\n" + "content": "/**\n * Pagination — moving through a result set one page at a time.\n *\n * The component owns the arithmetic and nothing else. Give it how many pages\n * there are and which one you are on, and it works out which numbers to show,\n * where the gaps fall, and which controls are dead at the ends; it never fetches\n * and never slices, because the data is yours.\n *\n * ```tsx\n * \n * ```\n *\n * Three presentations, because a phone is not a desktop:\n *\n * - `numbers` — the full run, with the middle folded into ellipses. Fits about\n * seven targets across a phone, which is what `siblings` and `boundaries`\n * are tuned for.\n * - `compact` — the two arrows with `3 / 12` between them. Digits and a slash,\n * so there is no sentence to translate.\n * - `simple` — labelled Previous and Next, for a flow you walk rather than\n * jump around in: a wizard, an article, a set of onboarding cards.\n *\n * Every target is at least 44pt in both axes, and the small size keeps that\n * reach with `hitSlop` rather than by growing — the row gets denser, the\n * fingers do not get smaller.\n *\n * An ellipsis is a button, not punctuation. A dead 44pt target in the middle of\n * a row of live ones is a thing people tap and then think is broken, so tapping\n * one jumps `pageJump` pages that way.\n *\n * Given children, the root becomes a row with them on the leading edge and the\n * controls on the trailing one — which is where `Pagination.Status` goes:\n *\n * ```tsx\n * \n * \n * \n * ```\n */\nimport {\n createContext,\n forwardRef,\n useCallback,\n useContext,\n useMemo,\n useState,\n type ReactNode,\n} from 'react';\nimport { View, type ViewProps } from 'react-native';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport { useCSSVariable } from 'uniwind';\nimport { ChevronLeftIcon, ChevronRightIcon, EllipsisIcon } from '@/components/ui/icons';\nimport { useDirection } from '@/hooks/use-direction';\nimport {\n AnimatedPressable,\n type AnimatedPressableProps,\n} from '@/components/ui/animated-pressable';\nimport { Text, type TextProps, textChildren } from '@/components/ui/text';\nimport { cn } from '@/lib/cn';\nimport { selectionTick } from '@/lib/haptics';\nimport type { TextRef, ViewRef } from '@/components/ui/refs';\n\nexport type PaginationVariant = 'numbers' | 'compact' | 'simple';\nexport type PaginationSize = 'sm' | 'default';\n\n/**\n * What the small size gives back as padding. 44pt is the smallest target a\n * finger reliably hits, and the row is allowed to get tighter than that only\n * because the reach is restored outside the paint — so this is exactly the\n * difference between the small target's 32pt and that 44.\n */\nconst SMALL_HIT_SLOP = { top: 6, bottom: 6, left: 6, right: 6 } as const;\n\nconst paginationVariants = tv({\n slots: {\n root: 'flex-row items-center',\n list: 'flex-row items-center',\n item: 'items-center justify-center rounded-lg',\n itemLabel: 'font-medium tabular-nums',\n ellipsis: 'items-center justify-center',\n status: 'text-muted-foreground tabular-nums',\n summary: 'text-muted-foreground tabular-nums',\n },\n variants: {\n size: {\n default: {\n list: 'gap-1',\n item: 'h-11 min-w-11 px-1',\n itemLabel: 'text-sm',\n ellipsis: 'h-11 w-11',\n status: 'text-sm',\n summary: 'text-sm',\n },\n sm: {\n list: 'gap-0.5',\n item: 'h-8 min-w-8 px-1',\n itemLabel: 'text-xs',\n ellipsis: 'h-8 w-8',\n status: 'text-xs',\n summary: 'text-xs',\n },\n },\n /** Whether the item is the page you are on. */\n current: {\n true: { item: 'bg-primary', itemLabel: 'text-primary-foreground' },\n false: { itemLabel: 'text-foreground' },\n },\n disabled: {\n true: { item: 'opacity-[0.4]' },\n },\n },\n defaultVariants: {\n size: 'default',\n current: false,\n },\n});\n\nconst iconSize: Record = { default: 20, sm: 16 };\n\n/**\n * The state every part reads. A `Pagination.Previous` dropped into a row of\n * your own still knows which page it is on and whether it is at the start,\n * because that answer lives here rather than in the props it was given.\n */\ninterface PaginationContextValue {\n page: number;\n count: number;\n size: PaginationSize;\n disabled: boolean;\n goTo: (page: number) => void;\n}\n\nconst PaginationContext = createContext(null);\n\nfunction usePagination(part: string): PaginationContextValue {\n const context = useContext(PaginationContext);\n if (!context) {\n throw new Error(`${part} must be used inside .`);\n }\n return context;\n}\n\nconst range = (start: number, end: number): number[] =>\n end < start ? [] : Array.from({ length: end - start + 1 }, (_, i) => start + i);\n\nexport type PaginationItemValue = number | 'start-ellipsis' | 'end-ellipsis';\n\n/**\n * Which numbers to draw, and where the gaps go.\n *\n * The run is a fixed width rather than a sliding window: the same number of\n * targets whichever page you are on, so the row does not reflow under the\n * finger as you step through it and the arrow you were aiming at stays put.\n * `boundaries` pins the ends, `siblings` pads the middle, and a gap of exactly\n * one page is drawn as that page instead of an ellipsis — a `…` that hides a\n * single number is longer than the number.\n *\n * Exported because a caller sometimes needs the same run for a control of their\n * own, and two implementations of this would drift.\n */\nexport function paginationRange({\n count,\n page,\n siblings = 1,\n boundaries = 1,\n}: {\n count: number;\n page: number;\n siblings?: number;\n boundaries?: number;\n}): PaginationItemValue[] {\n const startPages = range(1, Math.min(boundaries, count));\n const endPages = range(Math.max(count - boundaries + 1, boundaries + 1), count);\n\n const siblingsStart = Math.max(\n Math.min(page - siblings, count - boundaries - siblings * 2 - 1),\n boundaries + 2\n );\n const siblingsEnd = Math.min(\n Math.max(page + siblings, boundaries + siblings * 2 + 2),\n endPages.length > 0 ? (endPages[0] as number) - 2 : count - 1\n );\n\n return [\n ...startPages,\n ...(siblingsStart > boundaries + 2\n ? (['start-ellipsis'] as PaginationItemValue[])\n : boundaries + 1 < count - boundaries\n ? [boundaries + 1]\n : []),\n ...range(siblingsStart, siblingsEnd),\n ...(siblingsEnd < count - boundaries - 1\n ? (['end-ellipsis'] as PaginationItemValue[])\n : count - boundaries > boundaries\n ? [count - boundaries]\n : []),\n ...endPages,\n ];\n}\n\nexport interface PaginationProps\n extends Omit,\n VariantProps {\n className?: string;\n /**\n * How many pages there are. Pages are numbered from 1, so this is also the\n * last page's number.\n */\n count: number;\n /** The page being shown. Pass it to control the component. */\n page?: number;\n /** The page to start on when the component keeps its own. Defaults to 1. */\n defaultPage?: number;\n /** Called with the page that was asked for, already clamped to `count`. */\n onPageChange?: (page: number) => void;\n /** Which presentation to draw. */\n variant?: PaginationVariant;\n size?: PaginationSize;\n /**\n * How many pages to keep either side of the current one. Raise it on a\n * tablet, where there is room for a longer run.\n */\n siblings?: number;\n /** How many pages to keep pinned at each end of the run. */\n boundaries?: number;\n /**\n * Show the previous and next arrows. Turning them off leaves the numbers\n * alone, so only do it where something else moves the page — a swipe, a\n * scroller reaching its end.\n */\n controls?: boolean;\n /** How far tapping an ellipsis jumps. */\n pageJump?: number;\n /** Greys out and deafens the whole row — for a page that is still loading. */\n disabled?: boolean;\n /** Labels the row for a screen reader. Defaults to \"Pagination\". */\n accessibilityLabel?: string;\n /** Leading content — a `Pagination.Status`, a page-size control of your own. */\n children?: ReactNode;\n}\n\nconst PaginationRoot = forwardRef(\n (\n {\n className,\n count,\n page: controlledPage,\n defaultPage = 1,\n onPageChange,\n variant = 'numbers',\n size = 'default',\n siblings = 1,\n boundaries = 1,\n controls = true,\n pageJump = 5,\n disabled = false,\n accessibilityLabel = 'Pagination',\n children,\n ...props\n },\n ref\n ) => {\n const [internalPage, setInternalPage] = useState(defaultPage);\n const isControlled = controlledPage !== undefined;\n // Clamped on the way out as well as on the way in: `count` can shrink under\n // a controlled page — a filter narrowing the result set — and a page number\n // past the end would light nothing and disable both arrows at once.\n const page = Math.min(Math.max(isControlled ? controlledPage : internalPage, 1), count);\n\n const goTo = useCallback(\n (next: number) => {\n const clamped = Math.min(Math.max(next, 1), count);\n if (clamped === page || disabled) return;\n // On the frame of the press rather than after the fetch: the tick is\n // what ties the feeling to the tap, and the rows arrive whenever they\n // arrive.\n selectionTick();\n if (!isControlled) setInternalPage(clamped);\n onPageChange?.(clamped);\n },\n [count, page, disabled, isControlled, onPageChange]\n );\n\n const context = useMemo(\n () => ({ page, count, size, disabled, goTo }),\n [page, count, size, disabled, goTo]\n );\n\n const items = useMemo(\n () =>\n variant === 'numbers'\n ? paginationRange({ count, page, siblings, boundaries })\n : [],\n [variant, count, page, siblings, boundaries]\n );\n\n const { root, list } = paginationVariants({ size });\n\n return (\n \n \n {textChildren(children, (text) => (\n {text}\n ))}\n\n \n {controls ? : null}\n\n {variant === 'numbers'\n ? items.map((item, index) =>\n typeof item === 'number' ? (\n \n ) : (\n \n )\n )\n : null}\n\n {variant === 'compact' ? : null}\n\n {controls ? : null}\n \n \n \n );\n }\n);\nPaginationRoot.displayName = 'Pagination';\n\nexport interface PaginationItemProps\n extends Omit {\n className?: string;\n /** The page this target goes to. */\n page: number;\n /** Styles the number. */\n labelClassName?: string;\n children?: ReactNode;\n}\n\n/**\n * One numbered target. Announced as a selected button when it is the page you\n * are on, so the current page is spoken as state rather than only painted.\n */\nconst PaginationItem = forwardRef(\n ({ className, labelClassName, page, children, onPress, ...props }, ref) => {\n const { page: current, size, disabled, goTo } = usePagination('Pagination.Item');\n const isCurrent = page === current;\n const { item, itemLabel } = paginationVariants({ size, current: isCurrent, disabled });\n\n return (\n {\n onPress?.(event);\n goTo(page);\n }}\n className={item({ className })}\n >\n {textChildren(children ?? String(page), (text) => (\n {text}\n ))}\n \n );\n }\n);\nPaginationItem.displayName = 'Pagination.Item';\n\nexport interface PaginationPreviousProps extends Omit {\n className?: string;\n /** Write the word beside the arrow, rather than leaving it as a glyph. */\n label?: boolean;\n}\n\nexport interface PaginationNextProps extends Omit {\n className?: string;\n /** Write the word beside the arrow, rather than leaving it as a glyph. */\n label?: boolean;\n}\n\ntype PaginationArrowProps = PaginationPreviousProps;\n\n/**\n * The two arrows, which are the same button pointed the other way.\n *\n * Which glyph is \"back\" is a question about the reading direction, not about\n * the component: in a right-to-left layout the previous page is to the right.\n * Yoga mirrors the row on its own; the arrowhead inside it has to be chosen.\n */\nfunction useArrow(step: -1 | 1, part: string) {\n const { page, count, size, disabled, goTo } = usePagination(part);\n const rtl = useDirection() === 'rtl';\n const color = useCSSVariable('--color-foreground');\n const target = page + step;\n const spent = disabled || target < 1 || target > count;\n const Glyph = (step === -1) === rtl ? ChevronRightIcon : ChevronLeftIcon;\n\n return {\n Glyph,\n size,\n spent,\n color: typeof color === 'string' ? color : '#0a0a0a',\n press: () => goTo(target),\n };\n}\n\nfunction PaginationArrow({\n step,\n part,\n word,\n className,\n label,\n disabled,\n onPress,\n ...props\n}: PaginationArrowProps & { step: -1 | 1; part: string; word: string }) {\n const { Glyph, size, spent, color, press } = useArrow(step, part);\n const isDisabled = Boolean(spent || disabled);\n const { item, itemLabel } = paginationVariants({ size, disabled: isDisabled });\n\n return (\n {\n onPress?.(event);\n press();\n }}\n className={item({\n className: cn('flex-row gap-1', label && (size === 'sm' ? 'px-2' : 'px-3'), className),\n })}\n >\n {step === -1 ? : null}\n {label ? {word} : null}\n {step === 1 ? : null}\n \n );\n}\n\n/** Back one page. Dead on the first page rather than looping to the last. */\nconst PaginationPrevious = ({ ...props }: PaginationPreviousProps) => (\n \n);\nPaginationPrevious.displayName = 'Pagination.Previous';\n\n/** Forward one page. Dead on the last page rather than looping to the first. */\nconst PaginationNext = ({ ...props }: PaginationNextProps) => (\n \n);\nPaginationNext.displayName = 'Pagination.Next';\n\nexport interface PaginationEllipsisProps\n extends Omit {\n className?: string;\n /** Which way the gap runs: `-1` towards page 1, `1` towards the last page. */\n direction?: -1 | 1;\n /** How many pages a tap covers. */\n jump?: number;\n}\n\n/**\n * The gap in the run, and a way across it. Tapping jumps `jump` pages towards\n * the end the gap is on — which is why the two ellipses are told apart: a jump\n * that always went forwards would strand anyone reading the row right to left.\n */\nconst PaginationEllipsis = forwardRef(\n ({ className, direction = 1, jump = 5, disabled: disabledProp, onPress, ...props }, ref) => {\n const { page, size, disabled, goTo } = usePagination('Pagination.Ellipsis');\n const color = useCSSVariable('--color-muted-foreground');\n const { ellipsis } = paginationVariants({ size });\n const isDisabled = Boolean(disabled || disabledProp);\n\n return (\n {\n onPress?.(event);\n goTo(page + direction * jump);\n }}\n className={ellipsis({ className })}\n >\n \n \n );\n }\n);\nPaginationEllipsis.displayName = 'Pagination.Ellipsis';\n\nexport interface PaginationSummaryProps extends TextProps {\n className?: string;\n}\n\n/**\n * `3 / 12` — where you are, in the space two arrows leave between them.\n *\n * Digits and a slash rather than \"Page 3 of 12\", because a phone has no room\n * for the sentence and the sentence would need translating. `Pagination.Status`\n * is the place for words.\n */\nconst PaginationSummary = forwardRef(\n ({ className, ...props }, ref) => {\n const { page, count, size } = usePagination('Pagination.Summary');\n const { summary } = paginationVariants({ size });\n\n return (\n \n {page} / {count}\n \n );\n }\n);\nPaginationSummary.displayName = 'Pagination.Summary';\n\nexport interface PaginationStatusProps extends TextProps {\n className?: string;\n /** Which page the span is counted from. Read from the root when left out. */\n page?: number;\n /** How many rows a page holds. Required for the span to be worked out. */\n pageSize?: number;\n /** How many rows there are altogether. */\n total?: number;\n children?: ReactNode;\n}\n\n/**\n * The line that says how much of the set you are looking at — `1–20 of 240`.\n *\n * Worth having beside a table because a page number alone does not answer the\n * question people actually have, which is how much is left. Given `children`\n * it renders those instead, for a set whose size is not known yet.\n */\nconst PaginationStatus = forwardRef(\n ({ className, page: ownPage, pageSize, total, children, ...props }, ref) => {\n const context = useContext(PaginationContext);\n const size = context?.size ?? 'default';\n const page = ownPage ?? context?.page ?? 1;\n const { status } = paginationVariants({ size });\n\n const span = useMemo(() => {\n if (children !== undefined) return null;\n if (pageSize === undefined || total === undefined) return null;\n if (total <= 0) return 'None';\n const from = (page - 1) * pageSize + 1;\n // The last page is nearly always short; counting a full one past the end\n // is the classic off-by-a-page-size.\n const to = Math.min(page * pageSize, total);\n return `${from}–${to} of ${total}`;\n }, [children, page, pageSize, total]);\n\n return (\n \n {span ?? textChildren(children)}\n \n );\n }\n);\nPaginationStatus.displayName = 'Pagination.Status';\n\nexport const Pagination = Object.assign(PaginationRoot, {\n Item: PaginationItem,\n Previous: PaginationPrevious,\n Next: PaginationNext,\n Ellipsis: PaginationEllipsis,\n Summary: PaginationSummary,\n Status: PaginationStatus,\n});\n" } ] } diff --git a/apps/docs/public/r/planner.json b/apps/docs/public/r/planner.json index 5893422be..5f318779c 100644 --- a/apps/docs/public/r/planner.json +++ b/apps/docs/public/r/planner.json @@ -8,6 +8,7 @@ "dialog", "frame", "icons", + "refs", "text" ], "dependencies": [ @@ -19,7 +20,7 @@ { "path": "ui/planner.tsx", "type": "registry:ui", - "content": "/**\n * Planner — a month of days, each carrying what falls on it.\n *\n * ```tsx\n * const [month, setMonth] = useState(new Date());\n *\n * \n * \n * \n * \n * \n * \n * \n * \n * \n * {(date, entries) => {entries.length} on {date.toDateString()}}\n * \n * \n * ```\n *\n * ## How it differs from Calendar\n *\n * `Calendar` picks a date and answers with one. This shows what is already on\n * the days and answers with the day you asked about — the selection exists to\n * open something, not to be submitted.\n *\n * ## Why it draws its own Frame\n *\n * A month at a glance is a widget: a boundary, a strip along the top carrying\n * the month and the way through it, and a footer that holds still while the\n * middle changes. That is `Frame`, so the root renders one instead of leaving\n * every caller to assemble the same shell. Pass `frame={false}` to drop it,\n * for a planner in a sheet or a card that already draws its own edge.\n *\n * ## Why the grid is always six weeks\n *\n * A month can span five weeks or six. Drawn at its natural height the panel\n * jumps as you page through the year and the days appear to move under your\n * thumb, so the grid is always six rows and the last one is sometimes all\n * next month. `Calendar` fixes its height for the same reason.\n *\n * ## Why a day says more than its date\n *\n * The marker on a day is a coloured dot, and colour is a signal that does not\n * reach everyone looking at it. So the legend prints its label beside every\n * swatch, and a day is spoken as its date, how many entries it carries and\n * which categories they belong to. Neither is decoration: between them they\n * are the whole content of the grid for somebody who cannot see it.\n */\nimport {\n Children,\n createContext,\n forwardRef,\n isValidElement,\n useCallback,\n useContext,\n useEffect,\n useMemo,\n useRef,\n useState,\n type ReactElement,\n type ReactNode,\n} from 'react';\nimport {\n AccessibilityInfo,\n AppState,\n FlatList,\n Platform,\n Pressable,\n View,\n type ViewToken,\n type ViewProps,\n} from 'react-native';\nimport { tv } from 'tailwind-variants';\nimport { useCSSVariable } from 'uniwind';\nimport { ChevronLeftIcon, ChevronRightIcon } from '@/components/ui/icons';\nimport { Text, type TextProps } from '@/components/ui/text';\nimport { cn } from '@/lib/cn';\nimport {\n addCalendarMonths,\n calendarDayNumber,\n calendarLongDate,\n calendarMonthLabel,\n isSameCalendarMonth,\n isSameDay,\n localeWeekStart,\n monthGrid,\n normalizeWeekStart,\n resolveCalendar,\n startOfCalendarMonth,\n startOfDay,\n weekdayNames,\n type CalendarSystem,\n type DateLocale,\n} from '@/lib/date';\nimport { Dialog } from '@/components/ui/dialog';\nimport { Frame } from '@/components/ui/frame';\nimport {\n bucketByDay,\n dayAccessibilityLabel,\n entriesOn,\n summariseMonth,\n visibleEntries,\n type PlannerCountedCategory,\n} from '@/components/ui/planner-entries';\nimport { usePlannerMonthAnnouncement } from '@/components/ui/planner-announcement';\nimport { plannerGridTarget } from '@/components/ui/planner-grid-navigation';\nimport { weekAnchor, weekDays, weekIndex, weekRange } from '@/components/ui/planner-weeks';\nimport {\n usePlannerMonthLifecycle,\n usePlannerSelectionLifecycle,\n} from '@/components/ui/planner-lifecycle';\nimport { millisecondsUntilNextLocalDay } from '@/components/ui/planner-today';\n\n/**\n * How many of a day's entries a cell draws before it stops and counts.\n *\n * `calendar` gets one more because a named entry is a row, and a cell tall\n * enough to name one is tall enough for three; `tiles` draws a single icon at\n * the size of the tile, so the limit there is the look rather than a number.\n */\nconst DEFAULT_ENTRY_LIMIT: Record = {\n default: 2,\n tiles: 1,\n calendar: 3,\n};\n\n/** The palette a category takes its dot from when it does not name a colour. */\nconst PALETTE_SIZE = 5;\n\n/**\n * How strongly a `tiles` day takes its category's colour.\n *\n * Low enough that the date in the corner and the icon over it both stay\n * legible, high enough that a brand colour arrives as that brand rather than as\n * a grey tile with a suggestion of one.\n */\nconst TILE_TINT_OPACITY = 0.32;\n\n/**\n * The height a `tiles` grid reserves, and what it is made of.\n *\n * Six weeks' worth, always — but only the weeks the month actually spans are\n * drawn, and they share the reserved height out between them. A five-week\n * month therefore has slightly taller tiles than a six-week one and no band of\n * empty space under the last row, while the panel itself never changes size as\n * you page through the year. Reserving the height and leaving the sixth row\n * blank, or dropping the row and letting the panel shrink, each fix one of\n * those at the cost of the other.\n */\nconst TILE_ROW_HEIGHT = 64;\nconst TILE_ROW_GAP = 4;\nconst TILE_GRID_HEIGHT = TILE_ROW_HEIGHT * 6 + TILE_ROW_GAP * 5;\n\n/** Stable wrapper keeps the native method's receiver and the hook dependency steady. */\nconst announceMonth = (label: string) => {\n AccessibilityInfo.announceForAccessibility(label);\n};\n\nconst plannerVariants = tv({\n slots: {\n grid: 'gap-1 px-3 pb-3 pt-1',\n week: 'flex-row',\n heading: 'flex-1 text-center',\n legend: 'flex-row flex-wrap items-center gap-x-4 gap-y-2 px-4 py-3',\n swatch: 'h-2 w-2 rounded-full',\n },\n variants: {\n /*\n * The weeks share out the height instead of standing at their own. The\n * weekday row keeps its natural height — it is a label, and stretching it\n * only moves the letters away from the column they name.\n */\n fill: { true: { grid: 'flex-1', week: 'flex-1' } },\n },\n});\n\nconst dayVariants = tv({\n slots: {\n /*\n * A fixed height, and a square-ish tile rather than a circle.\n *\n * Fixed because the cell has to be the same size whether the day carries\n * anything or not — sized to its contents, a row with one icon in it is\n * taller than the five around it and the grid stops being a grid.\n *\n * Square-ish because a day here holds a number, a marker and sometimes an\n * icon, and a circle wastes the corners it needs for them.\n */\n /*\n * The transparent border is load-bearing: today and the open day both draw\n * one, and a cell that only grows a border when it is picked shifts its\n * contents by a point at the moment you look at it. Every cell reserves the\n * point; the variants below only colour it.\n */\n cell: 'mx-0.5 h-14 flex-1 rounded-xl border border-transparent px-1.5 pt-1.5',\n /*\n * `leading-none` is safe here and only here: these slots hold a day\n * number, `•••` or `+3`, and none of those has anything below the\n * baseline for Android to clip. Anywhere the caller's own words can\n * appear it has to be `leading-tight` — a line height equal to the font\n * size loses the tail of every `g` and `y` on that platform.\n */\n number: 'text-xs leading-none',\n marker: 'absolute right-1.5 top-1.5 h-1.5 w-1.5 rounded-full',\n body: 'flex-1 flex-row items-center justify-center gap-0.5 pb-1',\n /** The colour a day's entries give it, drawn under everything else. */\n tint: 'absolute inset-0 rounded-xl',\n /*\n * The corners of a tile. These are Views rather than classes on the Text\n * itself: a Text takes its typography from a class but not its position,\n * so a number told to sit in a corner stayed where the layout put it —\n * dead centre, under the icon.\n */\n corner: 'absolute bottom-1 right-1.5',\n counter: 'absolute left-1.5 top-1',\n /** One entry, named, in a cell with the height to name it. */\n chip: 'w-full overflow-hidden rounded-sm bg-muted px-1 py-0.5',\n overflow: 'leading-none',\n },\n variants: {\n /*\n * Cells stretch to their container rather than standing at a fixed height.\n * The grid is still six weeks, so what changes is how tall a week is, not\n * how many there are.\n */\n fill: { true: { cell: 'h-auto min-h-0 flex-1' } },\n /** Inside the month being shown, as opposed to the days either side. */\n inMonth: {\n true: { cell: 'bg-muted/40', number: 'text-foreground' },\n false: { cell: 'bg-muted/15', number: 'text-muted-foreground/40' },\n },\n /*\n * Today rings the tile; the open day fills it. Two rings would be the\n * problem — two channels are not, and the ring is the one that has to\n * survive being read at a glance across forty-two tiles.\n */\n today: { true: { cell: 'border-foreground', number: 'font-semibold' } },\n selected: { true: { cell: 'border-primary/60 bg-primary/10' } },\n disabled: { true: { cell: 'opacity-40' } },\n /*\n * What a cell draws. One axis, because these are three answers to the same\n * question and a cell can only give one of them.\n *\n * Last, so a look can undo what the states above it set. `calendar` has no\n * tile to ring or fill, so it has to be able to take the background and the\n * border back off a cell that today or the open day just gave one to.\n */\n variant: {\n default: {},\n /*\n * A tile per day, carrying the one thing on it at the size of a mark\n * rather than a marker. The date moves out of the way into the corner:\n * it is how you find the day you want, not what the cell is showing.\n */\n tiles: {\n cell: 'h-full items-center justify-center px-0 pt-0',\n overflow: 'leading-none',\n },\n /*\n * Named entries stacked down an open cell. No tile, because a tile per\n * day and a block per entry are two boxes saying the same thing and the\n * entries are the ones carrying words — the weeks are ruled off instead,\n * which separates them without enclosing anything.\n */\n calendar: {\n cell: 'mx-0 h-full min-h-0 items-stretch gap-px rounded-none border-transparent bg-transparent px-1 pb-1 pt-1.5',\n number: 'text-center',\n body: 'flex-col items-stretch justify-start gap-px pb-0',\n overflow: 'text-center leading-none',\n },\n },\n },\n compoundVariants: [\n /*\n * Today, and open. Declaration order would otherwise hand the border to\n * `selected` and lose the one mark that says which day it actually is —\n * so the ring is restated over the fill.\n */\n { today: true, selected: true, class: { cell: 'border-foreground bg-primary/10' } },\n ],\n defaultVariants: { inMonth: true, variant: 'default' },\n});\n\n/* ------------------------------------------------------------------ *\n * Data\n * ------------------------------------------------------------------ */\n\n/**\n * What a day cell draws.\n *\n * `default` is a number, a marker and small icons. `tiles` gives the day over\n * to one large icon tinted with its category's colour, with the date in the\n * corner. `calendar` names each entry in a block under a centred date, on an\n * open grid ruled off by week.\n */\nexport type PlannerVariant = 'default' | 'tiles' | 'calendar';\n\n/** One thing that falls on a day. */\nexport interface PlannerEntry {\n /** Stable across renders; it keys the cell's contents. */\n id: string;\n /** When it falls. The time of day is kept but not read by the grid. */\n date: Date;\n /** Named in the dialog and, through its category, in the day's spoken label. */\n label: string;\n /** Matches a `PlannerCategory` id. Without one the entry is still counted. */\n category?: string;\n /** Drawn in the cell — a brand mark, an avatar, a glyph. */\n icon?: ReactNode;\n /**\n * This entry's own colour, for a mark that belongs to the entry rather than\n * to a group of them — a brand. It wins over the category's colour, and\n * under `tiles` it is what tints the day.\n */\n color?: string;\n}\n\n/** A group of entries: the key to a colour, and a line in the legend. */\nexport interface PlannerCategory {\n id: string;\n /** Printed beside the swatch, and spoken as part of a day that carries it. */\n label: string;\n /**\n * Which `--color-chart-*` token the dot takes, 1 to 5. Categories without\n * one are numbered in the order they are declared.\n */\n colorIndex?: number;\n /** An explicit colour, for a brand that is not the theme's to choose. */\n color?: string;\n}\n\ninterface PlannerContextValue {\n month: Date;\n setMonth: (month: Date) => void;\n /** Moves the month without announcing it. For scrolling, which is continuous. */\n syncMonth: (month: Date) => void;\n today: Date;\n selected: Date | null;\n select: (date: Date | null) => void;\n days: Map;\n categories: PlannerCategory[];\n categoryLabels: Map;\n colorOf: (category: string | undefined) => string | undefined;\n summary: { total: number; categories: PlannerCountedCategory[] };\n entryLimit: number;\n variant: PlannerVariant;\n fill: boolean;\n weekStartsOn: number;\n grid: Date[][];\n locale: DateLocale;\n system: 'gregory' | 'islamic';\n isInMonth: (date: Date) => boolean;\n renderDay?: PlannerDayRenderer;\n onDayPress?: (date: Date, entries: PlannerEntry[]) => void;\n}\n\nconst PlannerContext = createContext(null);\n\nfunction usePlanner(component: string): PlannerContextValue {\n const context = useContext(PlannerContext);\n if (!context) {\n throw new Error(`${component} must be used within a `);\n }\n return context;\n}\n\n/** Today, recomputed at local midnight and whenever the app comes back. */\nfunction useToday(): Date {\n const [today, setToday] = useState(() => startOfDay(new Date()));\n\n useEffect(() => {\n let timer: ReturnType | null = null;\n\n const clearTimer = () => {\n if (timer === null) return;\n clearTimeout(timer);\n timer = null;\n };\n\n const refreshAndSchedule = () => {\n const current = new Date();\n const now = startOfDay(current);\n // A new object every resume would invalidate every memo below it for a\n // date that has not changed.\n setToday((previous) => (previous.getTime() === now.getTime() ? previous : now));\n clearTimer();\n timer = setTimeout(refreshAndSchedule, millisecondsUntilNextLocalDay(current));\n };\n\n refreshAndSchedule();\n const subscription = AppState.addEventListener('change', (state) => {\n if (state === 'active') refreshAndSchedule();\n else clearTimer();\n });\n return () => {\n clearTimer();\n subscription.remove();\n };\n }, []);\n\n return today;\n}\n\n/**\n * The five series colours, read once.\n *\n * A fixed number of reads rather than one per category: a hook cannot run in a\n * loop whose length is a prop.\n */\nfunction usePalette(): (string | undefined)[] {\n const one = useCSSVariable('--color-chart-1');\n const two = useCSSVariable('--color-chart-2');\n const three = useCSSVariable('--color-chart-3');\n const four = useCSSVariable('--color-chart-4');\n const five = useCSSVariable('--color-chart-5');\n return useMemo(\n () => [one, two, three, four, five].map((v) => (typeof v === 'string' ? v : undefined)),\n [one, two, three, four, five]\n );\n}\n\n/* ------------------------------------------------------------------ *\n * Root\n * ------------------------------------------------------------------ */\n\nexport interface PlannerProps extends Omit {\n className?: string;\n /** The month on show. Leave it out for an uncontrolled planner. */\n month?: Date;\n defaultMonth?: Date;\n onMonthChange?: (month: Date) => void;\n /** Everything the planner knows about, in any order and any month. */\n entries?: PlannerEntry[];\n /** The colour key. Declaration order is legend order and palette order. */\n categories?: PlannerCategory[];\n /** The open day. `null` is none. Leave it out for an uncontrolled planner. */\n selected?: Date | null;\n defaultSelected?: Date | null;\n onSelectedChange?: (date: Date | null) => void;\n /** Runs before the selection moves, whether or not `Details` is present. */\n onDayPress?: (date: Date, entries: PlannerEntry[]) => void;\n /**\n * What each day draws. `tiles` is one large icon per day, tinted by its\n * category; `calendar` names every entry and needs the height to do it.\n */\n variant?: PlannerVariant;\n /**\n * Stretch the grid to its container instead of standing at its own height.\n * For a planner that owns a screen — the six weeks share out whatever is\n * left after the header, the legend and anything below them.\n */\n fill?: boolean;\n /**\n * How many entries a cell draws before it counts the rest. Default `2`, or\n * `3` under `calendar`, which has the room; `tiles` draws one whatever you\n * pass.\n */\n entryLimit?: number;\n /** First day of the week, 0 is Sunday. Defaults to the locale's. */\n weekStartsOn?: number | 'auto';\n locale?: DateLocale;\n calendar?: CalendarSystem;\n /** Draw the surrounding `Frame`. Off for a planner in a sheet or a card. */\n frame?: boolean;\n children?: ReactNode;\n}\n\nconst PlannerRoot = forwardRef(\n (\n {\n className,\n month: monthProp,\n defaultMonth,\n onMonthChange,\n entries = [],\n categories = [],\n selected: selectedProp,\n defaultSelected = null,\n onSelectedChange,\n onDayPress,\n variant = 'default',\n fill = false,\n entryLimit,\n weekStartsOn = 'auto',\n locale,\n calendar = 'gregory',\n frame = true,\n children,\n ...props\n },\n ref\n ) => {\n const resolvedEntryLimit = entryLimit ?? DEFAULT_ENTRY_LIMIT[variant];\n const system = resolveCalendar(calendar, locale);\n const palette = usePalette();\n const today = useToday();\n const settleMonth = useCallback(\n (date: Date) => startOfCalendarMonth(date, system, locale),\n [system, locale]\n );\n\n const [month, requestMonth] = usePlannerMonthLifecycle({\n month: monthProp,\n defaultMonth,\n settleMonth,\n onMonthChange,\n });\n /*\n * The announcement is registered against the month a press asks for, and\n * spoken only once a commit arrives carrying it. That is what keeps a\n * controlled parent's rejection silent, so it wraps the lifecycle's setter\n * rather than living inside it — the hook owns which month is current, and\n * this owns whether the change was the user's to hear about.\n */\n const expectMonthAnnouncement = usePlannerMonthAnnouncement({\n monthKey: month.getTime(),\n monthLabel: calendarMonthLabel(month, system, locale),\n announce: announceMonth,\n });\n /*\n * Scrolling changes the month continuously, and announcing each one as it\n * goes would talk over somebody reading the weeks. The announcement belongs\n * to a deliberate move — the arrows, the Today pill — so a scroll reports\n * the change and stays quiet about it.\n */\n const syncMonth = useCallback(\n (next: Date) => {\n requestMonth(next);\n },\n [requestMonth]\n );\n const setMonth = useCallback(\n (next: Date) => {\n expectMonthAnnouncement(settleMonth(next));\n requestMonth(next);\n },\n [expectMonthAnnouncement, requestMonth, settleMonth]\n );\n const [selected, select] = usePlannerSelectionLifecycle({\n selected: selectedProp,\n defaultSelected,\n onSelectedChange,\n });\n\n const days = useMemo(() => bucketByDay(entries), [entries]);\n const normalizedWeekStart =\n weekStartsOn === 'auto'\n ? localeWeekStart(locale)\n : normalizeWeekStart(weekStartsOn);\n const grid = useMemo(\n () => monthGrid(month, normalizedWeekStart, system, locale),\n [month, normalizedWeekStart, system, locale]\n );\n\n const isInMonth = useCallback(\n (date: Date) => isSameCalendarMonth(date, month, system, locale),\n [month, system, locale]\n );\n\n const categoryLabels = useMemo(\n () => new Map(categories.map((category) => [category.id, category.label])),\n [categories]\n );\n\n const categoryColors = useMemo(\n () => {\n const colors = new Map();\n categories.forEach((category, index) => {\n // `findIndex` used to make the first duplicate id authoritative.\n if (colors.has(category.id)) return;\n const slot = (category.colorIndex ?? index + 1) - 1;\n colors.set(\n category.id,\n category.color ??\n palette[((slot % PALETTE_SIZE) + PALETTE_SIZE) % PALETTE_SIZE]\n );\n });\n return colors;\n },\n [categories, palette]\n );\n const colorOf = useCallback(\n (id: string | undefined) => id ? categoryColors.get(id) : undefined,\n [categoryColors]\n );\n\n const summary = useMemo(\n () => summariseMonth(days, grid, categories, isInMonth),\n [days, grid, categories, isInMonth]\n );\n\n const context = useMemo(\n () => ({\n month,\n setMonth,\n syncMonth,\n today,\n selected,\n select,\n days,\n categories,\n categoryLabels,\n colorOf,\n summary,\n entryLimit: resolvedEntryLimit,\n variant,\n fill,\n weekStartsOn: normalizedWeekStart,\n grid,\n locale,\n system,\n isInMonth,\n onDayPress,\n }),\n [\n month, setMonth, syncMonth, today, selected, select, days, categories, categoryLabels,\n colorOf, summary, resolvedEntryLimit, variant, fill, normalizedWeekStart,\n grid, locale, system, isInMonth, onDayPress,\n ]\n );\n\n /*\n * The header belongs in the Frame's strip and everything else in its\n * panel, so the root sorts its children rather than asking the caller to\n * nest them in two places to get one widget.\n */\n const parts = Children.toArray(children);\n const header = parts.find(\n (child) => isValidElement(child) && child.type === PlannerHeader\n );\n const body = parts.filter((child) => child !== header);\n\n return (\n \n {frame ? (\n // Full width by default: the grid is seven equal columns, and a\n // container that sizes to its content collapses them to the width of\n // a two-digit number. Under `fill` the panel has to flex too, or the\n // grid inside it stretches against a container that does not.\n \n {header}\n \n {body}\n \n \n ) : (\n \n {header}\n {body}\n \n )}\n \n );\n }\n);\nPlannerRoot.displayName = 'Planner';\n\n/* ------------------------------------------------------------------ *\n * Header strip\n * ------------------------------------------------------------------ */\n\nexport interface PlannerHeaderProps {\n children?: ReactNode;\n}\n\n/**\n * The strip along the top of the frame. Put `Title`, `Today` and `Nav` in it.\n *\n * The strip has two ends rather than an even spread. What the month *is* —\n * its name, and the way back to today — reads from the leading edge; what\n * *moves* it sits at the trailing edge, under the thumb that reaches for it.\n * Spaced evenly across a full-width strip they read as three unrelated\n * controls instead of a label and a pair of buttons.\n */\nfunction PlannerHeader({ children }: PlannerHeaderProps) {\n const parts = Children.toArray(children);\n const trails = (child: ReactNode) =>\n isValidElement(child) && (child.type === PlannerNav || child.type === PlannerAction);\n\n const trailing = parts.filter(trails);\n const lead = parts.filter((child) => !trails(child));\n\n return (\n \n {lead}\n {/*\n `ml-auto` rather than a flexible lead. The nav belongs against the right\n edge whatever is to its left — one short title, a title and a pill, or\n nothing at all — and pushing it there with a margin says that directly\n instead of leaving it to whether the thing beside it happened to grow.\n */}\n {trailing.length > 0 ? (\n {trailing}\n ) : null}\n \n );\n}\nPlannerHeader.displayName = 'Planner.Header';\n\nexport interface PlannerTitleProps extends Omit {\n /** Replaces the month name, for a title that says something else. */\n children?: ReactNode;\n}\n\n/** The month on show, in the calendar system and locale the grid uses. */\nfunction PlannerTitle({ children, className, ...props }: PlannerTitleProps) {\n const { month, system, locale } = usePlanner('Planner.Title');\n return (\n \n {children ?? calendarMonthLabel(month, system, locale)}\n \n );\n}\nPlannerTitle.displayName = 'Planner.Title';\n\nexport interface PlannerTodayProps {\n /** Replaces the word on the pill. */\n children?: ReactNode;\n}\n\n/** Jumps back to the month today is in, and selects nothing. */\nfunction PlannerToday({ children = 'Today' }: PlannerTodayProps) {\n const { today, setMonth, month, system, locale } = usePlanner('Planner.Today');\n const alreadyHere = isSameCalendarMonth(today, month, system, locale);\n\n return (\n setMonth(today)}\n disabled={alreadyHere}\n hitSlop={6}\n accessibilityRole=\"button\"\n accessibilityLabel=\"Go to this month\"\n accessibilityState={{ disabled: alreadyHere }}\n className={cn(\n 'rounded-full border border-border px-3 py-1',\n alreadyHere ? 'opacity-40' : 'active:bg-accent'\n )}\n >\n \n {children}\n \n \n );\n}\nPlannerToday.displayName = 'Planner.Today';\n\nexport interface PlannerNavProps {\n className?: string;\n}\n\n/** Back and forward a month. */\nfunction PlannerNav({ className }: PlannerNavProps) {\n const { month, setMonth, system, locale } = usePlanner('Planner.Nav');\n\n return (\n \n setMonth(addCalendarMonths(month, -1, system, locale))}\n />\n setMonth(addCalendarMonths(month, 1, system, locale))}\n />\n \n );\n}\nPlannerNav.displayName = 'Planner.Nav';\n\nfunction PlannerArrow({\n direction,\n onPress,\n}: {\n direction: 'previous' | 'next';\n onPress: () => void;\n}) {\n return (\n \n {direction === 'next' ? : }\n \n );\n}\n\nexport interface PlannerActionProps {\n children?: ReactNode;\n}\n\n/** The trailing end of the header strip, for a button of the caller's. */\nfunction PlannerAction({ children }: PlannerActionProps) {\n return {children};\n}\nPlannerAction.displayName = 'Planner.Action';\n\n/* ------------------------------------------------------------------ *\n * Grid\n * ------------------------------------------------------------------ */\n\n/** What a custom cell is handed. Everything the default cell draws from. */\nexport interface PlannerDayState {\n date: Date;\n entries: PlannerEntry[];\n isToday: boolean;\n isSelected: boolean;\n isInMonth: boolean;\n}\n\nexport type PlannerDayRenderer = (state: PlannerDayState) => ReactNode;\n\nexport interface PlannerGridProps {\n className?: string;\n /** Draws a cell yourself. It is handed the day and what falls on it. */\n renderDay?: PlannerDayRenderer;\n}\n\ninterface PlannerGridKeyDownEvent {\n nativeEvent: { key?: string };\n preventDefault: () => void;\n}\n\ninterface PlannerGridNavigationContextValue {\n activeIndex: number;\n indexByDay: ReadonlyMap;\n register: (index: number, node: View | null) => void;\n focus: (index: number, event: PlannerGridKeyDownEvent) => void;\n makeActive: (index: number) => void;\n}\n\nconst PlannerGridNavigationContext = createContext(null);\n\n/**\n * The weekday row and the six weeks below it.\n *\n * React Native has no per-cell grid vocabulary — no `gridcell`, no `row` — so\n * a screen reader never hears \"row three, column five\" and cannot fall back on\n * position for context. Every day therefore carries its own full date, and the\n * weekday headings are hidden rather than read out 42 times over.\n */\nfunction PlannerGrid({ className, renderDay }: PlannerGridProps) {\n const { grid: weeks, weekStartsOn, locale, selected, today, isInMonth, variant, fill } =\n usePlanner('Planner.Grid');\n const { grid, week: weekRow, heading } = plannerVariants({ fill });\n /*\n * `tiles` leaves the days either side of the month blank, so its columns are\n * told apart by position alone and a single letter is enough to head them.\n */\n const headings = useMemo(\n () => weekdayNames(locale, weekStartsOn, variant === 'tiles' ? 'narrow' : 'short'),\n [locale, weekStartsOn, variant]\n );\n /*\n * `tiles` draws nothing for the days either side of the month, so a week made\n * only of those is a band of empty space rather than a week. It is dropped,\n * and the grid keeps its height by letting the weeks that remain stretch.\n */\n const shownWeeks = useMemo(\n () => (variant === 'tiles' ? weeks.filter((week) => week.some(isInMonth)) : weeks),\n [weeks, variant, isInMonth]\n );\n const dates = useMemo(() => shownWeeks.flat(), [shownWeeks]);\n const indexByDay = useMemo(\n () => new Map(dates.map((date, index) => [date.getTime(), index])),\n [dates]\n );\n /*\n * A blank cell has nothing to focus, so arrow keys step over it rather than\n * landing on a day that is not drawn and losing focus altogether.\n */\n const navigable = useMemo(\n () =>\n variant === 'tiles' ? (index: number) => isInMonth(dates[index] ?? today) : undefined,\n [variant, dates, isInMonth, today]\n );\n const initialDay = selected ?? today;\n const [activeDay, setActiveDay] = useState(initialDay.getTime());\n const storedIndex = indexByDay.get(activeDay);\n const preferredIndex =\n (selected ? indexByDay.get(startOfDay(selected).getTime()) : undefined) ??\n indexByDay.get(today.getTime()) ??\n dates.findIndex(isInMonth);\n const activeIndex = storedIndex ?? Math.max(0, preferredIndex);\n const refs = useRef(new Map());\n const register = useCallback((index: number, node: View | null) => {\n if (node) refs.current.set(index, node);\n else refs.current.delete(index);\n }, []);\n const makeActive = useCallback(\n (index: number) => {\n const date = dates[index];\n if (date) setActiveDay(date.getTime());\n },\n [dates]\n );\n const focus = useCallback(\n (index: number, event: PlannerGridKeyDownEvent) => {\n const target = plannerGridTarget(\n event.nativeEvent.key ?? '',\n index,\n dates.length,\n 7,\n navigable\n );\n if (target === null) return;\n event.preventDefault();\n makeActive(target);\n refs.current.get(target)?.focus();\n },\n [dates.length, makeActive, navigable]\n );\n const navigation = useMemo(\n () => ({ activeIndex, indexByDay, register, focus, makeActive }),\n [activeIndex, indexByDay, register, focus, makeActive]\n );\n\n return (\n \n \n \n {/*\n Keyed by position rather than by the word: narrow names repeat in\n plenty of locales — English has T twice and S twice — and a column\n is identified by where it is, not by the letter over it.\n */}\n {headings.map((label, position) => (\n \n {label.toUpperCase()}\n \n ))}\n \n\n {shownWeeks.map((week, index) => (\n \n {week.map((date) => (\n \n ))}\n \n ))}\n \n \n );\n}\nPlannerGrid.displayName = 'Planner.Grid';\n\nexport interface PlannerDayProps {\n date: Date;\n renderDay?: PlannerDayRenderer;\n}\n\n/** One cell. Pressing it selects the day and opens whatever is bound to it. */\nfunction PlannerDay({ date, renderDay }: PlannerDayProps) {\n const {\n days, today, selected, select, colorOf, categoryLabels,\n entryLimit, variant, fill, locale, system, isInMonth, onDayPress,\n } = usePlanner('Planner.Day');\n const navigation = useContext(PlannerGridNavigationContext);\n const registerGridCell = navigation?.register;\n const focusGridCell = navigation?.focus;\n const makeGridCellActive = navigation?.makeActive;\n\n const entries = entriesOn(days, date);\n const inMonth = isInMonth(date);\n const isToday = isSameDay(date, today);\n const isSelected = selected ? isSameDay(date, selected) : false;\n const { shown, overflow } = visibleEntries(entries, entryLimit);\n const drawable = shown.filter((entry) => entry.icon);\n const styles = dayVariants({ variant, fill, inMonth, today: isToday, selected: isSelected });\n /*\n * The colour the day's entries give it. The first entry naming a category the\n * planner knows wins, and an uncategorised day gets nothing — the tint is a\n * signal about what is on the day, so a day with no answer stays plain rather\n * than being coloured for the sake of it.\n */\n const tint = markerColor(entries, colorOf);\n\n const label = dayAccessibilityLabel(\n calendarLongDate(date, system, locale),\n entries,\n categoryLabels\n );\n\n /*\n * Pressing the open day again closes it. Without this the mark is a one-way\n * door: a planner with no `Details` bound has nothing to dismiss, so the day\n * you pressed stays ringed until you press a different one and there is no\n * way back to none selected.\n */\n const press = () => {\n onDayPress?.(date, entries);\n select(isSelected ? null : date);\n };\n const gridIndex = navigation?.indexByDay.get(date.getTime());\n const setRef = useCallback(\n (node: View | null) => {\n if (gridIndex !== undefined) registerGridCell?.(gridIndex, node);\n },\n [gridIndex, registerGridCell]\n );\n const onFocus = useCallback(() => {\n if (gridIndex !== undefined) makeGridCellActive?.(gridIndex);\n }, [gridIndex, makeGridCellActive]);\n const onKeyDown = useCallback(\n (event: PlannerGridKeyDownEvent) => {\n if (gridIndex !== undefined) focusGridCell?.(gridIndex, event);\n },\n [gridIndex, focusGridCell]\n );\n const webGridProps =\n Platform.OS === 'web' && gridIndex !== undefined && navigation\n ? {\n ref: setRef,\n tabIndex: navigation.activeIndex === gridIndex ? (0 as const) : (-1 as const),\n onFocus,\n onKeyDown,\n }\n : {};\n\n if (renderDay) {\n return (\n \n {renderDay({ date, entries, isToday, isSelected, isInMonth: inMonth })}\n \n );\n }\n\n /*\n * `tiles` leaves the days either side of the month out altogether. The row\n * still holds their column, so the grid is six weeks whatever month it is\n * showing, but there is no tile, no number and nothing to press or to read\n * out: a blank is the answer, and drawing a faded one only invites the press\n * it is going to ignore.\n */\n if (variant === 'tiles' && !inMonth) {\n return (\n \n );\n }\n\n return (\n 0 ? 'Opens what is on this day' : undefined}\n accessibilityState={{ selected: isSelected }}\n className={styles.cell()}\n >\n {/*\n The tint sits under the content rather than on the cell itself: the\n colour arrives as a runtime string from the theme, so it cannot go\n through a class with an opacity on it, and painting the cell at full\n strength would take the number down with it.\n */}\n {variant === 'tiles' && tint ? (\n \n ) : null}\n\n {variant === 'tiles' ? (\n \n \n {calendarDayNumber(date, system, locale)}\n \n \n ) : variant === 'calendar' ? (\n /*\n * A disc around the date rather than a ring around the cell. There is\n * no tile here to ring, and the date is the only thing in the cell that\n * is always present — so it is the only place a mark can go and be\n * found in the same spot on every day of the month.\n */\n \n \n {calendarDayNumber(date, system, locale)}\n \n \n ) : (\n {calendarDayNumber(date, system, locale)}\n )}\n\n {variant === 'default' && entries.length > 0 ? (\n \n ) : null}\n\n {variant === 'calendar' ? (\n \n {shown.map((entry) => (\n \n ))}\n {/*\n Three dots, not a count. The cell has already run out of room for\n the entries themselves, and \"+2 more\" spends a whole row of that\n room on a number nobody acts on — the dots say there is more and\n leave the row for an entry.\n */}\n {overflow > 0 ? (\n \n •••\n \n ) : null}\n \n ) : null}\n\n {/*\n Only entries that brought an icon are drawn, and the overflow count\n goes with them. A day whose entries have no icons is already saying\n \"something is here\" with its marker; adding a bare \"+3\" under an empty\n row says it twice, in a way that looks like a stray number.\n */}\n {variant === 'tiles' && overflow > 0 ? (\n \n \n +{overflow}\n \n \n ) : null}\n\n {variant !== 'calendar' && drawable.length > 0 ? (\n \n {drawable.map((entry) => (\n {entry.icon}\n ))}\n {variant !== 'tiles' && overflow > 0 ? (\n \n +{overflow}\n \n ) : null}\n \n ) : null}\n \n );\n}\n\n/**\n * One named entry inside a `calendar` cell.\n *\n * A solid block of the entry's colour with light text on it. That is a real\n * constraint rather than a free choice: the colour is the caller's, so a pale\n * one will not carry white well — but a wash with the label in the foreground\n * colour makes every entry the same weight and loses the one thing the colour\n * was for, which is telling them apart down a column at a glance.\n */\nfunction PlannerChip({ entry, color }: { entry: PlannerEntry; color?: string }) {\n const { chip } = dayVariants();\n return (\n \n \n {entry.label}\n \n \n );\n}\n\n/**\n * The dot's colour: the first entry that names a category the planner knows.\n *\n * One dot rather than one per entry. A cell this size fits a row of dots or a\n * day number, and the count is already spoken — so the dot answers \"is there\n * anything here, and roughly what kind\", which is all it has room to answer.\n */\nfunction markerColor(\n entries: readonly PlannerEntry[],\n colorOf: (category: string | undefined) => string | undefined\n): string | undefined {\n for (const entry of entries) {\n // The entry's own colour first: a brand belongs to the thing, not to the\n // group it was filed under.\n const color = entry.color ?? colorOf(entry.category);\n if (color) return color;\n }\n return undefined;\n}\n\n/* ------------------------------------------------------------------ *\n * Legend and summary\n * ------------------------------------------------------------------ */\n\nexport interface PlannerLegendProps {\n className?: string;\n /** Print each category's count for the month beside its label. */\n counts?: boolean;\n /** Sits at the trailing end — a total, a currency, whatever the month adds to. */\n children?: ReactNode;\n}\n\n/**\n * The key to the dots.\n *\n * It prints the label beside every swatch, because a column of coloured dots\n * with nothing to read them against is a quiz.\n */\nfunction PlannerLegend({ className, counts = false, children }: PlannerLegendProps) {\n const { summary, colorOf } = usePlanner('Planner.Legend');\n const { legend, swatch } = plannerVariants();\n\n return (\n \n \n {summary.categories.map((category) => (\n \n \n \n {counts ? `${category.label} ${category.count}` : category.label}\n \n \n ))}\n \n {children}\n \n );\n}\nPlannerLegend.displayName = 'Planner.Legend';\n\nexport interface PlannerSummaryProps extends Omit {\n /** Replaces the count, for a total that is money rather than entries. */\n children?: ReactNode;\n}\n\n/** What the month adds up to. Counts this month only, never the days either side. */\nfunction PlannerSummary({ children, ...props }: PlannerSummaryProps) {\n const { summary } = usePlanner('Planner.Summary');\n return (\n \n {children ?? `${summary.total} ${summary.total === 1 ? 'entry' : 'entries'}`}\n \n );\n}\nPlannerSummary.displayName = 'Planner.Summary';\n\nexport interface PlannerFooterProps {\n className?: string;\n children?: ReactNode;\n}\n\n/** The strip along the bottom, for tools that act on the month. */\nfunction PlannerFooter({ className, children }: PlannerFooterProps) {\n return (\n \n {children}\n \n );\n}\nPlannerFooter.displayName = 'Planner.Footer';\n\n/* ------------------------------------------------------------------ *\n * Details\n * ------------------------------------------------------------------ */\n\nexport interface PlannerDetailsProps {\n className?: string;\n /** Title above the children. Defaults to the day's full date. */\n title?: ReactNode;\n /**\n * The line under the title. Defaults to how many entries the day carries,\n * so the dialog answers \"how much of this is there\" before it is read.\n * Pass `null` to drop it.\n */\n description?: ReactNode;\n /** Given the open day and what falls on it. */\n children: (date: Date, entries: PlannerEntry[]) => ReactNode;\n}\n\n/**\n * A dialog bound to the open day.\n *\n * The planner owns the grid and the binding; what the dialog says is the\n * application's, because the contents of a day are its data and not the\n * component's. Leave it out and `onDayPress` is still called — a planner that\n * pushes a screen instead of opening a dialog wants that and nothing else.\n */\nfunction PlannerDetails({ className, title, description, children }: PlannerDetailsProps) {\n const { selected, select, days, system, locale } = usePlanner('Planner.Details');\n const entries = selected ? entriesOn(days, selected) : [];\n\n const count =\n entries.length === 1 ? '1 entry' : `${entries.length} entries`;\n\n return (\n !open && select(null)}>\n \n \n \n {title ?? (selected ? calendarLongDate(selected, system, locale) : '')}\n \n {description === null ? null : (\n \n {description ?? (entries.length === 0 ? 'Nothing planned' : count)}\n \n )}\n \n {selected ? children(selected, entries) : null}\n \n \n );\n}\nPlannerDetails.displayName = 'Planner.Details';\n\n/* ------------------------------------------------------------------ *\n * Scroller\n * ------------------------------------------------------------------ */\n\n/** How many weeks either side of the opening month a scroller reaches by default. */\nconst DEFAULT_SCROLLER_WEEKS = 53;\n\n/** The height of one week row, and enough of it for a date and three entries. */\nconst DEFAULT_WEEK_HEIGHT = 96;\n\nexport interface PlannerScrollerProps {\n className?: string;\n /**\n * How many weeks either side of the opening month can be reached. Default\n * `53`, about a year each way.\n */\n weeks?: number;\n /** The height of one week row. Default `96`. */\n rowHeight?: number;\n /** Draws a cell yourself. It is handed the day and what falls on it. */\n renderDay?: PlannerDayRenderer;\n}\n\n/**\n * The weeks of the year, scrolled through rather than paged.\n *\n * A month grid answers \"what does this month look like\"; a scroller answers\n * \"what is coming\", which does not stop at the end of a month. The week\n * straddling the boundary is drawn once, in one piece, instead of appearing cut\n * in half at the bottom of one page and again at the top of the next.\n *\n * The range is bounded rather than endless. A scroller has to know its own\n * height to place a scrollbar and to jump to a month without rendering its way\n * there, and neither is possible over a list with no end.\n *\n * The month in the header follows the scroll: whichever month holds most of the\n * first week on screen is the one named, and the days either side of it grey\n * out. `Planner.Nav` and `Planner.Today` still work — they scroll the list\n * rather than replacing what is in it.\n */\nfunction PlannerScroller({\n className,\n weeks: span = DEFAULT_SCROLLER_WEEKS,\n rowHeight = DEFAULT_WEEK_HEIGHT,\n renderDay,\n}: PlannerScrollerProps) {\n const { month, syncMonth, weekStartsOn, locale, variant, system } =\n usePlanner('Planner.Scroller');\n const { week: weekRow, heading } = plannerVariants();\n const list = useRef>(null);\n\n /*\n * The range is anchored where the planner opened and never rebuilt. Rebuilding\n * it around the current month would move every row's index under the scroll\n * position each time the month changed, which is the one thing a list being\n * scrolled must not do.\n */\n const [anchor] = useState(() => month);\n const range = useMemo(\n () => weekRange(anchor, weekStartsOn, span, span),\n [anchor, weekStartsOn, span]\n );\n\n const headings = useMemo(\n () => weekdayNames(locale, weekStartsOn, 'narrow'),\n [locale, weekStartsOn]\n );\n\n /*\n * Which month the scroll is showing, reported up but not acted on here. The\n * ref is what stops a scroll that stays inside one month from setting it over\n * and over, and what stops the scroll this component performs in answer to a\n * month change from reporting that same month straight back.\n */\n const reported = useRef(month.getTime());\n const onViewable = useRef(({ viewableItems }: { viewableItems: ViewToken[] }) => {\n const first = viewableItems[0]?.item as Date | undefined;\n if (!first) return;\n const next = startOfCalendarMonth(weekAnchor(first), system, locale);\n if (next.getTime() === reported.current) return;\n reported.current = next.getTime();\n syncMonth(next);\n });\n\n /*\n * A month arriving from outside — the arrows, the Today pill, a controlled\n * prop — scrolls the list to it. A month the scroll itself just reported is\n * already there, and scrolling to it again would fight the finger.\n */\n useEffect(() => {\n if (month.getTime() === reported.current) return;\n reported.current = month.getTime();\n const index = weekIndex(range, month, weekStartsOn);\n if (index >= 0) list.current?.scrollToIndex({ index, animated: true });\n }, [month, range, weekStartsOn]);\n\n const initialIndex = Math.max(0, weekIndex(range, month, weekStartsOn));\n\n return (\n \n \n {headings.map((label, position) => (\n \n {label.toUpperCase()}\n \n ))}\n \n\n String(weekStart.getTime())}\n initialScrollIndex={initialIndex}\n // Every row is the same height, so the list can place any of them\n // without measuring — which is what makes jumping to a month cheap.\n getItemLayout={(_unused, index) => ({\n length: rowHeight,\n offset: rowHeight * index,\n index,\n })}\n onViewableItemsChanged={onViewable.current}\n viewabilityConfig={VIEWABILITY}\n showsVerticalScrollIndicator={false}\n renderItem={({ item }) => (\n \n {weekDays(item).map((date) => (\n \n ))}\n \n )}\n />\n \n );\n}\nPlannerScroller.displayName = 'Planner.Scroller';\n\n/*\n * The first row more than half on screen is the one that names the month. A\n * lower threshold hands the month over while the week is still mostly below the\n * fold, which reads as the header changing early.\n */\nconst VIEWABILITY = { itemVisiblePercentThreshold: 60 };\n\nexport const Planner = Object.assign(PlannerRoot, {\n Header: PlannerHeader,\n Title: PlannerTitle,\n Today: PlannerToday,\n Nav: PlannerNav,\n Action: PlannerAction,\n Grid: PlannerGrid,\n Scroller: PlannerScroller,\n Day: PlannerDay,\n Legend: PlannerLegend,\n Summary: PlannerSummary,\n Footer: PlannerFooter,\n Details: PlannerDetails,\n});\n\nexport type { PlannerCountedCategory };\n" + "content": "/**\n * Planner — a month of days, each carrying what falls on it.\n *\n * ```tsx\n * const [month, setMonth] = useState(new Date());\n *\n * \n * \n * \n * \n * \n * \n * \n * \n * \n * {(date, entries) => {entries.length} on {date.toDateString()}}\n * \n * \n * ```\n *\n * ## How it differs from Calendar\n *\n * `Calendar` picks a date and answers with one. This shows what is already on\n * the days and answers with the day you asked about — the selection exists to\n * open something, not to be submitted.\n *\n * ## Why it draws its own Frame\n *\n * A month at a glance is a widget: a boundary, a strip along the top carrying\n * the month and the way through it, and a footer that holds still while the\n * middle changes. That is `Frame`, so the root renders one instead of leaving\n * every caller to assemble the same shell. Pass `frame={false}` to drop it,\n * for a planner in a sheet or a card that already draws its own edge.\n *\n * ## Why the grid is always six weeks\n *\n * A month can span five weeks or six. Drawn at its natural height the panel\n * jumps as you page through the year and the days appear to move under your\n * thumb, so the grid is always six rows and the last one is sometimes all\n * next month. `Calendar` fixes its height for the same reason.\n *\n * ## Why a day says more than its date\n *\n * The marker on a day is a coloured dot, and colour is a signal that does not\n * reach everyone looking at it. So the legend prints its label beside every\n * swatch, and a day is spoken as its date, how many entries it carries and\n * which categories they belong to. Neither is decoration: between them they\n * are the whole content of the grid for somebody who cannot see it.\n */\nimport {\n Children,\n createContext,\n forwardRef,\n isValidElement,\n useCallback,\n useContext,\n useEffect,\n useMemo,\n useRef,\n useState,\n type ReactElement,\n type ReactNode,\n} from 'react';\nimport {\n AccessibilityInfo,\n AppState,\n FlatList,\n Platform,\n Pressable,\n View,\n type ViewToken,\n type ViewProps,\n} from 'react-native';\nimport { tv } from 'tailwind-variants';\nimport { useCSSVariable } from 'uniwind';\nimport { ChevronLeftIcon, ChevronRightIcon } from '@/components/ui/icons';\nimport { Text, type TextProps } from '@/components/ui/text';\nimport { cn } from '@/lib/cn';\nimport {\n addCalendarMonths,\n calendarDayNumber,\n calendarLongDate,\n calendarMonthLabel,\n isSameCalendarMonth,\n isSameDay,\n localeWeekStart,\n monthGrid,\n normalizeWeekStart,\n resolveCalendar,\n startOfCalendarMonth,\n startOfDay,\n weekdayNames,\n type CalendarSystem,\n type DateLocale,\n} from '@/lib/date';\nimport { Dialog } from '@/components/ui/dialog';\nimport { Frame } from '@/components/ui/frame';\nimport {\n bucketByDay,\n dayAccessibilityLabel,\n entriesOn,\n summariseMonth,\n visibleEntries,\n type PlannerCountedCategory,\n} from '@/components/ui/planner-entries';\nimport { usePlannerMonthAnnouncement } from '@/components/ui/planner-announcement';\nimport { plannerGridTarget } from '@/components/ui/planner-grid-navigation';\nimport { weekAnchor, weekDays, weekIndex, weekRange } from '@/components/ui/planner-weeks';\nimport {\n usePlannerMonthLifecycle,\n usePlannerSelectionLifecycle,\n} from '@/components/ui/planner-lifecycle';\nimport { millisecondsUntilNextLocalDay } from '@/components/ui/planner-today';\nimport type { ViewRef } from '@/components/ui/refs';\n\n/**\n * How many of a day's entries a cell draws before it stops and counts.\n *\n * `calendar` gets one more because a named entry is a row, and a cell tall\n * enough to name one is tall enough for three; `tiles` draws a single icon at\n * the size of the tile, so the limit there is the look rather than a number.\n */\nconst DEFAULT_ENTRY_LIMIT: Record = {\n default: 2,\n tiles: 1,\n calendar: 3,\n};\n\n/** The palette a category takes its dot from when it does not name a colour. */\nconst PALETTE_SIZE = 5;\n\n/**\n * How strongly a `tiles` day takes its category's colour.\n *\n * Low enough that the date in the corner and the icon over it both stay\n * legible, high enough that a brand colour arrives as that brand rather than as\n * a grey tile with a suggestion of one.\n */\nconst TILE_TINT_OPACITY = 0.32;\n\n/**\n * The height a `tiles` grid reserves, and what it is made of.\n *\n * Six weeks' worth, always — but only the weeks the month actually spans are\n * drawn, and they share the reserved height out between them. A five-week\n * month therefore has slightly taller tiles than a six-week one and no band of\n * empty space under the last row, while the panel itself never changes size as\n * you page through the year. Reserving the height and leaving the sixth row\n * blank, or dropping the row and letting the panel shrink, each fix one of\n * those at the cost of the other.\n */\nconst TILE_ROW_HEIGHT = 64;\nconst TILE_ROW_GAP = 4;\nconst TILE_GRID_HEIGHT = TILE_ROW_HEIGHT * 6 + TILE_ROW_GAP * 5;\n\n/** Stable wrapper keeps the native method's receiver and the hook dependency steady. */\nconst announceMonth = (label: string) => {\n AccessibilityInfo.announceForAccessibility(label);\n};\n\nconst plannerVariants = tv({\n slots: {\n grid: 'gap-1 px-3 pb-3 pt-1',\n week: 'flex-row',\n heading: 'flex-1 text-center',\n legend: 'flex-row flex-wrap items-center gap-x-4 gap-y-2 px-4 py-3',\n swatch: 'h-2 w-2 rounded-full',\n },\n variants: {\n /*\n * The weeks share out the height instead of standing at their own. The\n * weekday row keeps its natural height — it is a label, and stretching it\n * only moves the letters away from the column they name.\n */\n fill: { true: { grid: 'flex-1', week: 'flex-1' } },\n },\n});\n\nconst dayVariants = tv({\n slots: {\n /*\n * A fixed height, and a square-ish tile rather than a circle.\n *\n * Fixed because the cell has to be the same size whether the day carries\n * anything or not — sized to its contents, a row with one icon in it is\n * taller than the five around it and the grid stops being a grid.\n *\n * Square-ish because a day here holds a number, a marker and sometimes an\n * icon, and a circle wastes the corners it needs for them.\n */\n /*\n * The transparent border is load-bearing: today and the open day both draw\n * one, and a cell that only grows a border when it is picked shifts its\n * contents by a point at the moment you look at it. Every cell reserves the\n * point; the variants below only colour it.\n */\n cell: 'mx-0.5 h-14 flex-1 rounded-xl border border-transparent px-1.5 pt-1.5',\n /*\n * `leading-none` is safe here and only here: these slots hold a day\n * number, `•••` or `+3`, and none of those has anything below the\n * baseline for Android to clip. Anywhere the caller's own words can\n * appear it has to be `leading-tight` — a line height equal to the font\n * size loses the tail of every `g` and `y` on that platform.\n */\n number: 'text-xs leading-none',\n marker: 'absolute right-1.5 top-1.5 h-1.5 w-1.5 rounded-full',\n body: 'flex-1 flex-row items-center justify-center gap-0.5 pb-1',\n /** The colour a day's entries give it, drawn under everything else. */\n tint: 'absolute inset-0 rounded-xl',\n /*\n * The corners of a tile. These are Views rather than classes on the Text\n * itself: a Text takes its typography from a class but not its position,\n * so a number told to sit in a corner stayed where the layout put it —\n * dead centre, under the icon.\n */\n corner: 'absolute bottom-1 right-1.5',\n counter: 'absolute left-1.5 top-1',\n /** One entry, named, in a cell with the height to name it. */\n chip: 'w-full overflow-hidden rounded-sm bg-muted px-1 py-0.5',\n overflow: 'leading-none',\n },\n variants: {\n /*\n * Cells stretch to their container rather than standing at a fixed height.\n * The grid is still six weeks, so what changes is how tall a week is, not\n * how many there are.\n */\n fill: { true: { cell: 'h-auto min-h-0 flex-1' } },\n /** Inside the month being shown, as opposed to the days either side. */\n inMonth: {\n true: { cell: 'bg-muted/40', number: 'text-foreground' },\n false: { cell: 'bg-muted/15', number: 'text-muted-foreground/40' },\n },\n /*\n * Today rings the tile; the open day fills it. Two rings would be the\n * problem — two channels are not, and the ring is the one that has to\n * survive being read at a glance across forty-two tiles.\n */\n today: { true: { cell: 'border-foreground', number: 'font-semibold' } },\n selected: { true: { cell: 'border-primary/60 bg-primary/10' } },\n disabled: { true: { cell: 'opacity-40' } },\n /*\n * What a cell draws. One axis, because these are three answers to the same\n * question and a cell can only give one of them.\n *\n * Last, so a look can undo what the states above it set. `calendar` has no\n * tile to ring or fill, so it has to be able to take the background and the\n * border back off a cell that today or the open day just gave one to.\n */\n variant: {\n default: {},\n /*\n * A tile per day, carrying the one thing on it at the size of a mark\n * rather than a marker. The date moves out of the way into the corner:\n * it is how you find the day you want, not what the cell is showing.\n */\n tiles: {\n cell: 'h-full items-center justify-center px-0 pt-0',\n overflow: 'leading-none',\n },\n /*\n * Named entries stacked down an open cell. No tile, because a tile per\n * day and a block per entry are two boxes saying the same thing and the\n * entries are the ones carrying words — the weeks are ruled off instead,\n * which separates them without enclosing anything.\n */\n calendar: {\n cell: 'mx-0 h-full min-h-0 items-stretch gap-px rounded-none border-transparent bg-transparent px-1 pb-1 pt-1.5',\n number: 'text-center',\n body: 'flex-col items-stretch justify-start gap-px pb-0',\n overflow: 'text-center leading-none',\n },\n },\n },\n compoundVariants: [\n /*\n * Today, and open. Declaration order would otherwise hand the border to\n * `selected` and lose the one mark that says which day it actually is —\n * so the ring is restated over the fill.\n */\n { today: true, selected: true, class: { cell: 'border-foreground bg-primary/10' } },\n ],\n defaultVariants: { inMonth: true, variant: 'default' },\n});\n\n/* ------------------------------------------------------------------ *\n * Data\n * ------------------------------------------------------------------ */\n\n/**\n * What a day cell draws.\n *\n * `default` is a number, a marker and small icons. `tiles` gives the day over\n * to one large icon tinted with its category's colour, with the date in the\n * corner. `calendar` names each entry in a block under a centred date, on an\n * open grid ruled off by week.\n */\nexport type PlannerVariant = 'default' | 'tiles' | 'calendar';\n\n/** One thing that falls on a day. */\nexport interface PlannerEntry {\n /** Stable across renders; it keys the cell's contents. */\n id: string;\n /** When it falls. The time of day is kept but not read by the grid. */\n date: Date;\n /** Named in the dialog and, through its category, in the day's spoken label. */\n label: string;\n /** Matches a `PlannerCategory` id. Without one the entry is still counted. */\n category?: string;\n /** Drawn in the cell — a brand mark, an avatar, a glyph. */\n icon?: ReactNode;\n /**\n * This entry's own colour, for a mark that belongs to the entry rather than\n * to a group of them — a brand. It wins over the category's colour, and\n * under `tiles` it is what tints the day.\n */\n color?: string;\n}\n\n/** A group of entries: the key to a colour, and a line in the legend. */\nexport interface PlannerCategory {\n id: string;\n /** Printed beside the swatch, and spoken as part of a day that carries it. */\n label: string;\n /**\n * Which `--color-chart-*` token the dot takes, 1 to 5. Categories without\n * one are numbered in the order they are declared.\n */\n colorIndex?: number;\n /** An explicit colour, for a brand that is not the theme's to choose. */\n color?: string;\n}\n\ninterface PlannerContextValue {\n month: Date;\n setMonth: (month: Date) => void;\n /** Moves the month without announcing it. For scrolling, which is continuous. */\n syncMonth: (month: Date) => void;\n today: Date;\n selected: Date | null;\n select: (date: Date | null) => void;\n days: Map;\n categories: PlannerCategory[];\n categoryLabels: Map;\n colorOf: (category: string | undefined) => string | undefined;\n summary: { total: number; categories: PlannerCountedCategory[] };\n entryLimit: number;\n variant: PlannerVariant;\n fill: boolean;\n weekStartsOn: number;\n grid: Date[][];\n locale: DateLocale;\n system: 'gregory' | 'islamic';\n isInMonth: (date: Date) => boolean;\n renderDay?: PlannerDayRenderer;\n onDayPress?: (date: Date, entries: PlannerEntry[]) => void;\n}\n\nconst PlannerContext = createContext(null);\n\nfunction usePlanner(component: string): PlannerContextValue {\n const context = useContext(PlannerContext);\n if (!context) {\n throw new Error(`${component} must be used within a `);\n }\n return context;\n}\n\n/** Today, recomputed at local midnight and whenever the app comes back. */\nfunction useToday(): Date {\n const [today, setToday] = useState(() => startOfDay(new Date()));\n\n useEffect(() => {\n let timer: ReturnType | null = null;\n\n const clearTimer = () => {\n if (timer === null) return;\n clearTimeout(timer);\n timer = null;\n };\n\n const refreshAndSchedule = () => {\n const current = new Date();\n const now = startOfDay(current);\n // A new object every resume would invalidate every memo below it for a\n // date that has not changed.\n setToday((previous) => (previous.getTime() === now.getTime() ? previous : now));\n clearTimer();\n timer = setTimeout(refreshAndSchedule, millisecondsUntilNextLocalDay(current));\n };\n\n refreshAndSchedule();\n const subscription = AppState.addEventListener('change', (state) => {\n if (state === 'active') refreshAndSchedule();\n else clearTimer();\n });\n return () => {\n clearTimer();\n subscription.remove();\n };\n }, []);\n\n return today;\n}\n\n/**\n * The five series colours, read once.\n *\n * A fixed number of reads rather than one per category: a hook cannot run in a\n * loop whose length is a prop.\n */\nfunction usePalette(): (string | undefined)[] {\n const one = useCSSVariable('--color-chart-1');\n const two = useCSSVariable('--color-chart-2');\n const three = useCSSVariable('--color-chart-3');\n const four = useCSSVariable('--color-chart-4');\n const five = useCSSVariable('--color-chart-5');\n return useMemo(\n () => [one, two, three, four, five].map((v) => (typeof v === 'string' ? v : undefined)),\n [one, two, three, four, five]\n );\n}\n\n/* ------------------------------------------------------------------ *\n * Root\n * ------------------------------------------------------------------ */\n\nexport interface PlannerProps extends Omit {\n className?: string;\n /** The month on show. Leave it out for an uncontrolled planner. */\n month?: Date;\n defaultMonth?: Date;\n onMonthChange?: (month: Date) => void;\n /** Everything the planner knows about, in any order and any month. */\n entries?: PlannerEntry[];\n /** The colour key. Declaration order is legend order and palette order. */\n categories?: PlannerCategory[];\n /** The open day. `null` is none. Leave it out for an uncontrolled planner. */\n selected?: Date | null;\n defaultSelected?: Date | null;\n onSelectedChange?: (date: Date | null) => void;\n /** Runs before the selection moves, whether or not `Details` is present. */\n onDayPress?: (date: Date, entries: PlannerEntry[]) => void;\n /**\n * What each day draws. `tiles` is one large icon per day, tinted by its\n * category; `calendar` names every entry and needs the height to do it.\n */\n variant?: PlannerVariant;\n /**\n * Stretch the grid to its container instead of standing at its own height.\n * For a planner that owns a screen — the six weeks share out whatever is\n * left after the header, the legend and anything below them.\n */\n fill?: boolean;\n /**\n * How many entries a cell draws before it counts the rest. Default `2`, or\n * `3` under `calendar`, which has the room; `tiles` draws one whatever you\n * pass.\n */\n entryLimit?: number;\n /** First day of the week, 0 is Sunday. Defaults to the locale's. */\n weekStartsOn?: number | 'auto';\n locale?: DateLocale;\n calendar?: CalendarSystem;\n /** Draw the surrounding `Frame`. Off for a planner in a sheet or a card. */\n frame?: boolean;\n children?: ReactNode;\n}\n\nconst PlannerRoot = forwardRef(\n (\n {\n className,\n month: monthProp,\n defaultMonth,\n onMonthChange,\n entries = [],\n categories = [],\n selected: selectedProp,\n defaultSelected = null,\n onSelectedChange,\n onDayPress,\n variant = 'default',\n fill = false,\n entryLimit,\n weekStartsOn = 'auto',\n locale,\n calendar = 'gregory',\n frame = true,\n children,\n ...props\n },\n ref\n ) => {\n const resolvedEntryLimit = entryLimit ?? DEFAULT_ENTRY_LIMIT[variant];\n const system = resolveCalendar(calendar, locale);\n const palette = usePalette();\n const today = useToday();\n const settleMonth = useCallback(\n (date: Date) => startOfCalendarMonth(date, system, locale),\n [system, locale]\n );\n\n const [month, requestMonth] = usePlannerMonthLifecycle({\n month: monthProp,\n defaultMonth,\n settleMonth,\n onMonthChange,\n });\n /*\n * The announcement is registered against the month a press asks for, and\n * spoken only once a commit arrives carrying it. That is what keeps a\n * controlled parent's rejection silent, so it wraps the lifecycle's setter\n * rather than living inside it — the hook owns which month is current, and\n * this owns whether the change was the user's to hear about.\n */\n const expectMonthAnnouncement = usePlannerMonthAnnouncement({\n monthKey: month.getTime(),\n monthLabel: calendarMonthLabel(month, system, locale),\n announce: announceMonth,\n });\n /*\n * Scrolling changes the month continuously, and announcing each one as it\n * goes would talk over somebody reading the weeks. The announcement belongs\n * to a deliberate move — the arrows, the Today pill — so a scroll reports\n * the change and stays quiet about it.\n */\n const syncMonth = useCallback(\n (next: Date) => {\n requestMonth(next);\n },\n [requestMonth]\n );\n const setMonth = useCallback(\n (next: Date) => {\n expectMonthAnnouncement(settleMonth(next));\n requestMonth(next);\n },\n [expectMonthAnnouncement, requestMonth, settleMonth]\n );\n const [selected, select] = usePlannerSelectionLifecycle({\n selected: selectedProp,\n defaultSelected,\n onSelectedChange,\n });\n\n const days = useMemo(() => bucketByDay(entries), [entries]);\n const normalizedWeekStart =\n weekStartsOn === 'auto'\n ? localeWeekStart(locale)\n : normalizeWeekStart(weekStartsOn);\n const grid = useMemo(\n () => monthGrid(month, normalizedWeekStart, system, locale),\n [month, normalizedWeekStart, system, locale]\n );\n\n const isInMonth = useCallback(\n (date: Date) => isSameCalendarMonth(date, month, system, locale),\n [month, system, locale]\n );\n\n const categoryLabels = useMemo(\n () => new Map(categories.map((category) => [category.id, category.label])),\n [categories]\n );\n\n const categoryColors = useMemo(\n () => {\n const colors = new Map();\n categories.forEach((category, index) => {\n // `findIndex` used to make the first duplicate id authoritative.\n if (colors.has(category.id)) return;\n const slot = (category.colorIndex ?? index + 1) - 1;\n colors.set(\n category.id,\n category.color ??\n palette[((slot % PALETTE_SIZE) + PALETTE_SIZE) % PALETTE_SIZE]\n );\n });\n return colors;\n },\n [categories, palette]\n );\n const colorOf = useCallback(\n (id: string | undefined) => id ? categoryColors.get(id) : undefined,\n [categoryColors]\n );\n\n const summary = useMemo(\n () => summariseMonth(days, grid, categories, isInMonth),\n [days, grid, categories, isInMonth]\n );\n\n const context = useMemo(\n () => ({\n month,\n setMonth,\n syncMonth,\n today,\n selected,\n select,\n days,\n categories,\n categoryLabels,\n colorOf,\n summary,\n entryLimit: resolvedEntryLimit,\n variant,\n fill,\n weekStartsOn: normalizedWeekStart,\n grid,\n locale,\n system,\n isInMonth,\n onDayPress,\n }),\n [\n month, setMonth, syncMonth, today, selected, select, days, categories, categoryLabels,\n colorOf, summary, resolvedEntryLimit, variant, fill, normalizedWeekStart,\n grid, locale, system, isInMonth, onDayPress,\n ]\n );\n\n /*\n * The header belongs in the Frame's strip and everything else in its\n * panel, so the root sorts its children rather than asking the caller to\n * nest them in two places to get one widget.\n */\n const parts = Children.toArray(children);\n const header = parts.find(\n (child) => isValidElement(child) && child.type === PlannerHeader\n );\n const body = parts.filter((child) => child !== header);\n\n return (\n \n {frame ? (\n // Full width by default: the grid is seven equal columns, and a\n // container that sizes to its content collapses them to the width of\n // a two-digit number. Under `fill` the panel has to flex too, or the\n // grid inside it stretches against a container that does not.\n \n {header}\n \n {body}\n \n \n ) : (\n \n {header}\n {body}\n \n )}\n \n );\n }\n);\nPlannerRoot.displayName = 'Planner';\n\n/* ------------------------------------------------------------------ *\n * Header strip\n * ------------------------------------------------------------------ */\n\nexport interface PlannerHeaderProps {\n children?: ReactNode;\n}\n\n/**\n * The strip along the top of the frame. Put `Title`, `Today` and `Nav` in it.\n *\n * The strip has two ends rather than an even spread. What the month *is* —\n * its name, and the way back to today — reads from the leading edge; what\n * *moves* it sits at the trailing edge, under the thumb that reaches for it.\n * Spaced evenly across a full-width strip they read as three unrelated\n * controls instead of a label and a pair of buttons.\n */\nfunction PlannerHeader({ children }: PlannerHeaderProps) {\n const parts = Children.toArray(children);\n const trails = (child: ReactNode) =>\n isValidElement(child) && (child.type === PlannerNav || child.type === PlannerAction);\n\n const trailing = parts.filter(trails);\n const lead = parts.filter((child) => !trails(child));\n\n return (\n \n {lead}\n {/*\n `ml-auto` rather than a flexible lead. The nav belongs against the right\n edge whatever is to its left — one short title, a title and a pill, or\n nothing at all — and pushing it there with a margin says that directly\n instead of leaving it to whether the thing beside it happened to grow.\n */}\n {trailing.length > 0 ? (\n {trailing}\n ) : null}\n \n );\n}\nPlannerHeader.displayName = 'Planner.Header';\n\nexport interface PlannerTitleProps extends Omit {\n /** Replaces the month name, for a title that says something else. */\n children?: ReactNode;\n}\n\n/** The month on show, in the calendar system and locale the grid uses. */\nfunction PlannerTitle({ children, className, ...props }: PlannerTitleProps) {\n const { month, system, locale } = usePlanner('Planner.Title');\n return (\n \n {children ?? calendarMonthLabel(month, system, locale)}\n \n );\n}\nPlannerTitle.displayName = 'Planner.Title';\n\nexport interface PlannerTodayProps {\n /** Replaces the word on the pill. */\n children?: ReactNode;\n}\n\n/** Jumps back to the month today is in, and selects nothing. */\nfunction PlannerToday({ children = 'Today' }: PlannerTodayProps) {\n const { today, setMonth, month, system, locale } = usePlanner('Planner.Today');\n const alreadyHere = isSameCalendarMonth(today, month, system, locale);\n\n return (\n setMonth(today)}\n disabled={alreadyHere}\n hitSlop={6}\n accessibilityRole=\"button\"\n accessibilityLabel=\"Go to this month\"\n accessibilityState={{ disabled: alreadyHere }}\n className={cn(\n 'rounded-full border border-border px-3 py-1',\n alreadyHere ? 'opacity-40' : 'active:bg-accent'\n )}\n >\n \n {children}\n \n \n );\n}\nPlannerToday.displayName = 'Planner.Today';\n\nexport interface PlannerNavProps {\n className?: string;\n}\n\n/** Back and forward a month. */\nfunction PlannerNav({ className }: PlannerNavProps) {\n const { month, setMonth, system, locale } = usePlanner('Planner.Nav');\n\n return (\n \n setMonth(addCalendarMonths(month, -1, system, locale))}\n />\n setMonth(addCalendarMonths(month, 1, system, locale))}\n />\n \n );\n}\nPlannerNav.displayName = 'Planner.Nav';\n\nfunction PlannerArrow({\n direction,\n onPress,\n}: {\n direction: 'previous' | 'next';\n onPress: () => void;\n}) {\n return (\n \n {direction === 'next' ? : }\n \n );\n}\n\nexport interface PlannerActionProps {\n children?: ReactNode;\n}\n\n/** The trailing end of the header strip, for a button of the caller's. */\nfunction PlannerAction({ children }: PlannerActionProps) {\n return {children};\n}\nPlannerAction.displayName = 'Planner.Action';\n\n/* ------------------------------------------------------------------ *\n * Grid\n * ------------------------------------------------------------------ */\n\n/** What a custom cell is handed. Everything the default cell draws from. */\nexport interface PlannerDayState {\n date: Date;\n entries: PlannerEntry[];\n isToday: boolean;\n isSelected: boolean;\n isInMonth: boolean;\n}\n\nexport type PlannerDayRenderer = (state: PlannerDayState) => ReactNode;\n\nexport interface PlannerGridProps {\n className?: string;\n /** Draws a cell yourself. It is handed the day and what falls on it. */\n renderDay?: PlannerDayRenderer;\n}\n\ninterface PlannerGridKeyDownEvent {\n nativeEvent: { key?: string };\n preventDefault: () => void;\n}\n\ninterface PlannerGridNavigationContextValue {\n activeIndex: number;\n indexByDay: ReadonlyMap;\n register: (index: number, node: View | null) => void;\n focus: (index: number, event: PlannerGridKeyDownEvent) => void;\n makeActive: (index: number) => void;\n}\n\nconst PlannerGridNavigationContext = createContext(null);\n\n/**\n * The weekday row and the six weeks below it.\n *\n * React Native has no per-cell grid vocabulary — no `gridcell`, no `row` — so\n * a screen reader never hears \"row three, column five\" and cannot fall back on\n * position for context. Every day therefore carries its own full date, and the\n * weekday headings are hidden rather than read out 42 times over.\n */\nfunction PlannerGrid({ className, renderDay }: PlannerGridProps) {\n const { grid: weeks, weekStartsOn, locale, selected, today, isInMonth, variant, fill } =\n usePlanner('Planner.Grid');\n const { grid, week: weekRow, heading } = plannerVariants({ fill });\n /*\n * `tiles` leaves the days either side of the month blank, so its columns are\n * told apart by position alone and a single letter is enough to head them.\n */\n const headings = useMemo(\n () => weekdayNames(locale, weekStartsOn, variant === 'tiles' ? 'narrow' : 'short'),\n [locale, weekStartsOn, variant]\n );\n /*\n * `tiles` draws nothing for the days either side of the month, so a week made\n * only of those is a band of empty space rather than a week. It is dropped,\n * and the grid keeps its height by letting the weeks that remain stretch.\n */\n const shownWeeks = useMemo(\n () => (variant === 'tiles' ? weeks.filter((week) => week.some(isInMonth)) : weeks),\n [weeks, variant, isInMonth]\n );\n const dates = useMemo(() => shownWeeks.flat(), [shownWeeks]);\n const indexByDay = useMemo(\n () => new Map(dates.map((date, index) => [date.getTime(), index])),\n [dates]\n );\n /*\n * A blank cell has nothing to focus, so arrow keys step over it rather than\n * landing on a day that is not drawn and losing focus altogether.\n */\n const navigable = useMemo(\n () =>\n variant === 'tiles' ? (index: number) => isInMonth(dates[index] ?? today) : undefined,\n [variant, dates, isInMonth, today]\n );\n const initialDay = selected ?? today;\n const [activeDay, setActiveDay] = useState(initialDay.getTime());\n const storedIndex = indexByDay.get(activeDay);\n const preferredIndex =\n (selected ? indexByDay.get(startOfDay(selected).getTime()) : undefined) ??\n indexByDay.get(today.getTime()) ??\n dates.findIndex(isInMonth);\n const activeIndex = storedIndex ?? Math.max(0, preferredIndex);\n const refs = useRef(new Map());\n const register = useCallback((index: number, node: View | null) => {\n if (node) refs.current.set(index, node);\n else refs.current.delete(index);\n }, []);\n const makeActive = useCallback(\n (index: number) => {\n const date = dates[index];\n if (date) setActiveDay(date.getTime());\n },\n [dates]\n );\n const focus = useCallback(\n (index: number, event: PlannerGridKeyDownEvent) => {\n const target = plannerGridTarget(\n event.nativeEvent.key ?? '',\n index,\n dates.length,\n 7,\n navigable\n );\n if (target === null) return;\n event.preventDefault();\n makeActive(target);\n refs.current.get(target)?.focus();\n },\n [dates.length, makeActive, navigable]\n );\n const navigation = useMemo(\n () => ({ activeIndex, indexByDay, register, focus, makeActive }),\n [activeIndex, indexByDay, register, focus, makeActive]\n );\n\n return (\n \n \n \n {/*\n Keyed by position rather than by the word: narrow names repeat in\n plenty of locales — English has T twice and S twice — and a column\n is identified by where it is, not by the letter over it.\n */}\n {headings.map((label, position) => (\n \n {label.toUpperCase()}\n \n ))}\n \n\n {shownWeeks.map((week, index) => (\n \n {week.map((date) => (\n \n ))}\n \n ))}\n \n \n );\n}\nPlannerGrid.displayName = 'Planner.Grid';\n\nexport interface PlannerDayProps {\n date: Date;\n renderDay?: PlannerDayRenderer;\n}\n\n/** One cell. Pressing it selects the day and opens whatever is bound to it. */\nfunction PlannerDay({ date, renderDay }: PlannerDayProps) {\n const {\n days, today, selected, select, colorOf, categoryLabels,\n entryLimit, variant, fill, locale, system, isInMonth, onDayPress,\n } = usePlanner('Planner.Day');\n const navigation = useContext(PlannerGridNavigationContext);\n const registerGridCell = navigation?.register;\n const focusGridCell = navigation?.focus;\n const makeGridCellActive = navigation?.makeActive;\n\n const entries = entriesOn(days, date);\n const inMonth = isInMonth(date);\n const isToday = isSameDay(date, today);\n const isSelected = selected ? isSameDay(date, selected) : false;\n const { shown, overflow } = visibleEntries(entries, entryLimit);\n const drawable = shown.filter((entry) => entry.icon);\n const styles = dayVariants({ variant, fill, inMonth, today: isToday, selected: isSelected });\n /*\n * The colour the day's entries give it. The first entry naming a category the\n * planner knows wins, and an uncategorised day gets nothing — the tint is a\n * signal about what is on the day, so a day with no answer stays plain rather\n * than being coloured for the sake of it.\n */\n const tint = markerColor(entries, colorOf);\n\n const label = dayAccessibilityLabel(\n calendarLongDate(date, system, locale),\n entries,\n categoryLabels\n );\n\n /*\n * Pressing the open day again closes it. Without this the mark is a one-way\n * door: a planner with no `Details` bound has nothing to dismiss, so the day\n * you pressed stays ringed until you press a different one and there is no\n * way back to none selected.\n */\n const press = () => {\n onDayPress?.(date, entries);\n select(isSelected ? null : date);\n };\n const gridIndex = navigation?.indexByDay.get(date.getTime());\n const setRef = useCallback(\n (node: View | null) => {\n if (gridIndex !== undefined) registerGridCell?.(gridIndex, node);\n },\n [gridIndex, registerGridCell]\n );\n const onFocus = useCallback(() => {\n if (gridIndex !== undefined) makeGridCellActive?.(gridIndex);\n }, [gridIndex, makeGridCellActive]);\n const onKeyDown = useCallback(\n (event: PlannerGridKeyDownEvent) => {\n if (gridIndex !== undefined) focusGridCell?.(gridIndex, event);\n },\n [gridIndex, focusGridCell]\n );\n const webGridProps =\n Platform.OS === 'web' && gridIndex !== undefined && navigation\n ? {\n ref: setRef,\n tabIndex: navigation.activeIndex === gridIndex ? (0 as const) : (-1 as const),\n onFocus,\n onKeyDown,\n }\n : {};\n\n if (renderDay) {\n return (\n \n {renderDay({ date, entries, isToday, isSelected, isInMonth: inMonth })}\n \n );\n }\n\n /*\n * `tiles` leaves the days either side of the month out altogether. The row\n * still holds their column, so the grid is six weeks whatever month it is\n * showing, but there is no tile, no number and nothing to press or to read\n * out: a blank is the answer, and drawing a faded one only invites the press\n * it is going to ignore.\n */\n if (variant === 'tiles' && !inMonth) {\n return (\n \n );\n }\n\n return (\n 0 ? 'Opens what is on this day' : undefined}\n accessibilityState={{ selected: isSelected }}\n className={styles.cell()}\n >\n {/*\n The tint sits under the content rather than on the cell itself: the\n colour arrives as a runtime string from the theme, so it cannot go\n through a class with an opacity on it, and painting the cell at full\n strength would take the number down with it.\n */}\n {variant === 'tiles' && tint ? (\n \n ) : null}\n\n {variant === 'tiles' ? (\n \n \n {calendarDayNumber(date, system, locale)}\n \n \n ) : variant === 'calendar' ? (\n /*\n * A disc around the date rather than a ring around the cell. There is\n * no tile here to ring, and the date is the only thing in the cell that\n * is always present — so it is the only place a mark can go and be\n * found in the same spot on every day of the month.\n */\n \n \n {calendarDayNumber(date, system, locale)}\n \n \n ) : (\n {calendarDayNumber(date, system, locale)}\n )}\n\n {variant === 'default' && entries.length > 0 ? (\n \n ) : null}\n\n {variant === 'calendar' ? (\n \n {shown.map((entry) => (\n \n ))}\n {/*\n Three dots, not a count. The cell has already run out of room for\n the entries themselves, and \"+2 more\" spends a whole row of that\n room on a number nobody acts on — the dots say there is more and\n leave the row for an entry.\n */}\n {overflow > 0 ? (\n \n •••\n \n ) : null}\n \n ) : null}\n\n {/*\n Only entries that brought an icon are drawn, and the overflow count\n goes with them. A day whose entries have no icons is already saying\n \"something is here\" with its marker; adding a bare \"+3\" under an empty\n row says it twice, in a way that looks like a stray number.\n */}\n {variant === 'tiles' && overflow > 0 ? (\n \n \n +{overflow}\n \n \n ) : null}\n\n {variant !== 'calendar' && drawable.length > 0 ? (\n \n {drawable.map((entry) => (\n {entry.icon}\n ))}\n {variant !== 'tiles' && overflow > 0 ? (\n \n +{overflow}\n \n ) : null}\n \n ) : null}\n \n );\n}\n\n/**\n * One named entry inside a `calendar` cell.\n *\n * A solid block of the entry's colour with light text on it. That is a real\n * constraint rather than a free choice: the colour is the caller's, so a pale\n * one will not carry white well — but a wash with the label in the foreground\n * colour makes every entry the same weight and loses the one thing the colour\n * was for, which is telling them apart down a column at a glance.\n */\nfunction PlannerChip({ entry, color }: { entry: PlannerEntry; color?: string }) {\n const { chip } = dayVariants();\n return (\n \n \n {entry.label}\n \n \n );\n}\n\n/**\n * The dot's colour: the first entry that names a category the planner knows.\n *\n * One dot rather than one per entry. A cell this size fits a row of dots or a\n * day number, and the count is already spoken — so the dot answers \"is there\n * anything here, and roughly what kind\", which is all it has room to answer.\n */\nfunction markerColor(\n entries: readonly PlannerEntry[],\n colorOf: (category: string | undefined) => string | undefined\n): string | undefined {\n for (const entry of entries) {\n // The entry's own colour first: a brand belongs to the thing, not to the\n // group it was filed under.\n const color = entry.color ?? colorOf(entry.category);\n if (color) return color;\n }\n return undefined;\n}\n\n/* ------------------------------------------------------------------ *\n * Legend and summary\n * ------------------------------------------------------------------ */\n\nexport interface PlannerLegendProps {\n className?: string;\n /** Print each category's count for the month beside its label. */\n counts?: boolean;\n /** Sits at the trailing end — a total, a currency, whatever the month adds to. */\n children?: ReactNode;\n}\n\n/**\n * The key to the dots.\n *\n * It prints the label beside every swatch, because a column of coloured dots\n * with nothing to read them against is a quiz.\n */\nfunction PlannerLegend({ className, counts = false, children }: PlannerLegendProps) {\n const { summary, colorOf } = usePlanner('Planner.Legend');\n const { legend, swatch } = plannerVariants();\n\n return (\n \n \n {summary.categories.map((category) => (\n \n \n \n {counts ? `${category.label} ${category.count}` : category.label}\n \n \n ))}\n \n {children}\n \n );\n}\nPlannerLegend.displayName = 'Planner.Legend';\n\nexport interface PlannerSummaryProps extends Omit {\n /** Replaces the count, for a total that is money rather than entries. */\n children?: ReactNode;\n}\n\n/** What the month adds up to. Counts this month only, never the days either side. */\nfunction PlannerSummary({ children, ...props }: PlannerSummaryProps) {\n const { summary } = usePlanner('Planner.Summary');\n return (\n \n {children ?? `${summary.total} ${summary.total === 1 ? 'entry' : 'entries'}`}\n \n );\n}\nPlannerSummary.displayName = 'Planner.Summary';\n\nexport interface PlannerFooterProps {\n className?: string;\n children?: ReactNode;\n}\n\n/** The strip along the bottom, for tools that act on the month. */\nfunction PlannerFooter({ className, children }: PlannerFooterProps) {\n return (\n \n {children}\n \n );\n}\nPlannerFooter.displayName = 'Planner.Footer';\n\n/* ------------------------------------------------------------------ *\n * Details\n * ------------------------------------------------------------------ */\n\nexport interface PlannerDetailsProps {\n className?: string;\n /** Title above the children. Defaults to the day's full date. */\n title?: ReactNode;\n /**\n * The line under the title. Defaults to how many entries the day carries,\n * so the dialog answers \"how much of this is there\" before it is read.\n * Pass `null` to drop it.\n */\n description?: ReactNode;\n /** Given the open day and what falls on it. */\n children: (date: Date, entries: PlannerEntry[]) => ReactNode;\n}\n\n/**\n * A dialog bound to the open day.\n *\n * The planner owns the grid and the binding; what the dialog says is the\n * application's, because the contents of a day are its data and not the\n * component's. Leave it out and `onDayPress` is still called — a planner that\n * pushes a screen instead of opening a dialog wants that and nothing else.\n */\nfunction PlannerDetails({ className, title, description, children }: PlannerDetailsProps) {\n const { selected, select, days, system, locale } = usePlanner('Planner.Details');\n const entries = selected ? entriesOn(days, selected) : [];\n\n const count =\n entries.length === 1 ? '1 entry' : `${entries.length} entries`;\n\n return (\n !open && select(null)}>\n \n \n \n {title ?? (selected ? calendarLongDate(selected, system, locale) : '')}\n \n {description === null ? null : (\n \n {description ?? (entries.length === 0 ? 'Nothing planned' : count)}\n \n )}\n \n {selected ? children(selected, entries) : null}\n \n \n );\n}\nPlannerDetails.displayName = 'Planner.Details';\n\n/* ------------------------------------------------------------------ *\n * Scroller\n * ------------------------------------------------------------------ */\n\n/** How many weeks either side of the opening month a scroller reaches by default. */\nconst DEFAULT_SCROLLER_WEEKS = 53;\n\n/** The height of one week row, and enough of it for a date and three entries. */\nconst DEFAULT_WEEK_HEIGHT = 96;\n\nexport interface PlannerScrollerProps {\n className?: string;\n /**\n * How many weeks either side of the opening month can be reached. Default\n * `53`, about a year each way.\n */\n weeks?: number;\n /** The height of one week row. Default `96`. */\n rowHeight?: number;\n /** Draws a cell yourself. It is handed the day and what falls on it. */\n renderDay?: PlannerDayRenderer;\n}\n\n/**\n * The weeks of the year, scrolled through rather than paged.\n *\n * A month grid answers \"what does this month look like\"; a scroller answers\n * \"what is coming\", which does not stop at the end of a month. The week\n * straddling the boundary is drawn once, in one piece, instead of appearing cut\n * in half at the bottom of one page and again at the top of the next.\n *\n * The range is bounded rather than endless. A scroller has to know its own\n * height to place a scrollbar and to jump to a month without rendering its way\n * there, and neither is possible over a list with no end.\n *\n * The month in the header follows the scroll: whichever month holds most of the\n * first week on screen is the one named, and the days either side of it grey\n * out. `Planner.Nav` and `Planner.Today` still work — they scroll the list\n * rather than replacing what is in it.\n */\nfunction PlannerScroller({\n className,\n weeks: span = DEFAULT_SCROLLER_WEEKS,\n rowHeight = DEFAULT_WEEK_HEIGHT,\n renderDay,\n}: PlannerScrollerProps) {\n const { month, syncMonth, weekStartsOn, locale, variant, system } =\n usePlanner('Planner.Scroller');\n const { week: weekRow, heading } = plannerVariants();\n const list = useRef>(null);\n\n /*\n * The range is anchored where the planner opened and never rebuilt. Rebuilding\n * it around the current month would move every row's index under the scroll\n * position each time the month changed, which is the one thing a list being\n * scrolled must not do.\n */\n const [anchor] = useState(() => month);\n const range = useMemo(\n () => weekRange(anchor, weekStartsOn, span, span),\n [anchor, weekStartsOn, span]\n );\n\n const headings = useMemo(\n () => weekdayNames(locale, weekStartsOn, 'narrow'),\n [locale, weekStartsOn]\n );\n\n /*\n * Which month the scroll is showing, reported up but not acted on here. The\n * ref is what stops a scroll that stays inside one month from setting it over\n * and over, and what stops the scroll this component performs in answer to a\n * month change from reporting that same month straight back.\n */\n const reported = useRef(month.getTime());\n const onViewable = useRef(({ viewableItems }: { viewableItems: ViewToken[] }) => {\n const first = viewableItems[0]?.item as Date | undefined;\n if (!first) return;\n const next = startOfCalendarMonth(weekAnchor(first), system, locale);\n if (next.getTime() === reported.current) return;\n reported.current = next.getTime();\n syncMonth(next);\n });\n\n /*\n * A month arriving from outside — the arrows, the Today pill, a controlled\n * prop — scrolls the list to it. A month the scroll itself just reported is\n * already there, and scrolling to it again would fight the finger.\n */\n useEffect(() => {\n if (month.getTime() === reported.current) return;\n reported.current = month.getTime();\n const index = weekIndex(range, month, weekStartsOn);\n if (index >= 0) list.current?.scrollToIndex({ index, animated: true });\n }, [month, range, weekStartsOn]);\n\n const initialIndex = Math.max(0, weekIndex(range, month, weekStartsOn));\n\n return (\n \n \n {headings.map((label, position) => (\n \n {label.toUpperCase()}\n \n ))}\n \n\n String(weekStart.getTime())}\n initialScrollIndex={initialIndex}\n // Every row is the same height, so the list can place any of them\n // without measuring — which is what makes jumping to a month cheap.\n getItemLayout={(_unused, index) => ({\n length: rowHeight,\n offset: rowHeight * index,\n index,\n })}\n onViewableItemsChanged={onViewable.current}\n viewabilityConfig={VIEWABILITY}\n showsVerticalScrollIndicator={false}\n renderItem={({ item }) => (\n \n {weekDays(item).map((date) => (\n \n ))}\n \n )}\n />\n \n );\n}\nPlannerScroller.displayName = 'Planner.Scroller';\n\n/*\n * The first row more than half on screen is the one that names the month. A\n * lower threshold hands the month over while the week is still mostly below the\n * fold, which reads as the header changing early.\n */\nconst VIEWABILITY = { itemVisiblePercentThreshold: 60 };\n\nexport const Planner = Object.assign(PlannerRoot, {\n Header: PlannerHeader,\n Title: PlannerTitle,\n Today: PlannerToday,\n Nav: PlannerNav,\n Action: PlannerAction,\n Grid: PlannerGrid,\n Scroller: PlannerScroller,\n Day: PlannerDay,\n Legend: PlannerLegend,\n Summary: PlannerSummary,\n Footer: PlannerFooter,\n Details: PlannerDetails,\n});\n\nexport type { PlannerCountedCategory };\n" }, { "path": "ui/planner-announcement.ts", diff --git a/apps/docs/public/r/post.json b/apps/docs/public/r/post.json index d2786659b..71571baa0 100644 --- a/apps/docs/public/r/post.json +++ b/apps/docs/public/r/post.json @@ -8,6 +8,7 @@ "cn", "haptics", "icons", + "refs", "text", "use-previous" ], @@ -22,7 +23,7 @@ { "path": "ui/post.tsx", "type": "registry:ui", - "content": "/**\n * Post — a card carrying something somebody said, and what everyone did about it.\n *\n * Four shapes, because a feed is not one thing. `feed` is the full card: author,\n * body, media and a row of counts. `vote` puts a score pill beside a headline\n * and a thumbnail, the way a ranked community reads. `compact` drops the media\n * and puts the name and handle on one line, for a dense timeline. `media` gives\n * the image the whole card and lays the author over it.\n *\n * What they share is the anatomy — the same `Post.Header`, `Post.Body`,\n * `Post.Footer` in every one — so moving between them is a prop rather than a\n * rewrite. The variant decides padding, media shape and where the author sits;\n * it does not decide which parts exist.\n *\n * ```tsx\n * \n * \n * \n * \n * \n * I've been paying off my credit card #FinancialFreedom\n * \n * \n * \n * \n * \n * \n * \n * \n * ```\n *\n * ## Counts change under the finger\n *\n * Every control here is a toggle over a number, and the number is the point: a\n * like that lights up but leaves `215` sitting there has not told you it\n * counted. So a stat that changes animates the old value out and the new one in\n * along the direction of the change, and `Post.Votes` does the same with its\n * arrows. The alternative — repainting the digits in place — is indisting-\n * uishable from a re-render, which is exactly the doubt the animation exists to\n * remove.\n */\nimport {\n createContext,\n forwardRef,\n useContext,\n useEffect,\n useMemo,\n useRef,\n type ComponentType,\n type ReactNode,\n} from 'react';\nimport {\n Image,\n Pressable,\n View,\n type ImageSourcePropType,\n type PressableProps,\n type ViewProps,\n} from 'react-native';\nimport Animated, {\n FadeIn,\n FadeInDown,\n FadeInUp,\n FadeOut,\n useAnimatedStyle,\n useReducedMotion,\n useSharedValue,\n withSequence,\n withSpring,\n withTiming,\n} from 'react-native-reanimated';\nimport { LinearGradient } from 'expo-linear-gradient';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport { useCSSVariable } from 'uniwind';\nimport {\n ArrowDownIcon,\n ArrowUpIcon,\n BadgeCheckIcon,\n type IconProps,\n} from '@/components/ui/icons';\nimport { usePrevious } from '@/hooks/use-previous';\nimport { AnimatedPressable } from '@/components/ui/animated-pressable';\nimport { Text, textChildren } from '@/components/ui/text';\nimport { cn } from '@/lib/cn';\nimport { selectionTick } from '@/lib/haptics';\nimport { Avatar } from '@/components/ui/avatar';\n\n/** Long enough to see the value move, short enough to keep up with a fast tap. */\nconst COUNT_DURATION = 160;\n\n/** The pop on a toggle: past 1 and back, so the press has a shape. */\nconst POP = { mass: 0.4, damping: 12, stiffness: 320 } as const;\n\nconst postVariants = tv({\n slots: {\n root: 'w-full overflow-hidden rounded-2xl border border-border bg-card',\n header: 'flex-row items-start gap-3',\n author: 'min-w-0 flex-1 gap-0.5',\n authorLine: 'flex-row items-center gap-1.5',\n name: 'font-semibold text-card-foreground',\n handle: 'text-muted-foreground',\n meta: 'text-sm text-muted-foreground',\n // As tall as the name's own line box, so at `items-start` the menu lands on\n // the username's line rather than somewhere between the two lines of the\n // author block — and lands there identically in all four variants.\n action: 'h-6 shrink-0 items-center justify-center',\n // `flex-1` for the same reason `Post.Author` has it: it is the header's\n // flexible side, and without it the row is only as wide as the group's\n // name — so `Post.Action` sits against the name instead of at the edge.\n community: 'min-w-0 h-6 flex-1 flex-row items-center gap-2',\n communityName: 'text-xs font-semibold text-card-foreground',\n communityMeta: 'text-xs text-muted-foreground',\n title: 'text-base font-semibold leading-snug text-card-foreground',\n body: 'text-card-foreground',\n media: 'overflow-hidden bg-muted',\n footer: 'flex-row items-center',\n stat: 'flex-row items-center gap-1.5 rounded-full',\n statLabel: 'text-sm text-muted-foreground',\n votes: 'flex-row items-center gap-0.5 rounded-full bg-muted',\n voteButton: 'h-8 w-8 items-center justify-center rounded-full',\n voteScore: 'text-sm font-semibold tabular-nums',\n },\n variants: {\n variant: {\n /** The full card — author, body, media, counts. */\n feed: {\n header: 'p-4 pb-3',\n body: 'px-4 pb-3 text-base leading-relaxed',\n media: 'mx-4 rounded-xl',\n footer: 'gap-5 p-4',\n },\n /** A headline with a score beside it, the way a ranked community reads. */\n vote: {\n header: 'px-4 pb-2 pt-3.5',\n body: 'px-4 pb-3 text-sm leading-relaxed',\n media: 'mx-4 rounded-xl',\n footer: 'gap-2 px-4 pb-3.5',\n },\n /** No media, name and handle on one line — for a dense timeline. */\n compact: {\n root: 'rounded-xl',\n header: 'p-3.5 pb-2',\n body: 'px-3.5 pb-2.5 text-sm leading-relaxed',\n media: 'mx-3.5 rounded-lg',\n footer: 'gap-6 px-3.5 pb-3',\n },\n /** The image is the card; the author is laid over it. */\n media: {\n header: 'absolute inset-x-0 top-0 z-10 p-3',\n body: 'px-4 pb-3 pt-3 text-sm leading-relaxed',\n media: 'rounded-none',\n footer: 'gap-5 px-4 pb-4',\n },\n },\n size: {\n default: {},\n sm: { name: 'text-sm', meta: 'text-xs', statLabel: 'text-xs' },\n },\n },\n defaultVariants: {\n variant: 'feed',\n size: 'default',\n },\n});\n\ntype PostVariant = 'feed' | 'vote' | 'compact' | 'media';\n\nconst PostContext = createContext<{ variant: PostVariant; size: 'default' | 'sm' }>({\n variant: 'feed',\n size: 'default',\n});\n\nfunction usePost() {\n return useContext(PostContext);\n}\n\nexport interface PostProps\n extends Omit,\n VariantProps {\n className?: string;\n /** Which of the four shapes. */\n variant?: PostVariant;\n /** `sm` tightens the type for a card in a sidebar or a preview. */\n size?: 'default' | 'sm';\n /** Opening the post itself. The parts inside keep their own presses. */\n onPress?: PressableProps['onPress'];\n children?: ReactNode;\n}\n\nconst PostRoot = forwardRef(\n ({ className, variant = 'feed', size = 'default', onPress, children, ...props }, ref) => {\n const { root } = postVariants({ variant, size });\n const context = useMemo(() => ({ variant, size }), [variant, size]);\n\n const body = (\n {children}\n );\n\n if (!onPress) {\n return (\n \n {body}\n \n );\n }\n\n return (\n // `article` rather than `button`: the card opens, but the row of controls\n // inside it is the reason anyone is here, and announcing the whole thing\n // as one button buries them.\n \n {body}\n \n );\n }\n);\nPostRoot.displayName = 'Post';\n\n/* -------------------------------------------------------------------------- */\n/* Header */\n/* -------------------------------------------------------------------------- */\n\nexport interface PostHeaderProps extends ViewProps {\n className?: string;\n children?: ReactNode;\n}\n\n/**\n * The author row. `Post.Action` is pulled to the trailing edge, so the overflow\n * menu stays in the corner as a long display name wraps rather than riding down\n * with it.\n */\nconst PostHeader = forwardRef(\n ({ className, children, ...props }, ref) => {\n const { variant, size } = usePost();\n const { header } = postVariants({ variant, size });\n\n const content: ReactNode[] = [];\n const actions: ReactNode[] = [];\n for (const child of Array.isArray(children) ? children : [children]) {\n if (isType(child, PostAction)) actions.push(child);\n else content.push(child);\n }\n\n return (\n \n {content}\n {actions}\n \n );\n }\n);\nPostHeader.displayName = 'Post.Header';\n\nfunction isType(child: ReactNode, type: unknown): boolean {\n return (\n typeof child === 'object' &&\n child !== null &&\n 'type' in child &&\n (child as { type?: unknown }).type === type\n );\n}\n\nexport interface PostAuthorProps extends Omit {\n className?: string;\n /** Display name. */\n name: string;\n /** `@handle`, shown beside the name in `compact` and under it elsewhere. */\n handle?: string;\n avatar?: ImageSourcePropType;\n /** Initials behind a missing or broken avatar. */\n fallback?: string;\n /** Draws the verification rosette after the name. */\n verified?: boolean;\n /** \"Posted 3m ago\" — whatever the caller wants to call the time. */\n timestamp?: ReactNode;\n children?: ReactNode;\n}\n\n/**\n * Who posted it.\n *\n * The handle sits beside the name in `compact` and beneath it everywhere else,\n * which is the difference between a timeline row and a card: a dense row cannot\n * afford a second line, and a card looks starved without one.\n */\nconst PostAuthor = forwardRef(\n (\n { className, name, handle, avatar, fallback, verified, timestamp, children, ...props },\n ref\n ) => {\n const { variant, size } = usePost();\n const { author, authorLine, name: nameSlot, handle: handleSlot, meta } = postVariants({\n variant,\n size,\n });\n const verifiedColor = useCSSVariable('--color-primary');\n const onVerified = useCSSVariable('--color-primary-foreground');\n\n const inline = variant === 'compact';\n const overlaid = variant === 'media';\n\n return (\n // Top-aligned, not centred. The name is the first line of the block, and\n // aligning to the top is what puts it — and therefore the menu opposite\n // it — on the same rule in every variant, whether the block below the\n // name is one line, two, or none.\n \n \n \n {/* `h-6` fixes the name's line box, so the block below it starts at\n the same offset whatever the type scale rounds the line height\n to — which is what the menu opposite is aligning against. */}\n \n \n {name}\n \n {verified ? (\n \n ) : null}\n {inline && handle ? (\n \n {handle}\n \n ) : null}\n {inline && timestamp !== undefined ? (\n <>\n \n ·\n \n {textChildren(timestamp, (text) => (\n \n {text}\n \n ))}\n \n ) : null}\n \n\n {!inline && (handle || timestamp !== undefined) ? (\n \n {[handle, typeof timestamp === 'string' ? timestamp : undefined]\n .filter(Boolean)\n .join(' · ')}\n \n ) : null}\n {!inline && timestamp !== undefined && typeof timestamp !== 'string'\n ? timestamp\n : null}\n {children}\n \n \n );\n }\n);\nPostAuthor.displayName = 'Post.Author';\n\n/** First letters of the first two words — enough to tell two people apart. */\nfunction initials(name: string): string {\n return name\n .split(/\\s+/)\n .slice(0, 2)\n .map((word) => word[0] ?? '')\n .join('')\n .toUpperCase();\n}\n\nexport interface PostActionProps extends ViewProps {\n className?: string;\n children?: ReactNode;\n}\n\n/** The header's trailing slot — an overflow menu, a follow button, a badge. */\nconst PostAction = forwardRef(\n ({ className, children, ...props }, ref) => {\n const { variant, size } = usePost();\n const { action } = postVariants({ variant, size });\n return (\n \n {children}\n \n );\n }\n);\nPostAction.displayName = 'Post.Action';\n\nexport interface PostCommunityProps extends Omit {\n className?: string;\n /** The group's name — \"r/reactnative\", \"#design\". */\n name: string;\n avatar?: ImageSourcePropType;\n /** How long ago, and anything else that belongs on the line. */\n meta?: ReactNode;\n children?: ReactNode;\n}\n\n/**\n * The group a post was made in, above its headline.\n *\n * Smaller and quieter than an author row, because in a ranked community the\n * post's subject is the headline — the group is where it came from, not who\n * said it.\n */\nconst PostCommunity = forwardRef(\n ({ className, name, avatar, meta, children, ...props }, ref) => {\n const { variant, size } = usePost();\n const {\n community,\n communityName,\n communityMeta,\n } = postVariants({ variant, size });\n\n return (\n \n \n \n {name}\n \n {meta !== undefined ? (\n <>\n ·\n {textChildren(meta, (text) => (\n {text}\n ))}\n \n ) : null}\n {children}\n \n );\n }\n);\nPostCommunity.displayName = 'Post.Community';\n\n/* -------------------------------------------------------------------------- */\n/* Body */\n/* -------------------------------------------------------------------------- */\n\nexport interface PostTitleProps extends ViewProps {\n className?: string;\n children?: ReactNode;\n}\n\n/** The headline of a post whose subject is a headline rather than a person. */\nconst PostTitle = forwardRef(\n ({ className, children, ...props }, ref) => {\n const { variant, size } = usePost();\n const { title } = postVariants({ variant, size });\n return (\n \n {textChildren(children, (text) => {text})}\n \n );\n }\n);\nPostTitle.displayName = 'Post.Title';\n\nexport interface PostBodyProps extends Omit {\n className?: string;\n /** How many lines before it is cut off. Unlimited by default. */\n numberOfLines?: number;\n /** Called with the tag, without its `#`. Makes hashtags pressable. */\n onTagPress?: (tag: string) => void;\n /** Called with the handle, without its `@`. */\n onMentionPress?: (handle: string) => void;\n children?: ReactNode;\n}\n\n/**\n * What was written.\n *\n * Hashtags and mentions inside a string child are picked out and coloured. They\n * are found in the text rather than declared, because a post body arrives as\n * one string from wherever it was typed — asking the caller to pre-split it\n * would mean every caller writing the same tokeniser.\n *\n * A non-string child is left entirely alone: it has already been laid out by\n * someone who knew more than we do.\n */\nconst PostBody = forwardRef(\n (\n { className, numberOfLines, onTagPress, onMentionPress, children, ...props },\n ref\n ) => {\n const { variant, size } = usePost();\n const { body } = postVariants({ variant, size });\n\n if (typeof children !== 'string') {\n return (\n \n {children}\n \n );\n }\n\n return (\n \n \n {tokenize(children).map((token, index) =>\n token.kind === 'text' ? (\n token.value\n ) : (\n onTagPress(token.value.slice(1)))\n : onMentionPress && (() => onMentionPress(token.value.slice(1)))\n }\n >\n {token.value}\n \n )\n )}\n \n \n );\n }\n);\nPostBody.displayName = 'Post.Body';\n\ninterface Token {\n kind: 'text' | 'tag' | 'mention';\n value: string;\n}\n\n/**\n * Splits a body into plain runs, hashtags and mentions.\n *\n * The tag pattern stops at whitespace and at punctuation that ends a sentence,\n * so \"#DebtSnowball.\" highlights the tag and leaves the full stop black — the\n * naive `\\S+` swallows it and the sentence loses its ending.\n */\nfunction tokenize(text: string): Token[] {\n const pattern = /[#@][\\w-]+/g;\n const tokens: Token[] = [];\n let last = 0;\n\n for (const match of text.matchAll(pattern)) {\n const start = match.index ?? 0;\n if (start > last) tokens.push({ kind: 'text', value: text.slice(last, start) });\n tokens.push({\n kind: match[0].startsWith('#') ? 'tag' : 'mention',\n value: match[0],\n });\n last = start + match[0].length;\n }\n if (last < text.length) tokens.push({ kind: 'text', value: text.slice(last) });\n return tokens;\n}\n\nexport interface PostMediaProps extends Omit {\n className?: string;\n source: ImageSourcePropType;\n /** Width over height. `16 / 10` by default — wide enough not to eat the feed. */\n aspectRatio?: number;\n /**\n * Darkens an edge of the image so type laid over it stays legible.\n *\n * A gradient rather than a panel: a flat rectangle over the top of a\n * photograph has an edge of its own, and that edge reads as a bar covering\n * the picture rather than as shading. `media` posts default to `top`, where\n * the author sits; everything else to `none`.\n */\n scrim?: 'none' | 'top' | 'bottom' | 'both';\n /** Laid over the image: an expand affordance, a duration, a gallery count. */\n overlay?: ReactNode;\n /** Described for a screen reader. An image with nothing to say is decorative. */\n alt?: string;\n onPress?: PressableProps['onPress'];\n children?: ReactNode;\n}\n\n/**\n * Black, fading to nothing.\n *\n * Three stops rather than two: a straight linear ramp from 55% to nothing has a\n * visible shoulder where it meets the picture, and the eye reads that shoulder\n * as an edge — which is the whole thing a scrim exists to avoid.\n */\nconst SCRIM_COLOURS: readonly [string, string, string] = [\n 'rgba(0,0,0,0.55)',\n 'rgba(0,0,0,0.28)',\n 'rgba(0,0,0,0)',\n];\n\n/**\n * The picture.\n *\n * A fixed aspect ratio rather than the image's own, because a feed of cards\n * whose heights are decided by whatever was uploaded scrolls like a broken\n * staircase. The image covers the box and is cropped by it.\n */\nconst PostMedia = forwardRef(\n (\n {\n className,\n source,\n aspectRatio = 16 / 10,\n scrim,\n overlay,\n alt,\n onPress,\n children,\n ...props\n },\n ref\n ) => {\n const { variant, size } = usePost();\n const { media } = postVariants({ variant, size });\n // The media variant lays the author over the image, so it needs shading by\n // default — the caller should not have to remember what makes their own\n // layout readable.\n const shade = scrim ?? (variant === 'media' ? 'top' : 'none');\n\n const picture = (\n <>\n \n {shade === 'top' || shade === 'both' ? (\n \n ) : null}\n {shade === 'bottom' || shade === 'both' ? (\n \n ) : null}\n {overlay ? (\n \n {overlay}\n \n ) : null}\n {children}\n \n );\n\n if (!onPress) {\n return (\n \n {picture}\n \n );\n }\n\n return (\n \n {picture}\n \n );\n }\n);\nPostMedia.displayName = 'Post.Media';\n\n/* -------------------------------------------------------------------------- */\n/* Footer */\n/* -------------------------------------------------------------------------- */\n\nexport interface PostFooterProps extends ViewProps {\n className?: string;\n children?: ReactNode;\n}\n\n/** The row of counts and controls under the post. */\nconst PostFooter = forwardRef(\n ({ className, children, ...props }, ref) => {\n const { variant, size } = usePost();\n const { footer } = postVariants({ variant, size });\n return (\n \n {children}\n \n );\n }\n);\nPostFooter.displayName = 'Post.Footer';\n\n/** What lighting up means. A like is red, a save is the accent, a repost green. */\nconst statTone: Record<'default' | 'like' | 'save' | 'repost', string> = {\n default: '--color-primary',\n like: '--color-destructive',\n save: '--color-primary',\n repost: '--color-success',\n};\n\nexport interface PostStatProps extends Omit {\n className?: string;\n /** The icon component itself, not an element — it is re-rendered on toggle. */\n icon?: ComponentType;\n /** The number, or a word where a number would be meaningless (\"Save\"). */\n value?: ReactNode;\n /** Lit, and filled. */\n active?: boolean;\n /** Which colour \"lit\" is. */\n tone?: 'default' | 'like' | 'save' | 'repost';\n /** Pushes this stat and everything after it to the trailing edge. */\n align?: 'start' | 'end';\n children?: ReactNode;\n}\n\n/**\n * One count in the footer, and the control that changes it.\n *\n * Pressing it pops the icon past its own size and settles back, fills it, and\n * animates the value: the old number leaves upwards and the new one arrives\n * from below when the count goes up, and the other way when it comes down.\n * Repainting the digits in place is indistinguishable from a re-render, which\n * is the doubt this exists to remove.\n */\nconst PostStat = forwardRef(\n (\n {\n className,\n icon: Icon,\n value,\n active = false,\n tone = 'default',\n align = 'start',\n onPress,\n children,\n ...props\n },\n ref\n ) => {\n const { variant, size } = usePost();\n const { stat, statLabel } = postVariants({ variant, size });\n const litColor = useCSSVariable(statTone[tone]);\n const restColor = useCSSVariable('--color-muted-foreground');\n const reducedMotion = useReducedMotion();\n\n const pop = useSharedValue(1);\n const first = useRef(true);\n\n useEffect(() => {\n if (first.current) {\n first.current = false;\n return;\n }\n if (reducedMotion) return;\n // Past 1 and back. A toggle that only grows reads as a state that got\n // bigger; one that overshoots reads as a press.\n pop.value = withSequence(\n withTiming(active ? 1.25 : 0.85, { duration: 90 }),\n withSpring(1, POP)\n );\n }, [active, reducedMotion, pop]);\n\n const iconStyle = useAnimatedStyle(() => ({ transform: [{ scale: pop.value }] }));\n\n const colour = active\n ? typeof litColor === 'string'\n ? litColor\n : '#e11d48'\n : typeof restColor === 'string'\n ? restColor\n : '#737373';\n\n const inner = (\n <>\n {Icon ? (\n \n \n \n ) : null}\n {value !== undefined ? (\n \n ) : null}\n {children}\n \n );\n\n const classes = stat({ className: cn(align === 'end' && 'ms-auto', className) });\n\n if (!onPress) {\n return (\n \n {inner}\n \n );\n }\n\n return (\n {\n selectionTick();\n onPress(event);\n }}\n className={classes}\n {...props}\n >\n {inner}\n \n );\n }\n);\nPostStat.displayName = 'Post.Stat';\n\n/**\n * A number that moves when it changes.\n *\n * The new value arrives from the direction the count travelled — rising into\n * place when it went up, dropping in when it came down — while the old one\n * fades. `215 → 216` then reads as a count going up rather than as text being\n * replaced, which is the doubt worth removing: repainting digits in place is\n * indistinguishable from a re-render.\n *\n * Only the entering half carries the direction. The element leaving was\n * rendered before anyone knew which way the number would go, so asking it to\n * animate knowingly would mean knowing the future.\n *\n * A value that is not a number — \"Save\" — cross-fades, since there is no\n * direction for a word to go.\n */\nfunction PostCount({\n value,\n className,\n color,\n}: {\n value: ReactNode;\n className?: string;\n color?: string;\n}) {\n const reducedMotion = useReducedMotion();\n // Read during the render the value changes on, not after it: an effect would\n // settle the direction one frame after the animation had already started.\n const before = numeric(usePrevious(value));\n const after = numeric(value);\n const direction =\n before === null || after === null || before === after ? 0 : after > before ? 1 : -1;\n\n const label = (\n \n {value}\n \n );\n\n if (reducedMotion) return label;\n\n return (\n \n {label}\n \n );\n}\n\n/** The value as a number, where it is one. `\"5,874\"` counts; `\"Save\"` does not. */\nfunction numeric(value: ReactNode): number | null {\n if (typeof value === 'number') return value;\n if (typeof value !== 'string') return null;\n const parsed = Number(value.replace(/[,\\s]/g, ''));\n return Number.isFinite(parsed) ? parsed : null;\n}\n\n/* -------------------------------------------------------------------------- */\n/* Votes */\n/* -------------------------------------------------------------------------- */\n\nexport type PostVote = 'up' | 'down' | null;\n\nexport interface PostVotesProps extends Omit {\n className?: string;\n /** The score as it stands, with the reader's own vote already in it. */\n score: number | string;\n /** Which way this reader voted, if either. */\n vote?: PostVote;\n /**\n * Called with the new vote. Pressing the arrow already cast clears it, so\n * `null` arrives as often as the other two — a vote you cannot take back is\n * a vote people hesitate over.\n */\n onVote?: (vote: PostVote) => void;\n /** `vertical` stacks the arrows beside a thumbnail, the way a ranked list reads. */\n orientation?: 'horizontal' | 'vertical';\n disabled?: boolean;\n}\n\n/**\n * The score, and the two arrows that move it.\n *\n * Both arrows fill and take a colour when cast, and the whole pill tints with\n * them — the arrow alone is 16pt of a card, which is not enough to notice from\n * arm's length. The score rolls in the direction of the change.\n *\n * Pressing the arrow already cast clears the vote. This is not a nicety: a\n * score is a thing people change their minds about, and a vote that cannot be\n * withdrawn is one they think twice before casting.\n */\nconst PostVotes = forwardRef(\n (\n {\n className,\n score,\n vote = null,\n onVote,\n orientation = 'horizontal',\n disabled = false,\n ...props\n },\n ref\n ) => {\n const { variant, size } = usePost();\n const { votes, voteButton, voteScore } = postVariants({ variant, size });\n const upColor = useCSSVariable('--color-success');\n const downColor = useCSSVariable('--color-destructive');\n const restColor = useCSSVariable('--color-muted-foreground');\n\n const up = typeof upColor === 'string' ? upColor : '#16a34a';\n const down = typeof downColor === 'string' ? downColor : '#dc2626';\n const rest = typeof restColor === 'string' ? restColor : '#737373';\n\n const cast = (next: 'up' | 'down') => {\n selectionTick();\n onVote?.(vote === next ? null : next);\n };\n\n const scoreColour = vote === 'up' ? up : vote === 'down' ? down : undefined;\n\n return (\n \n cast('up')}\n />\n \n cast('down')}\n />\n \n );\n }\n);\nPostVotes.displayName = 'Post.Votes';\n\n/**\n * One arrow.\n *\n * It travels along its own axis as it is cast — up for the up arrow, down for\n * the down one — and springs back. The direction of the throw is the whole\n * point: an arrow that merely scales says *something happened*, one that moves\n * the way it points says *which way*.\n */\nfunction VoteArrow({\n direction,\n active,\n color,\n disabled,\n className,\n onPress,\n}: {\n direction: 'up' | 'down';\n active: boolean;\n color: string;\n disabled: boolean;\n className: string;\n onPress: () => void;\n}) {\n const reducedMotion = useReducedMotion();\n const shift = useSharedValue(0);\n const scale = useSharedValue(1);\n const first = useRef(true);\n\n useEffect(() => {\n if (first.current) {\n first.current = false;\n return;\n }\n if (reducedMotion) return;\n const travel = direction === 'up' ? -5 : 5;\n shift.value = withSequence(\n withTiming(active ? travel : -travel * 0.4, { duration: 100 }),\n withSpring(0, POP)\n );\n scale.value = withSequence(\n withTiming(active ? 1.2 : 0.9, { duration: 100 }),\n withSpring(1, POP)\n );\n }, [active, direction, reducedMotion, shift, scale]);\n\n const style = useAnimatedStyle(() => ({\n transform: [{ translateY: shift.value }, { scale: scale.value }],\n }));\n\n const Arrow = direction === 'up' ? ArrowUpIcon : ArrowDownIcon;\n\n return (\n \n \n \n \n \n );\n}\n\nexport const Post = Object.assign(PostRoot, {\n Header: PostHeader,\n Author: PostAuthor,\n Action: PostAction,\n Community: PostCommunity,\n Title: PostTitle,\n Body: PostBody,\n Media: PostMedia,\n Footer: PostFooter,\n Stat: PostStat,\n Votes: PostVotes,\n});\n" + "content": "/**\n * Post — a card carrying something somebody said, and what everyone did about it.\n *\n * Four shapes, because a feed is not one thing. `feed` is the full card: author,\n * body, media and a row of counts. `vote` puts a score pill beside a headline\n * and a thumbnail, the way a ranked community reads. `compact` drops the media\n * and puts the name and handle on one line, for a dense timeline. `media` gives\n * the image the whole card and lays the author over it.\n *\n * What they share is the anatomy — the same `Post.Header`, `Post.Body`,\n * `Post.Footer` in every one — so moving between them is a prop rather than a\n * rewrite. The variant decides padding, media shape and where the author sits;\n * it does not decide which parts exist.\n *\n * ```tsx\n * \n * \n * \n * \n * \n * I've been paying off my credit card #FinancialFreedom\n * \n * \n * \n * \n * \n * \n * \n * \n * ```\n *\n * ## Counts change under the finger\n *\n * Every control here is a toggle over a number, and the number is the point: a\n * like that lights up but leaves `215` sitting there has not told you it\n * counted. So a stat that changes animates the old value out and the new one in\n * along the direction of the change, and `Post.Votes` does the same with its\n * arrows. The alternative — repainting the digits in place — is indisting-\n * uishable from a re-render, which is exactly the doubt the animation exists to\n * remove.\n */\nimport {\n createContext,\n forwardRef,\n useContext,\n useEffect,\n useMemo,\n useRef,\n type ComponentType,\n type ReactNode,\n} from 'react';\nimport {\n Image,\n Pressable,\n View,\n type ImageSourcePropType,\n type PressableProps,\n type ViewProps,\n} from 'react-native';\nimport Animated, {\n FadeIn,\n FadeInDown,\n FadeInUp,\n FadeOut,\n useAnimatedStyle,\n useReducedMotion,\n useSharedValue,\n withSequence,\n withSpring,\n withTiming,\n} from 'react-native-reanimated';\nimport { LinearGradient } from 'expo-linear-gradient';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport { useCSSVariable } from 'uniwind';\nimport {\n ArrowDownIcon,\n ArrowUpIcon,\n BadgeCheckIcon,\n type IconProps,\n} from '@/components/ui/icons';\nimport { usePrevious } from '@/hooks/use-previous';\nimport { AnimatedPressable } from '@/components/ui/animated-pressable';\nimport { Text, textChildren } from '@/components/ui/text';\nimport { cn } from '@/lib/cn';\nimport { selectionTick } from '@/lib/haptics';\nimport { Avatar } from '@/components/ui/avatar';\nimport type { ViewRef } from '@/components/ui/refs';\n\n/** Long enough to see the value move, short enough to keep up with a fast tap. */\nconst COUNT_DURATION = 160;\n\n/** The pop on a toggle: past 1 and back, so the press has a shape. */\nconst POP = { mass: 0.4, damping: 12, stiffness: 320 } as const;\n\nconst postVariants = tv({\n slots: {\n root: 'w-full overflow-hidden rounded-2xl border border-border bg-card',\n header: 'flex-row items-start gap-3',\n author: 'min-w-0 flex-1 gap-0.5',\n authorLine: 'flex-row items-center gap-1.5',\n name: 'font-semibold text-card-foreground',\n handle: 'text-muted-foreground',\n meta: 'text-sm text-muted-foreground',\n // As tall as the name's own line box, so at `items-start` the menu lands on\n // the username's line rather than somewhere between the two lines of the\n // author block — and lands there identically in all four variants.\n action: 'h-6 shrink-0 items-center justify-center',\n // `flex-1` for the same reason `Post.Author` has it: it is the header's\n // flexible side, and without it the row is only as wide as the group's\n // name — so `Post.Action` sits against the name instead of at the edge.\n community: 'min-w-0 h-6 flex-1 flex-row items-center gap-2',\n communityName: 'text-xs font-semibold text-card-foreground',\n communityMeta: 'text-xs text-muted-foreground',\n title: 'text-base font-semibold leading-snug text-card-foreground',\n body: 'text-card-foreground',\n media: 'overflow-hidden bg-muted',\n footer: 'flex-row items-center',\n stat: 'flex-row items-center gap-1.5 rounded-full',\n statLabel: 'text-sm text-muted-foreground',\n votes: 'flex-row items-center gap-0.5 rounded-full bg-muted',\n voteButton: 'h-8 w-8 items-center justify-center rounded-full',\n voteScore: 'text-sm font-semibold tabular-nums',\n },\n variants: {\n variant: {\n /** The full card — author, body, media, counts. */\n feed: {\n header: 'p-4 pb-3',\n body: 'px-4 pb-3 text-base leading-relaxed',\n media: 'mx-4 rounded-xl',\n footer: 'gap-5 p-4',\n },\n /** A headline with a score beside it, the way a ranked community reads. */\n vote: {\n header: 'px-4 pb-2 pt-3.5',\n body: 'px-4 pb-3 text-sm leading-relaxed',\n media: 'mx-4 rounded-xl',\n footer: 'gap-2 px-4 pb-3.5',\n },\n /** No media, name and handle on one line — for a dense timeline. */\n compact: {\n root: 'rounded-xl',\n header: 'p-3.5 pb-2',\n body: 'px-3.5 pb-2.5 text-sm leading-relaxed',\n media: 'mx-3.5 rounded-lg',\n footer: 'gap-6 px-3.5 pb-3',\n },\n /** The image is the card; the author is laid over it. */\n media: {\n header: 'absolute inset-x-0 top-0 z-10 p-3',\n body: 'px-4 pb-3 pt-3 text-sm leading-relaxed',\n media: 'rounded-none',\n footer: 'gap-5 px-4 pb-4',\n },\n },\n size: {\n default: {},\n sm: { name: 'text-sm', meta: 'text-xs', statLabel: 'text-xs' },\n },\n },\n defaultVariants: {\n variant: 'feed',\n size: 'default',\n },\n});\n\ntype PostVariant = 'feed' | 'vote' | 'compact' | 'media';\n\nconst PostContext = createContext<{ variant: PostVariant; size: 'default' | 'sm' }>({\n variant: 'feed',\n size: 'default',\n});\n\nfunction usePost() {\n return useContext(PostContext);\n}\n\nexport interface PostProps\n extends Omit,\n VariantProps {\n className?: string;\n /** Which of the four shapes. */\n variant?: PostVariant;\n /** `sm` tightens the type for a card in a sidebar or a preview. */\n size?: 'default' | 'sm';\n /** Opening the post itself. The parts inside keep their own presses. */\n onPress?: PressableProps['onPress'];\n children?: ReactNode;\n}\n\nconst PostRoot = forwardRef(\n ({ className, variant = 'feed', size = 'default', onPress, children, ...props }, ref) => {\n const { root } = postVariants({ variant, size });\n const context = useMemo(() => ({ variant, size }), [variant, size]);\n\n const body = (\n {children}\n );\n\n if (!onPress) {\n return (\n \n {body}\n \n );\n }\n\n return (\n // `article` rather than `button`: the card opens, but the row of controls\n // inside it is the reason anyone is here, and announcing the whole thing\n // as one button buries them.\n \n {body}\n \n );\n }\n);\nPostRoot.displayName = 'Post';\n\n/* -------------------------------------------------------------------------- */\n/* Header */\n/* -------------------------------------------------------------------------- */\n\nexport interface PostHeaderProps extends ViewProps {\n className?: string;\n children?: ReactNode;\n}\n\n/**\n * The author row. `Post.Action` is pulled to the trailing edge, so the overflow\n * menu stays in the corner as a long display name wraps rather than riding down\n * with it.\n */\nconst PostHeader = forwardRef(\n ({ className, children, ...props }, ref) => {\n const { variant, size } = usePost();\n const { header } = postVariants({ variant, size });\n\n const content: ReactNode[] = [];\n const actions: ReactNode[] = [];\n for (const child of Array.isArray(children) ? children : [children]) {\n if (isType(child, PostAction)) actions.push(child);\n else content.push(child);\n }\n\n return (\n \n {content}\n {actions}\n \n );\n }\n);\nPostHeader.displayName = 'Post.Header';\n\nfunction isType(child: ReactNode, type: unknown): boolean {\n return (\n typeof child === 'object' &&\n child !== null &&\n 'type' in child &&\n (child as { type?: unknown }).type === type\n );\n}\n\nexport interface PostAuthorProps extends Omit {\n className?: string;\n /** Display name. */\n name: string;\n /** `@handle`, shown beside the name in `compact` and under it elsewhere. */\n handle?: string;\n avatar?: ImageSourcePropType;\n /** Initials behind a missing or broken avatar. */\n fallback?: string;\n /** Draws the verification rosette after the name. */\n verified?: boolean;\n /** \"Posted 3m ago\" — whatever the caller wants to call the time. */\n timestamp?: ReactNode;\n children?: ReactNode;\n}\n\n/**\n * Who posted it.\n *\n * The handle sits beside the name in `compact` and beneath it everywhere else,\n * which is the difference between a timeline row and a card: a dense row cannot\n * afford a second line, and a card looks starved without one.\n */\nconst PostAuthor = forwardRef(\n (\n { className, name, handle, avatar, fallback, verified, timestamp, children, ...props },\n ref\n ) => {\n const { variant, size } = usePost();\n const { author, authorLine, name: nameSlot, handle: handleSlot, meta } = postVariants({\n variant,\n size,\n });\n const verifiedColor = useCSSVariable('--color-primary');\n const onVerified = useCSSVariable('--color-primary-foreground');\n\n const inline = variant === 'compact';\n const overlaid = variant === 'media';\n\n return (\n // Top-aligned, not centred. The name is the first line of the block, and\n // aligning to the top is what puts it — and therefore the menu opposite\n // it — on the same rule in every variant, whether the block below the\n // name is one line, two, or none.\n \n \n \n {/* `h-6` fixes the name's line box, so the block below it starts at\n the same offset whatever the type scale rounds the line height\n to — which is what the menu opposite is aligning against. */}\n \n \n {name}\n \n {verified ? (\n \n ) : null}\n {inline && handle ? (\n \n {handle}\n \n ) : null}\n {inline && timestamp !== undefined ? (\n <>\n \n ·\n \n {textChildren(timestamp, (text) => (\n \n {text}\n \n ))}\n \n ) : null}\n \n\n {!inline && (handle || timestamp !== undefined) ? (\n \n {[handle, typeof timestamp === 'string' ? timestamp : undefined]\n .filter(Boolean)\n .join(' · ')}\n \n ) : null}\n {!inline && timestamp !== undefined && typeof timestamp !== 'string'\n ? timestamp\n : null}\n {children}\n \n \n );\n }\n);\nPostAuthor.displayName = 'Post.Author';\n\n/** First letters of the first two words — enough to tell two people apart. */\nfunction initials(name: string): string {\n return name\n .split(/\\s+/)\n .slice(0, 2)\n .map((word) => word[0] ?? '')\n .join('')\n .toUpperCase();\n}\n\nexport interface PostActionProps extends ViewProps {\n className?: string;\n children?: ReactNode;\n}\n\n/** The header's trailing slot — an overflow menu, a follow button, a badge. */\nconst PostAction = forwardRef(\n ({ className, children, ...props }, ref) => {\n const { variant, size } = usePost();\n const { action } = postVariants({ variant, size });\n return (\n \n {children}\n \n );\n }\n);\nPostAction.displayName = 'Post.Action';\n\nexport interface PostCommunityProps extends Omit {\n className?: string;\n /** The group's name — \"r/reactnative\", \"#design\". */\n name: string;\n avatar?: ImageSourcePropType;\n /** How long ago, and anything else that belongs on the line. */\n meta?: ReactNode;\n children?: ReactNode;\n}\n\n/**\n * The group a post was made in, above its headline.\n *\n * Smaller and quieter than an author row, because in a ranked community the\n * post's subject is the headline — the group is where it came from, not who\n * said it.\n */\nconst PostCommunity = forwardRef(\n ({ className, name, avatar, meta, children, ...props }, ref) => {\n const { variant, size } = usePost();\n const {\n community,\n communityName,\n communityMeta,\n } = postVariants({ variant, size });\n\n return (\n \n \n \n {name}\n \n {meta !== undefined ? (\n <>\n ·\n {textChildren(meta, (text) => (\n {text}\n ))}\n \n ) : null}\n {children}\n \n );\n }\n);\nPostCommunity.displayName = 'Post.Community';\n\n/* -------------------------------------------------------------------------- */\n/* Body */\n/* -------------------------------------------------------------------------- */\n\nexport interface PostTitleProps extends ViewProps {\n className?: string;\n children?: ReactNode;\n}\n\n/** The headline of a post whose subject is a headline rather than a person. */\nconst PostTitle = forwardRef(\n ({ className, children, ...props }, ref) => {\n const { variant, size } = usePost();\n const { title } = postVariants({ variant, size });\n return (\n \n {textChildren(children, (text) => {text})}\n \n );\n }\n);\nPostTitle.displayName = 'Post.Title';\n\nexport interface PostBodyProps extends Omit {\n className?: string;\n /** How many lines before it is cut off. Unlimited by default. */\n numberOfLines?: number;\n /** Called with the tag, without its `#`. Makes hashtags pressable. */\n onTagPress?: (tag: string) => void;\n /** Called with the handle, without its `@`. */\n onMentionPress?: (handle: string) => void;\n children?: ReactNode;\n}\n\n/**\n * What was written.\n *\n * Hashtags and mentions inside a string child are picked out and coloured. They\n * are found in the text rather than declared, because a post body arrives as\n * one string from wherever it was typed — asking the caller to pre-split it\n * would mean every caller writing the same tokeniser.\n *\n * A non-string child is left entirely alone: it has already been laid out by\n * someone who knew more than we do.\n */\nconst PostBody = forwardRef(\n (\n { className, numberOfLines, onTagPress, onMentionPress, children, ...props },\n ref\n ) => {\n const { variant, size } = usePost();\n const { body } = postVariants({ variant, size });\n\n if (typeof children !== 'string') {\n return (\n \n {children}\n \n );\n }\n\n return (\n \n \n {tokenize(children).map((token, index) =>\n token.kind === 'text' ? (\n token.value\n ) : (\n onTagPress(token.value.slice(1)))\n : onMentionPress && (() => onMentionPress(token.value.slice(1)))\n }\n >\n {token.value}\n \n )\n )}\n \n \n );\n }\n);\nPostBody.displayName = 'Post.Body';\n\ninterface Token {\n kind: 'text' | 'tag' | 'mention';\n value: string;\n}\n\n/**\n * Splits a body into plain runs, hashtags and mentions.\n *\n * The tag pattern stops at whitespace and at punctuation that ends a sentence,\n * so \"#DebtSnowball.\" highlights the tag and leaves the full stop black — the\n * naive `\\S+` swallows it and the sentence loses its ending.\n */\nfunction tokenize(text: string): Token[] {\n const pattern = /[#@][\\w-]+/g;\n const tokens: Token[] = [];\n let last = 0;\n\n for (const match of text.matchAll(pattern)) {\n const start = match.index ?? 0;\n if (start > last) tokens.push({ kind: 'text', value: text.slice(last, start) });\n tokens.push({\n kind: match[0].startsWith('#') ? 'tag' : 'mention',\n value: match[0],\n });\n last = start + match[0].length;\n }\n if (last < text.length) tokens.push({ kind: 'text', value: text.slice(last) });\n return tokens;\n}\n\nexport interface PostMediaProps extends Omit {\n className?: string;\n source: ImageSourcePropType;\n /** Width over height. `16 / 10` by default — wide enough not to eat the feed. */\n aspectRatio?: number;\n /**\n * Darkens an edge of the image so type laid over it stays legible.\n *\n * A gradient rather than a panel: a flat rectangle over the top of a\n * photograph has an edge of its own, and that edge reads as a bar covering\n * the picture rather than as shading. `media` posts default to `top`, where\n * the author sits; everything else to `none`.\n */\n scrim?: 'none' | 'top' | 'bottom' | 'both';\n /** Laid over the image: an expand affordance, a duration, a gallery count. */\n overlay?: ReactNode;\n /** Described for a screen reader. An image with nothing to say is decorative. */\n alt?: string;\n onPress?: PressableProps['onPress'];\n children?: ReactNode;\n}\n\n/**\n * Black, fading to nothing.\n *\n * Three stops rather than two: a straight linear ramp from 55% to nothing has a\n * visible shoulder where it meets the picture, and the eye reads that shoulder\n * as an edge — which is the whole thing a scrim exists to avoid.\n */\nconst SCRIM_COLOURS: readonly [string, string, string] = [\n 'rgba(0,0,0,0.55)',\n 'rgba(0,0,0,0.28)',\n 'rgba(0,0,0,0)',\n];\n\n/**\n * The picture.\n *\n * A fixed aspect ratio rather than the image's own, because a feed of cards\n * whose heights are decided by whatever was uploaded scrolls like a broken\n * staircase. The image covers the box and is cropped by it.\n */\nconst PostMedia = forwardRef(\n (\n {\n className,\n source,\n aspectRatio = 16 / 10,\n scrim,\n overlay,\n alt,\n onPress,\n children,\n ...props\n },\n ref\n ) => {\n const { variant, size } = usePost();\n const { media } = postVariants({ variant, size });\n // The media variant lays the author over the image, so it needs shading by\n // default — the caller should not have to remember what makes their own\n // layout readable.\n const shade = scrim ?? (variant === 'media' ? 'top' : 'none');\n\n const picture = (\n <>\n \n {shade === 'top' || shade === 'both' ? (\n \n ) : null}\n {shade === 'bottom' || shade === 'both' ? (\n \n ) : null}\n {overlay ? (\n \n {overlay}\n \n ) : null}\n {children}\n \n );\n\n if (!onPress) {\n return (\n \n {picture}\n \n );\n }\n\n return (\n \n {picture}\n \n );\n }\n);\nPostMedia.displayName = 'Post.Media';\n\n/* -------------------------------------------------------------------------- */\n/* Footer */\n/* -------------------------------------------------------------------------- */\n\nexport interface PostFooterProps extends ViewProps {\n className?: string;\n children?: ReactNode;\n}\n\n/** The row of counts and controls under the post. */\nconst PostFooter = forwardRef(\n ({ className, children, ...props }, ref) => {\n const { variant, size } = usePost();\n const { footer } = postVariants({ variant, size });\n return (\n \n {children}\n \n );\n }\n);\nPostFooter.displayName = 'Post.Footer';\n\n/** What lighting up means. A like is red, a save is the accent, a repost green. */\nconst statTone: Record<'default' | 'like' | 'save' | 'repost', string> = {\n default: '--color-primary',\n like: '--color-destructive',\n save: '--color-primary',\n repost: '--color-success',\n};\n\nexport interface PostStatProps extends Omit {\n className?: string;\n /** The icon component itself, not an element — it is re-rendered on toggle. */\n icon?: ComponentType;\n /** The number, or a word where a number would be meaningless (\"Save\"). */\n value?: ReactNode;\n /** Lit, and filled. */\n active?: boolean;\n /** Which colour \"lit\" is. */\n tone?: 'default' | 'like' | 'save' | 'repost';\n /** Pushes this stat and everything after it to the trailing edge. */\n align?: 'start' | 'end';\n children?: ReactNode;\n}\n\n/**\n * One count in the footer, and the control that changes it.\n *\n * Pressing it pops the icon past its own size and settles back, fills it, and\n * animates the value: the old number leaves upwards and the new one arrives\n * from below when the count goes up, and the other way when it comes down.\n * Repainting the digits in place is indistinguishable from a re-render, which\n * is the doubt this exists to remove.\n */\nconst PostStat = forwardRef(\n (\n {\n className,\n icon: Icon,\n value,\n active = false,\n tone = 'default',\n align = 'start',\n onPress,\n children,\n ...props\n },\n ref\n ) => {\n const { variant, size } = usePost();\n const { stat, statLabel } = postVariants({ variant, size });\n const litColor = useCSSVariable(statTone[tone]);\n const restColor = useCSSVariable('--color-muted-foreground');\n const reducedMotion = useReducedMotion();\n\n const pop = useSharedValue(1);\n const first = useRef(true);\n\n useEffect(() => {\n if (first.current) {\n first.current = false;\n return;\n }\n if (reducedMotion) return;\n // Past 1 and back. A toggle that only grows reads as a state that got\n // bigger; one that overshoots reads as a press.\n pop.value = withSequence(\n withTiming(active ? 1.25 : 0.85, { duration: 90 }),\n withSpring(1, POP)\n );\n }, [active, reducedMotion, pop]);\n\n const iconStyle = useAnimatedStyle(() => ({ transform: [{ scale: pop.value }] }));\n\n const colour = active\n ? typeof litColor === 'string'\n ? litColor\n : '#e11d48'\n : typeof restColor === 'string'\n ? restColor\n : '#737373';\n\n const inner = (\n <>\n {Icon ? (\n \n \n \n ) : null}\n {value !== undefined ? (\n \n ) : null}\n {children}\n \n );\n\n const classes = stat({ className: cn(align === 'end' && 'ms-auto', className) });\n\n if (!onPress) {\n return (\n \n {inner}\n \n );\n }\n\n return (\n {\n selectionTick();\n onPress(event);\n }}\n className={classes}\n {...props}\n >\n {inner}\n \n );\n }\n);\nPostStat.displayName = 'Post.Stat';\n\n/**\n * A number that moves when it changes.\n *\n * The new value arrives from the direction the count travelled — rising into\n * place when it went up, dropping in when it came down — while the old one\n * fades. `215 → 216` then reads as a count going up rather than as text being\n * replaced, which is the doubt worth removing: repainting digits in place is\n * indistinguishable from a re-render.\n *\n * Only the entering half carries the direction. The element leaving was\n * rendered before anyone knew which way the number would go, so asking it to\n * animate knowingly would mean knowing the future.\n *\n * A value that is not a number — \"Save\" — cross-fades, since there is no\n * direction for a word to go.\n */\nfunction PostCount({\n value,\n className,\n color,\n}: {\n value: ReactNode;\n className?: string;\n color?: string;\n}) {\n const reducedMotion = useReducedMotion();\n // Read during the render the value changes on, not after it: an effect would\n // settle the direction one frame after the animation had already started.\n const before = numeric(usePrevious(value));\n const after = numeric(value);\n const direction =\n before === null || after === null || before === after ? 0 : after > before ? 1 : -1;\n\n const label = (\n \n {value}\n \n );\n\n if (reducedMotion) return label;\n\n return (\n \n {label}\n \n );\n}\n\n/** The value as a number, where it is one. `\"5,874\"` counts; `\"Save\"` does not. */\nfunction numeric(value: ReactNode): number | null {\n if (typeof value === 'number') return value;\n if (typeof value !== 'string') return null;\n const parsed = Number(value.replace(/[,\\s]/g, ''));\n return Number.isFinite(parsed) ? parsed : null;\n}\n\n/* -------------------------------------------------------------------------- */\n/* Votes */\n/* -------------------------------------------------------------------------- */\n\nexport type PostVote = 'up' | 'down' | null;\n\nexport interface PostVotesProps extends Omit {\n className?: string;\n /** The score as it stands, with the reader's own vote already in it. */\n score: number | string;\n /** Which way this reader voted, if either. */\n vote?: PostVote;\n /**\n * Called with the new vote. Pressing the arrow already cast clears it, so\n * `null` arrives as often as the other two — a vote you cannot take back is\n * a vote people hesitate over.\n */\n onVote?: (vote: PostVote) => void;\n /** `vertical` stacks the arrows beside a thumbnail, the way a ranked list reads. */\n orientation?: 'horizontal' | 'vertical';\n disabled?: boolean;\n}\n\n/**\n * The score, and the two arrows that move it.\n *\n * Both arrows fill and take a colour when cast, and the whole pill tints with\n * them — the arrow alone is 16pt of a card, which is not enough to notice from\n * arm's length. The score rolls in the direction of the change.\n *\n * Pressing the arrow already cast clears the vote. This is not a nicety: a\n * score is a thing people change their minds about, and a vote that cannot be\n * withdrawn is one they think twice before casting.\n */\nconst PostVotes = forwardRef(\n (\n {\n className,\n score,\n vote = null,\n onVote,\n orientation = 'horizontal',\n disabled = false,\n ...props\n },\n ref\n ) => {\n const { variant, size } = usePost();\n const { votes, voteButton, voteScore } = postVariants({ variant, size });\n const upColor = useCSSVariable('--color-success');\n const downColor = useCSSVariable('--color-destructive');\n const restColor = useCSSVariable('--color-muted-foreground');\n\n const up = typeof upColor === 'string' ? upColor : '#16a34a';\n const down = typeof downColor === 'string' ? downColor : '#dc2626';\n const rest = typeof restColor === 'string' ? restColor : '#737373';\n\n const cast = (next: 'up' | 'down') => {\n selectionTick();\n onVote?.(vote === next ? null : next);\n };\n\n const scoreColour = vote === 'up' ? up : vote === 'down' ? down : undefined;\n\n return (\n \n cast('up')}\n />\n \n cast('down')}\n />\n \n );\n }\n);\nPostVotes.displayName = 'Post.Votes';\n\n/**\n * One arrow.\n *\n * It travels along its own axis as it is cast — up for the up arrow, down for\n * the down one — and springs back. The direction of the throw is the whole\n * point: an arrow that merely scales says *something happened*, one that moves\n * the way it points says *which way*.\n */\nfunction VoteArrow({\n direction,\n active,\n color,\n disabled,\n className,\n onPress,\n}: {\n direction: 'up' | 'down';\n active: boolean;\n color: string;\n disabled: boolean;\n className: string;\n onPress: () => void;\n}) {\n const reducedMotion = useReducedMotion();\n const shift = useSharedValue(0);\n const scale = useSharedValue(1);\n const first = useRef(true);\n\n useEffect(() => {\n if (first.current) {\n first.current = false;\n return;\n }\n if (reducedMotion) return;\n const travel = direction === 'up' ? -5 : 5;\n shift.value = withSequence(\n withTiming(active ? travel : -travel * 0.4, { duration: 100 }),\n withSpring(0, POP)\n );\n scale.value = withSequence(\n withTiming(active ? 1.2 : 0.9, { duration: 100 }),\n withSpring(1, POP)\n );\n }, [active, direction, reducedMotion, shift, scale]);\n\n const style = useAnimatedStyle(() => ({\n transform: [{ translateY: shift.value }, { scale: scale.value }],\n }));\n\n const Arrow = direction === 'up' ? ArrowUpIcon : ArrowDownIcon;\n\n return (\n \n \n \n \n \n );\n}\n\nexport const Post = Object.assign(PostRoot, {\n Header: PostHeader,\n Author: PostAuthor,\n Action: PostAction,\n Community: PostCommunity,\n Title: PostTitle,\n Body: PostBody,\n Media: PostMedia,\n Footer: PostFooter,\n Stat: PostStat,\n Votes: PostVotes,\n});\n" } ] } diff --git a/apps/docs/public/r/progress-button.json b/apps/docs/public/r/progress-button.json index 5c757fba3..6da6576e2 100644 --- a/apps/docs/public/r/progress-button.json +++ b/apps/docs/public/r/progress-button.json @@ -5,6 +5,7 @@ "registryDependencies": [ "haptics", "icons", + "refs", "text" ], "dependencies": [ @@ -17,7 +18,7 @@ { "path": "ui/progress-button.tsx", "type": "registry:ui", - "content": "/**\n * ProgressButton — press and hold to confirm, with the wait drawn on the button.\n *\n * ```tsx\n * \n * Hold to erase\n * \n * ```\n *\n * For the action a confirmation dialog exists to slow down. A dialog asks the\n * question somewhere else and takes the answer as a tap — which is two taps,\n * and two taps in a row is a rhythm a hand falls into. A hold cannot be\n * completed by accident and cannot be completed by habit: it has to be\n * sustained, and the fill says for how much longer.\n *\n * ## The fill is the promise\n *\n * Nothing fires until the fill reaches the end. There is no tolerance near the\n * top, because a tolerance means the button sometimes commits after the reader\n * has deliberately let go — which is the one failure a confirmation control\n * cannot have. Released early, the fill drains back, in proportion to how far\n * it got.\n *\n * ## How it is drawn\n *\n * The fill is a clipped copy of the button in inverted colours, growing from\n * the leading edge. That is what keeps the label legible across the boundary:\n * a single label under a translucent wash goes muddy in the middle of the\n * wipe, exactly where the eye is. Two labels, each at full contrast on its own\n * ground, never do.\n *\n * The clip is a view under `overflow: 'hidden'`, and its width is animated.\n * Animating a width is normally a layout pass per frame; this view is\n * absolutely positioned over the button and its copy of the label is pinned to\n * the button's own width, so nothing it contains reflows as it grows.\n *\n * ## Reduced motion\n *\n * The wipe is replaced by a stepped fill that advances in fifths. The hold\n * still has to be legible — a control that asks you to wait and shows nothing\n * is a broken button — so this is a coarser indicator, not the absence of one.\n */\nimport {\n Children,\n createContext,\n forwardRef,\n isValidElement,\n useCallback,\n useContext,\n useEffect,\n useMemo,\n useRef,\n useState,\n type ReactNode,\n} from 'react';\nimport {\n Pressable,\n View,\n type LayoutChangeEvent,\n type PressableProps,\n type ViewProps,\n} from 'react-native';\nimport Animated, {\n Easing,\n cancelAnimation,\n runOnJS,\n runOnUI,\n useAnimatedReaction,\n useAnimatedStyle,\n useReducedMotion,\n useSharedValue,\n withSpring,\n withTiming,\n type SharedValue,\n} from 'react-native-reanimated';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport { useCSSVariable } from 'uniwind';\nimport { CheckIcon, IconColorProvider } from '@/components/ui/icons';\nimport { Text, textChildren } from '@/components/ui/text';\nimport { impactKnock, selectionTick } from '@/lib/haptics';\nimport {\n DEFAULT_AUTO_RESET_DELAY,\n fillDuration,\n releaseDuration,\n resolveHoldDuration,\n} from '@/components/ui/progress-button-hold';\n\n/** How many steps the reduced-motion fill advances in. */\nconst REDUCED_STEPS = 5;\n\n/**\n * The arrival of the completed drawing.\n *\n * A spring rather than a timing, and slightly overshooting: the fill has just\n * spent two seconds moving at a constant rate, and something that lands with a\n * little weight is what tells the reader the waiting part is over.\n */\nconst DONE_SPRING = { damping: 14, stiffness: 220, mass: 0.6 } as const;\n\n/** How long the completed drawing takes to leave again on a reset. */\nconst DONE_EXIT = 140;\n\n/**\n * Which token the tick is drawn in, per variant.\n *\n * It sits on the finished fill, so it takes that fill's own foreground rather\n * than the button's — the same pairing `fillLabel` uses, which is what keeps\n * the contrast right in both themes without a hardcoded colour.\n */\nconst DONE_TINT = {\n primary: '--color-primary-foreground',\n secondary: '--color-background',\n destructive: '--color-destructive-solid-foreground',\n success: '--color-success-solid-foreground',\n} as const;\n\nconst progressButtonVariants = tv({\n slots: {\n /*\n * Every variant rests on the same secondary surface, and `variant` decides\n * only what colour comes across it.\n *\n * Drawn as outlines they were four different buttons before anything had\n * happened, and the one thing they all do — wait to be held — was the\n * thing the drawing did not say. A solid ground says it: the button is\n * unfilled, and the fill is what the hold produces.\n *\n * The variant is still legible at rest, because the label carries its\n * colour. That is the half worth keeping — a destructive hold should not\n * look like an ordinary one before it is touched — and it is also the half\n * that survives the wipe, since the fill covers the ground the label was\n * standing on and the second copy takes over.\n */\n root: 'relative overflow-hidden border border-transparent bg-secondary',\n /*\n * The row inside the button. It is separate from `root` because the fill\n * has to sit over the whole button including its padding — a wipe that\n * stops at the text's own box leaves an unfilled margin down each side and\n * reads as a progress bar someone put inside a button.\n */\n content: 'flex-row items-center justify-center gap-2',\n label: 'min-w-0 shrink text-center font-medium',\n /** The inverted copy, drawn on the filled ground. */\n fill: '',\n fillLabel: 'min-w-0 shrink text-center font-medium',\n },\n variants: {\n variant: {\n primary: {\n label: 'text-primary',\n fill: 'bg-primary',\n fillLabel: 'text-primary-foreground',\n },\n secondary: {\n label: 'text-secondary-foreground',\n fill: 'bg-foreground',\n fillLabel: 'text-background',\n },\n destructive: {\n label: 'text-destructive',\n fill: 'bg-destructive',\n fillLabel: 'text-destructive-solid-foreground',\n },\n success: {\n label: 'text-success',\n fill: 'bg-success',\n fillLabel: 'text-success-solid-foreground',\n },\n },\n size: {\n // Matched to Button's boxes, and `min-h-*` for the same reason: the\n // label's glyphs grow with the system text size and the box has to grow\n // with them. The side padding is `shape`'s to decide, below.\n sm: {\n root: 'min-h-9',\n content: 'min-h-9 gap-1.5 py-2',\n label: 'text-[14px]',\n fillLabel: 'text-[14px]',\n },\n md: {\n root: 'min-h-11',\n content: 'min-h-11 py-2.5',\n label: 'text-[16px]',\n fillLabel: 'text-[16px]',\n },\n lg: {\n root: 'min-h-12',\n content: 'min-h-12 py-2.5',\n label: 'text-[18px]',\n fillLabel: 'text-[18px]',\n },\n },\n /*\n * The corner, and the side padding that follows from it.\n *\n * The fill is clipped by the button's radius, so the shape of the button is\n * also the shape of the wipe's leading edge as it comes out of the corner.\n * A pill sends that edge out as a curve, which reads as the button filling\n * up; a small radius sends it out square, which reads as a rectangle\n * sliding out from under the button. That is why `pill` is the default and\n * stays it.\n *\n * `rounded` is for a progress button standing in a row of ordinary ones —\n * a form's footer, a toolbar, a card's actions — where a lone pill among\n * `rounded-lg` buttons reads as a different kind of control rather than as\n * the one that has to be held. It is `Button`'s box exactly: same radius,\n * same padding, same minimum width, and the heights already agreed.\n */\n shape: {\n // The radius is only on the root. It has `overflow-hidden`, so it is what\n // clips the fill — and the fill's own leading edge has to stay straight:\n // rounding it would round the wipe's front as well as the button's\n // corner, which is a bar with a domed end travelling across the button.\n pill: { root: 'rounded-full' },\n rounded: { root: 'rounded-lg' },\n },\n fullWidth: {\n true: { root: 'w-full' },\n },\n disabled: {\n true: { root: 'opacity-[0.64]' },\n },\n },\n compoundVariants: [\n // A pill is wider than the equivalent Button at every size, because the\n // corner is a half-circle rather than a small radius: the curve eats into\n // the side padding, and at Button's values the first and last glyphs sit\n // against it. `rounded` has Button's corner, so it takes Button's padding.\n { shape: 'pill', size: 'sm', class: { content: 'px-3.5' } },\n { shape: 'pill', size: 'md', class: { content: 'px-5' } },\n { shape: 'pill', size: 'lg', class: { content: 'px-7' } },\n { shape: 'rounded', size: 'sm', class: { root: 'min-w-9', content: 'px-2.5' } },\n { shape: 'rounded', size: 'md', class: { root: 'min-w-11', content: 'px-4' } },\n { shape: 'rounded', size: 'lg', class: { content: 'px-6' } },\n ],\n defaultVariants: {\n variant: 'primary',\n size: 'md',\n shape: 'pill',\n },\n});\n\ntype ProgressButtonVariantProps = VariantProps;\n\n/** How a progress button looks. */\nexport type ProgressButtonVariant = NonNullable;\n/** How big a progress button is. */\nexport type ProgressButtonSize = NonNullable;\n/** What corner a progress button has. */\nexport type ProgressButtonShape = NonNullable;\n\ninterface ProgressButtonContextValue {\n /** `0` to `1` across the hold. */\n progress: SharedValue;\n /**\n * `0` to `1` across the arrival of the completed drawing. Separate from\n * `progress` because the fill has finished by the time this starts, and the\n * two would otherwise have to share one clock running at two speeds.\n */\n done: SharedValue;\n /** Width of the button, so the fill's copy of the label can match it. */\n width: number;\n completed: boolean;\n variant: ProgressButtonVariant;\n size: ProgressButtonSize;\n slots: ReturnType;\n}\n\nconst ProgressButtonContext = createContext(null);\n\nfunction useProgressButtonContext(component: string): ProgressButtonContextValue {\n const context = useContext(ProgressButtonContext);\n if (!context) throw new Error(`${component} must be used within a `);\n return context;\n}\n\n/** How far through the hold this button is, for something rendered inside it. */\nexport function useProgressButton() {\n const { progress, completed } = useProgressButtonContext('useProgressButton');\n return { progress, completed };\n}\n\nexport interface ProgressButtonProps\n extends Omit,\n Omit {\n className?: string;\n /**\n * Milliseconds the button has to be held. Defaults to `2000`, and is floored\n * at `200` — a hold that completes on touch-down is a button with extra steps.\n */\n holdDuration?: number;\n /** Fires once the hold has been sustained to the end. */\n onComplete?: () => void;\n /** Fires whenever the completed state changes, including on a reset. */\n onCompletedChange?: (completed: boolean) => void;\n /** Controlled completion. Leave unset to let the button own it. */\n completed?: boolean;\n /** Return to the unfilled state after `autoResetDelay`. */\n autoReset?: boolean;\n /** Milliseconds to stay completed before resetting. Defaults to `1000`. */\n autoResetDelay?: number;\n /**\n * Dim the button and refuse the hold outright. The fill never starts, so\n * there is no half-finished state to explain.\n */\n disabled?: boolean;\n /**\n * A tick as the hold takes, and a knock when it completes. Off by default:\n * whether an action is worth feeling is the caller's call, not the control's.\n */\n haptics?: boolean;\n /**\n * The corner. `pill` by default — the fill is clipped by it, so a half-circle\n * sends the wipe's leading edge out as a curve and the button reads as\n * filling up.\n *\n * `rounded` gives it [Button](/docs/components/button)'s box exactly: the\n * same radius, side padding and minimum width, at heights that already\n * matched. Use it where the hold stands in a row of ordinary buttons — a\n * form's footer, a toolbar, a card's actions — and a lone pill would read as\n * a different kind of control rather than as the one that has to be held.\n */\n shape?: ProgressButtonShape;\n children?: ReactNode;\n}\n\nconst ProgressButtonRoot = forwardRef(function ProgressButtonRoot(\n {\n className,\n variant = 'primary',\n size = 'md',\n shape = 'pill',\n fullWidth,\n holdDuration,\n onComplete,\n onCompletedChange,\n completed: completedProp,\n autoReset = false,\n autoResetDelay = DEFAULT_AUTO_RESET_DELAY,\n disabled = false,\n haptics = false,\n accessibilityState,\n children,\n ...props\n },\n ref\n) {\n const slots = progressButtonVariants({ variant, size, shape, fullWidth, disabled });\n const duration = resolveHoldDuration(holdDuration);\n const reducedMotion = useReducedMotion();\n\n const progress = useSharedValue(0);\n const done = useSharedValue(0);\n const [width, setWidth] = useState(0);\n const [internalCompleted, setInternalCompleted] = useState(false);\n const resetTimer = useRef | null>(null);\n\n const isControlled = completedProp !== undefined;\n const completed = isControlled ? completedProp : internalCompleted;\n\n /*\n * The reaction below watches a shared value, and a shared value can reach the\n * end for reasons other than a hold — a controlled button being told it is\n * complete fills instantly. Read from a ref rather than from `completed` so\n * the guard sees the current answer without the reaction being rebuilt.\n */\n const completedRef = useRef(completed);\n completedRef.current = completed;\n\n const finish = useCallback(() => {\n // Already complete: the fill was set from outside, and firing the action\n // again would run it a second time for something the reader did not do.\n if (completedRef.current) return;\n if (!isControlled) setInternalCompleted(true);\n onCompletedChange?.(true);\n onComplete?.();\n if (haptics) impactKnock();\n }, [isControlled, onComplete, onCompletedChange, haptics]);\n\n /*\n * Completion is read off the animation rather than timed alongside it.\n *\n * A `setTimeout` for `holdDuration` and a fill for `holdDuration` are two\n * clocks that agree only while the app is idle: the moment the JS thread is\n * busy the button fires before the fill arrives, or the fill sits full while\n * nothing happens. The reaction fires on the frame the value actually\n * reaches the end, which is the frame the reader saw it get there.\n */\n useAnimatedReaction(\n () => progress.value >= 1,\n (full, was) => {\n if (full && !was) runOnJS(finish)();\n }\n );\n\n /*\n * Both directions are started on the UI thread, and that is not a detail.\n *\n * A shared value animated on the UI thread does not report back to\n * JavaScript, so `progress.value` read from a press handler is the value\n * from before the hold began — zero. The release computed from it either did\n * nothing or, worse, `cancelAnimation` wrote that stale zero back and the\n * fill vanished on touch-up instead of travelling home.\n *\n * `runOnUI` puts the read where the value actually lives.\n */\n const begin = useCallback(() => {\n if (disabled || completed) return;\n if (haptics) selectionTick();\n runOnUI(() => {\n 'worklet';\n cancelAnimation(progress);\n const from = progress.value;\n if (from >= 1) return;\n progress.value = withTiming(1, {\n // The distance still ahead, at the fill's own rate — so a press that\n // catches the fill on its way back carries on from there rather than\n // restarting the clock.\n duration: fillDuration(from, duration),\n /*\n * Linear. A fill is constant motion, and an eased one misreports the\n * wait: it races the first half and crawls the second, or the reverse.\n *\n * Under reduced motion it steps instead, and is still a real\n * indicator — what that setting is about is continuous movement, not\n * the button saying how much longer to wait. Take that away and the\n * control asks for a hold with nothing on screen to say why.\n */\n easing: reducedMotion ? Easing.steps(REDUCED_STEPS, true) : Easing.linear,\n });\n })();\n }, [disabled, completed, haptics, progress, reducedMotion, duration]);\n\n /*\n * The fill, played backwards.\n *\n * Same rate, same easing, same stepping under reduced motion — only the\n * direction differs. Let go at nine tenths of a two-second hold and the fill\n * takes 1.8 seconds to travel home, which is the 1.8 seconds it took to get\n * there. A fill that vanishes has been deleted; a fill that travels back has\n * been let go, and telling those apart is the whole reason the wait is drawn\n * on the button.\n *\n * Every path that empties the fill goes through here — a hold let go, an\n * `autoReset` landing, a controlled button told it is no longer complete.\n * They were three separate assignments and two of them snapped.\n */\n const rewind = useCallback(() => {\n runOnUI(() => {\n 'worklet';\n cancelAnimation(progress);\n const from = progress.value;\n if (from <= 0) {\n progress.value = 0;\n return;\n }\n progress.value = withTiming(0, {\n duration: releaseDuration(from, duration),\n easing: reducedMotion ? Easing.steps(REDUCED_STEPS, true) : Easing.linear,\n });\n })();\n }, [progress, duration, reducedMotion]);\n\n const abandon = useCallback(() => {\n /*\n * A completed hold has nothing to abandon. Without this the fill drains on\n * the release that follows a successful hold — the button empties, looks\n * untouched, and then refuses every press after it, because `begin` bails\n * on a completed button. That combination is a control that has silently\n * stopped working.\n */\n if (completedRef.current) return;\n rewind();\n }, [rewind]);\n\n const reset = useCallback(() => {\n rewind();\n if (!isControlled) setInternalCompleted(false);\n onCompletedChange?.(false);\n }, [rewind, isControlled, onCompletedChange]);\n\n useEffect(() => {\n if (!completed || !autoReset) return;\n resetTimer.current = setTimeout(reset, autoResetDelay);\n return () => {\n if (resetTimer.current) clearTimeout(resetTimer.current);\n resetTimer.current = null;\n };\n }, [completed, autoReset, autoResetDelay, reset]);\n\n /*\n * The fill follows the completed state, in both directions.\n *\n * A controlled button told it is complete fills without a hold, and one told\n * it is no longer complete empties — otherwise the next hold would start from\n * a bar that is already full.\n */\n useEffect(() => {\n if (completed) {\n cancelAnimation(progress);\n progress.value = 1;\n return;\n }\n rewind();\n }, [completed, progress, rewind]);\n\n /*\n * And the completed drawing follows it too. Under reduced motion it is a\n * swap: what that setting is about is movement, and a tick that grows is\n * movement with nothing to report.\n */\n useEffect(() => {\n if (reducedMotion) {\n done.value = completed ? 1 : 0;\n return;\n }\n done.value = completed ? withSpring(1, DONE_SPRING) : withTiming(0, { duration: DONE_EXIT });\n }, [completed, done, reducedMotion]);\n\n useEffect(\n () => () => {\n cancelAnimation(progress);\n cancelAnimation(done);\n if (resetTimer.current) clearTimeout(resetTimer.current);\n },\n [progress, done]\n );\n\n const onLayout = (event: LayoutChangeEvent) => {\n const next = Math.round(event.nativeEvent.layout.width);\n setWidth((current) => (current === next ? current : next));\n props.onLayout?.(event);\n };\n\n /*\n * `slots` is rebuilt every render, so it is deliberately not a dependency —\n * including it would make this memo re-run every time and hand every consumer\n * a new object for no change. The variant and size it is derived from are the\n * dependencies instead.\n */\n const context = useMemo(\n () => ({ progress, done, width, completed, variant, size, slots }),\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [progress, done, width, completed, variant, size]\n );\n\n /*\n * The completed drawing is part of the button rather than something every\n * call site has to remember, so one is added unless the caller wrote their\n * own. A hold that lands and shows nothing is the reader wondering whether it\n * worked, which is the thing this control exists to remove.\n */\n const written = Children.toArray(children);\n const hasDone = written.some(\n (child) => isValidElement(child) && child.type === ProgressButtonDone\n );\n const body = (\n <>\n {written.length > 0 ? written : Hold to confirm}\n {hasDone ? null : }\n \n );\n\n return (\n \n \n {body}\n \n \n );\n});\n\nexport interface ProgressButtonLabelProps extends ViewProps {\n className?: string;\n children?: ReactNode;\n}\n\n/**\n * What the button says, drawn twice.\n *\n * The second copy is the one inside the fill, in the inverted colour, pinned to\n * the button's own width and clipped to however far the hold has got. Both are\n * the same text at the same position, so the boundary between them falls in the\n * middle of a glyph rather than between two differently laid-out lines.\n */\nfunction ProgressButtonLabel({ className, children, ...props }: ProgressButtonLabelProps) {\n const { progress, width, slots } = useProgressButtonContext('ProgressButton.Label');\n\n const fillStyle = useAnimatedStyle(() => ({ width: width * progress.value }));\n\n return (\n \n {textChildren(children, (text) => (\n {text}\n ))}\n\n {/* The wipe. `pointerEvents` off so it never takes the press it is\n drawn on top of, and `width` fixed to the button so the copy inside\n it is laid out exactly where the original is rather than reflowing\n into whatever the clip currently allows. */}\n \n \n {textChildren(children, (text) => (\n {text}\n ))}\n \n \n \n );\n}\nProgressButtonLabel.displayName = 'ProgressButton.Label';\n\nexport interface ProgressButtonDoneProps extends ViewProps {\n className?: string;\n /**\n * What the button shows once the hold has landed. A tick on its own by\n * default; children replace it, so a word beside one is\n * `Paid`.\n */\n children?: ReactNode;\n}\n\n/**\n * The drawing the button lands on.\n *\n * It sits over the finished fill rather than beside the label, so the button\n * does not change width at the moment it completes — a control that resizes as\n * it succeeds moves everything under it, and the reader's eye is on the button.\n *\n * One is added for you unless you write your own, because a hold that lands and\n * shows nothing leaves the reader checking whether it worked.\n */\nfunction ProgressButtonDone({ className, children, ...props }: ProgressButtonDoneProps) {\n const { done, completed, variant, slots } = useProgressButtonContext('ProgressButton.Done');\n const tint = useCSSVariable(DONE_TINT[variant]);\n\n /*\n * It carries the fill's own colour and covers the button edge to edge.\n *\n * The alternative — fading the label out from under it — takes the fill with\n * it, because the fill is drawn inside the label so that the two copies of\n * the text line up. The button would empty at the exact moment it succeeded,\n * and the tick, drawn in the colour that reads against a full fill, would be\n * left standing on nothing.\n */\n const style = useAnimatedStyle(() => ({ opacity: done.value }));\n // The tick arrives from slightly under full size. The ground it lands on\n // does not: a background box that grows reads as the button resizing.\n const markStyle = useAnimatedStyle(() => ({ transform: [{ scale: 0.7 + done.value * 0.3 }] }));\n\n return (\n \n \n \n {children ?? }\n \n \n \n );\n}\nProgressButtonDone.displayName = 'ProgressButton.Done';\n\nProgressButtonRoot.displayName = 'ProgressButton';\n\nexport const ProgressButton = Object.assign(ProgressButtonRoot, {\n Label: ProgressButtonLabel,\n Done: ProgressButtonDone,\n});\n\nexport {\n DEFAULT_AUTO_RESET_DELAY,\n DEFAULT_HOLD_DURATION,\n DEFAULT_RELEASE_DURATION,\n fillDuration,\n isComplete,\n releaseDuration,\n resolveHoldDuration,\n} from '@/components/ui/progress-button-hold';\n" + "content": "/**\n * ProgressButton — press and hold to confirm, with the wait drawn on the button.\n *\n * ```tsx\n * \n * Hold to erase\n * \n * ```\n *\n * For the action a confirmation dialog exists to slow down. A dialog asks the\n * question somewhere else and takes the answer as a tap — which is two taps,\n * and two taps in a row is a rhythm a hand falls into. A hold cannot be\n * completed by accident and cannot be completed by habit: it has to be\n * sustained, and the fill says for how much longer.\n *\n * ## The fill is the promise\n *\n * Nothing fires until the fill reaches the end. There is no tolerance near the\n * top, because a tolerance means the button sometimes commits after the reader\n * has deliberately let go — which is the one failure a confirmation control\n * cannot have. Released early, the fill drains back, in proportion to how far\n * it got.\n *\n * ## How it is drawn\n *\n * The fill is a clipped copy of the button in inverted colours, growing from\n * the leading edge. That is what keeps the label legible across the boundary:\n * a single label under a translucent wash goes muddy in the middle of the\n * wipe, exactly where the eye is. Two labels, each at full contrast on its own\n * ground, never do.\n *\n * The clip is a view under `overflow: 'hidden'`, and its width is animated.\n * Animating a width is normally a layout pass per frame; this view is\n * absolutely positioned over the button and its copy of the label is pinned to\n * the button's own width, so nothing it contains reflows as it grows.\n *\n * ## Reduced motion\n *\n * The wipe is replaced by a stepped fill that advances in fifths. The hold\n * still has to be legible — a control that asks you to wait and shows nothing\n * is a broken button — so this is a coarser indicator, not the absence of one.\n */\nimport {\n Children,\n createContext,\n forwardRef,\n isValidElement,\n useCallback,\n useContext,\n useEffect,\n useMemo,\n useRef,\n useState,\n type ReactNode,\n} from 'react';\nimport {\n Pressable,\n View,\n type LayoutChangeEvent,\n type PressableProps,\n type ViewProps,\n} from 'react-native';\nimport Animated, {\n Easing,\n cancelAnimation,\n runOnJS,\n runOnUI,\n useAnimatedReaction,\n useAnimatedStyle,\n useReducedMotion,\n useSharedValue,\n withSpring,\n withTiming,\n type SharedValue,\n} from 'react-native-reanimated';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport { useCSSVariable } from 'uniwind';\nimport { CheckIcon, IconColorProvider } from '@/components/ui/icons';\nimport { Text, textChildren } from '@/components/ui/text';\nimport { impactKnock, selectionTick } from '@/lib/haptics';\nimport {\n DEFAULT_AUTO_RESET_DELAY,\n fillDuration,\n releaseDuration,\n resolveHoldDuration,\n} from '@/components/ui/progress-button-hold';\nimport type { ViewRef } from '@/components/ui/refs';\n\n/** How many steps the reduced-motion fill advances in. */\nconst REDUCED_STEPS = 5;\n\n/**\n * The arrival of the completed drawing.\n *\n * A spring rather than a timing, and slightly overshooting: the fill has just\n * spent two seconds moving at a constant rate, and something that lands with a\n * little weight is what tells the reader the waiting part is over.\n */\nconst DONE_SPRING = { damping: 14, stiffness: 220, mass: 0.6 } as const;\n\n/** How long the completed drawing takes to leave again on a reset. */\nconst DONE_EXIT = 140;\n\n/**\n * Which token the tick is drawn in, per variant.\n *\n * It sits on the finished fill, so it takes that fill's own foreground rather\n * than the button's — the same pairing `fillLabel` uses, which is what keeps\n * the contrast right in both themes without a hardcoded colour.\n */\nconst DONE_TINT = {\n primary: '--color-primary-foreground',\n secondary: '--color-background',\n destructive: '--color-destructive-solid-foreground',\n success: '--color-success-solid-foreground',\n} as const;\n\nconst progressButtonVariants = tv({\n slots: {\n /*\n * Every variant rests on the same secondary surface, and `variant` decides\n * only what colour comes across it.\n *\n * Drawn as outlines they were four different buttons before anything had\n * happened, and the one thing they all do — wait to be held — was the\n * thing the drawing did not say. A solid ground says it: the button is\n * unfilled, and the fill is what the hold produces.\n *\n * The variant is still legible at rest, because the label carries its\n * colour. That is the half worth keeping — a destructive hold should not\n * look like an ordinary one before it is touched — and it is also the half\n * that survives the wipe, since the fill covers the ground the label was\n * standing on and the second copy takes over.\n */\n root: 'relative overflow-hidden border border-transparent bg-secondary',\n /*\n * The row inside the button. It is separate from `root` because the fill\n * has to sit over the whole button including its padding — a wipe that\n * stops at the text's own box leaves an unfilled margin down each side and\n * reads as a progress bar someone put inside a button.\n */\n content: 'flex-row items-center justify-center gap-2',\n label: 'min-w-0 shrink text-center font-medium',\n /** The inverted copy, drawn on the filled ground. */\n fill: '',\n fillLabel: 'min-w-0 shrink text-center font-medium',\n },\n variants: {\n variant: {\n primary: {\n label: 'text-primary',\n fill: 'bg-primary',\n fillLabel: 'text-primary-foreground',\n },\n secondary: {\n label: 'text-secondary-foreground',\n fill: 'bg-foreground',\n fillLabel: 'text-background',\n },\n destructive: {\n label: 'text-destructive',\n fill: 'bg-destructive',\n fillLabel: 'text-destructive-solid-foreground',\n },\n success: {\n label: 'text-success',\n fill: 'bg-success',\n fillLabel: 'text-success-solid-foreground',\n },\n },\n size: {\n // Matched to Button's boxes, and `min-h-*` for the same reason: the\n // label's glyphs grow with the system text size and the box has to grow\n // with them. The side padding is `shape`'s to decide, below.\n sm: {\n root: 'min-h-9',\n content: 'min-h-9 gap-1.5 py-2',\n label: 'text-[14px]',\n fillLabel: 'text-[14px]',\n },\n md: {\n root: 'min-h-11',\n content: 'min-h-11 py-2.5',\n label: 'text-[16px]',\n fillLabel: 'text-[16px]',\n },\n lg: {\n root: 'min-h-12',\n content: 'min-h-12 py-2.5',\n label: 'text-[18px]',\n fillLabel: 'text-[18px]',\n },\n },\n /*\n * The corner, and the side padding that follows from it.\n *\n * The fill is clipped by the button's radius, so the shape of the button is\n * also the shape of the wipe's leading edge as it comes out of the corner.\n * A pill sends that edge out as a curve, which reads as the button filling\n * up; a small radius sends it out square, which reads as a rectangle\n * sliding out from under the button. That is why `pill` is the default and\n * stays it.\n *\n * `rounded` is for a progress button standing in a row of ordinary ones —\n * a form's footer, a toolbar, a card's actions — where a lone pill among\n * `rounded-lg` buttons reads as a different kind of control rather than as\n * the one that has to be held. It is `Button`'s box exactly: same radius,\n * same padding, same minimum width, and the heights already agreed.\n */\n shape: {\n // The radius is only on the root. It has `overflow-hidden`, so it is what\n // clips the fill — and the fill's own leading edge has to stay straight:\n // rounding it would round the wipe's front as well as the button's\n // corner, which is a bar with a domed end travelling across the button.\n pill: { root: 'rounded-full' },\n rounded: { root: 'rounded-lg' },\n },\n fullWidth: {\n true: { root: 'w-full' },\n },\n disabled: {\n true: { root: 'opacity-[0.64]' },\n },\n },\n compoundVariants: [\n // A pill is wider than the equivalent Button at every size, because the\n // corner is a half-circle rather than a small radius: the curve eats into\n // the side padding, and at Button's values the first and last glyphs sit\n // against it. `rounded` has Button's corner, so it takes Button's padding.\n { shape: 'pill', size: 'sm', class: { content: 'px-3.5' } },\n { shape: 'pill', size: 'md', class: { content: 'px-5' } },\n { shape: 'pill', size: 'lg', class: { content: 'px-7' } },\n { shape: 'rounded', size: 'sm', class: { root: 'min-w-9', content: 'px-2.5' } },\n { shape: 'rounded', size: 'md', class: { root: 'min-w-11', content: 'px-4' } },\n { shape: 'rounded', size: 'lg', class: { content: 'px-6' } },\n ],\n defaultVariants: {\n variant: 'primary',\n size: 'md',\n shape: 'pill',\n },\n});\n\ntype ProgressButtonVariantProps = VariantProps;\n\n/** How a progress button looks. */\nexport type ProgressButtonVariant = NonNullable;\n/** How big a progress button is. */\nexport type ProgressButtonSize = NonNullable;\n/** What corner a progress button has. */\nexport type ProgressButtonShape = NonNullable;\n\ninterface ProgressButtonContextValue {\n /** `0` to `1` across the hold. */\n progress: SharedValue;\n /**\n * `0` to `1` across the arrival of the completed drawing. Separate from\n * `progress` because the fill has finished by the time this starts, and the\n * two would otherwise have to share one clock running at two speeds.\n */\n done: SharedValue;\n /** Width of the button, so the fill's copy of the label can match it. */\n width: number;\n completed: boolean;\n variant: ProgressButtonVariant;\n size: ProgressButtonSize;\n slots: ReturnType;\n}\n\nconst ProgressButtonContext = createContext(null);\n\nfunction useProgressButtonContext(component: string): ProgressButtonContextValue {\n const context = useContext(ProgressButtonContext);\n if (!context) throw new Error(`${component} must be used within a `);\n return context;\n}\n\n/** How far through the hold this button is, for something rendered inside it. */\nexport function useProgressButton() {\n const { progress, completed } = useProgressButtonContext('useProgressButton');\n return { progress, completed };\n}\n\nexport interface ProgressButtonProps\n extends Omit,\n Omit {\n className?: string;\n /**\n * Milliseconds the button has to be held. Defaults to `2000`, and is floored\n * at `200` — a hold that completes on touch-down is a button with extra steps.\n */\n holdDuration?: number;\n /** Fires once the hold has been sustained to the end. */\n onComplete?: () => void;\n /** Fires whenever the completed state changes, including on a reset. */\n onCompletedChange?: (completed: boolean) => void;\n /** Controlled completion. Leave unset to let the button own it. */\n completed?: boolean;\n /** Return to the unfilled state after `autoResetDelay`. */\n autoReset?: boolean;\n /** Milliseconds to stay completed before resetting. Defaults to `1000`. */\n autoResetDelay?: number;\n /**\n * Dim the button and refuse the hold outright. The fill never starts, so\n * there is no half-finished state to explain.\n */\n disabled?: boolean;\n /**\n * A tick as the hold takes, and a knock when it completes. Off by default:\n * whether an action is worth feeling is the caller's call, not the control's.\n */\n haptics?: boolean;\n /**\n * The corner. `pill` by default — the fill is clipped by it, so a half-circle\n * sends the wipe's leading edge out as a curve and the button reads as\n * filling up.\n *\n * `rounded` gives it [Button](/docs/components/button)'s box exactly: the\n * same radius, side padding and minimum width, at heights that already\n * matched. Use it where the hold stands in a row of ordinary buttons — a\n * form's footer, a toolbar, a card's actions — and a lone pill would read as\n * a different kind of control rather than as the one that has to be held.\n */\n shape?: ProgressButtonShape;\n children?: ReactNode;\n}\n\nconst ProgressButtonRoot = forwardRef(function ProgressButtonRoot(\n {\n className,\n variant = 'primary',\n size = 'md',\n shape = 'pill',\n fullWidth,\n holdDuration,\n onComplete,\n onCompletedChange,\n completed: completedProp,\n autoReset = false,\n autoResetDelay = DEFAULT_AUTO_RESET_DELAY,\n disabled = false,\n haptics = false,\n accessibilityState,\n children,\n ...props\n },\n ref\n) {\n const slots = progressButtonVariants({ variant, size, shape, fullWidth, disabled });\n const duration = resolveHoldDuration(holdDuration);\n const reducedMotion = useReducedMotion();\n\n const progress = useSharedValue(0);\n const done = useSharedValue(0);\n const [width, setWidth] = useState(0);\n const [internalCompleted, setInternalCompleted] = useState(false);\n const resetTimer = useRef | null>(null);\n\n const isControlled = completedProp !== undefined;\n const completed = isControlled ? completedProp : internalCompleted;\n\n /*\n * The reaction below watches a shared value, and a shared value can reach the\n * end for reasons other than a hold — a controlled button being told it is\n * complete fills instantly. Read from a ref rather than from `completed` so\n * the guard sees the current answer without the reaction being rebuilt.\n */\n const completedRef = useRef(completed);\n completedRef.current = completed;\n\n const finish = useCallback(() => {\n // Already complete: the fill was set from outside, and firing the action\n // again would run it a second time for something the reader did not do.\n if (completedRef.current) return;\n if (!isControlled) setInternalCompleted(true);\n onCompletedChange?.(true);\n onComplete?.();\n if (haptics) impactKnock();\n }, [isControlled, onComplete, onCompletedChange, haptics]);\n\n /*\n * Completion is read off the animation rather than timed alongside it.\n *\n * A `setTimeout` for `holdDuration` and a fill for `holdDuration` are two\n * clocks that agree only while the app is idle: the moment the JS thread is\n * busy the button fires before the fill arrives, or the fill sits full while\n * nothing happens. The reaction fires on the frame the value actually\n * reaches the end, which is the frame the reader saw it get there.\n */\n useAnimatedReaction(\n () => progress.value >= 1,\n (full, was) => {\n if (full && !was) runOnJS(finish)();\n }\n );\n\n /*\n * Both directions are started on the UI thread, and that is not a detail.\n *\n * A shared value animated on the UI thread does not report back to\n * JavaScript, so `progress.value` read from a press handler is the value\n * from before the hold began — zero. The release computed from it either did\n * nothing or, worse, `cancelAnimation` wrote that stale zero back and the\n * fill vanished on touch-up instead of travelling home.\n *\n * `runOnUI` puts the read where the value actually lives.\n */\n const begin = useCallback(() => {\n if (disabled || completed) return;\n if (haptics) selectionTick();\n runOnUI(() => {\n 'worklet';\n cancelAnimation(progress);\n const from = progress.value;\n if (from >= 1) return;\n progress.value = withTiming(1, {\n // The distance still ahead, at the fill's own rate — so a press that\n // catches the fill on its way back carries on from there rather than\n // restarting the clock.\n duration: fillDuration(from, duration),\n /*\n * Linear. A fill is constant motion, and an eased one misreports the\n * wait: it races the first half and crawls the second, or the reverse.\n *\n * Under reduced motion it steps instead, and is still a real\n * indicator — what that setting is about is continuous movement, not\n * the button saying how much longer to wait. Take that away and the\n * control asks for a hold with nothing on screen to say why.\n */\n easing: reducedMotion ? Easing.steps(REDUCED_STEPS, true) : Easing.linear,\n });\n })();\n }, [disabled, completed, haptics, progress, reducedMotion, duration]);\n\n /*\n * The fill, played backwards.\n *\n * Same rate, same easing, same stepping under reduced motion — only the\n * direction differs. Let go at nine tenths of a two-second hold and the fill\n * takes 1.8 seconds to travel home, which is the 1.8 seconds it took to get\n * there. A fill that vanishes has been deleted; a fill that travels back has\n * been let go, and telling those apart is the whole reason the wait is drawn\n * on the button.\n *\n * Every path that empties the fill goes through here — a hold let go, an\n * `autoReset` landing, a controlled button told it is no longer complete.\n * They were three separate assignments and two of them snapped.\n */\n const rewind = useCallback(() => {\n runOnUI(() => {\n 'worklet';\n cancelAnimation(progress);\n const from = progress.value;\n if (from <= 0) {\n progress.value = 0;\n return;\n }\n progress.value = withTiming(0, {\n duration: releaseDuration(from, duration),\n easing: reducedMotion ? Easing.steps(REDUCED_STEPS, true) : Easing.linear,\n });\n })();\n }, [progress, duration, reducedMotion]);\n\n const abandon = useCallback(() => {\n /*\n * A completed hold has nothing to abandon. Without this the fill drains on\n * the release that follows a successful hold — the button empties, looks\n * untouched, and then refuses every press after it, because `begin` bails\n * on a completed button. That combination is a control that has silently\n * stopped working.\n */\n if (completedRef.current) return;\n rewind();\n }, [rewind]);\n\n const reset = useCallback(() => {\n rewind();\n if (!isControlled) setInternalCompleted(false);\n onCompletedChange?.(false);\n }, [rewind, isControlled, onCompletedChange]);\n\n useEffect(() => {\n if (!completed || !autoReset) return;\n resetTimer.current = setTimeout(reset, autoResetDelay);\n return () => {\n if (resetTimer.current) clearTimeout(resetTimer.current);\n resetTimer.current = null;\n };\n }, [completed, autoReset, autoResetDelay, reset]);\n\n /*\n * The fill follows the completed state, in both directions.\n *\n * A controlled button told it is complete fills without a hold, and one told\n * it is no longer complete empties — otherwise the next hold would start from\n * a bar that is already full.\n */\n useEffect(() => {\n if (completed) {\n cancelAnimation(progress);\n progress.value = 1;\n return;\n }\n rewind();\n }, [completed, progress, rewind]);\n\n /*\n * And the completed drawing follows it too. Under reduced motion it is a\n * swap: what that setting is about is movement, and a tick that grows is\n * movement with nothing to report.\n */\n useEffect(() => {\n if (reducedMotion) {\n done.value = completed ? 1 : 0;\n return;\n }\n done.value = completed ? withSpring(1, DONE_SPRING) : withTiming(0, { duration: DONE_EXIT });\n }, [completed, done, reducedMotion]);\n\n useEffect(\n () => () => {\n cancelAnimation(progress);\n cancelAnimation(done);\n if (resetTimer.current) clearTimeout(resetTimer.current);\n },\n [progress, done]\n );\n\n const onLayout = (event: LayoutChangeEvent) => {\n const next = Math.round(event.nativeEvent.layout.width);\n setWidth((current) => (current === next ? current : next));\n props.onLayout?.(event);\n };\n\n /*\n * `slots` is rebuilt every render, so it is deliberately not a dependency —\n * including it would make this memo re-run every time and hand every consumer\n * a new object for no change. The variant and size it is derived from are the\n * dependencies instead.\n */\n const context = useMemo(\n () => ({ progress, done, width, completed, variant, size, slots }),\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [progress, done, width, completed, variant, size]\n );\n\n /*\n * The completed drawing is part of the button rather than something every\n * call site has to remember, so one is added unless the caller wrote their\n * own. A hold that lands and shows nothing is the reader wondering whether it\n * worked, which is the thing this control exists to remove.\n */\n const written = Children.toArray(children);\n const hasDone = written.some(\n (child) => isValidElement(child) && child.type === ProgressButtonDone\n );\n const body = (\n <>\n {written.length > 0 ? written : Hold to confirm}\n {hasDone ? null : }\n \n );\n\n return (\n \n \n {body}\n \n \n );\n});\n\nexport interface ProgressButtonLabelProps extends ViewProps {\n className?: string;\n children?: ReactNode;\n}\n\n/**\n * What the button says, drawn twice.\n *\n * The second copy is the one inside the fill, in the inverted colour, pinned to\n * the button's own width and clipped to however far the hold has got. Both are\n * the same text at the same position, so the boundary between them falls in the\n * middle of a glyph rather than between two differently laid-out lines.\n */\nfunction ProgressButtonLabel({ className, children, ...props }: ProgressButtonLabelProps) {\n const { progress, width, slots } = useProgressButtonContext('ProgressButton.Label');\n\n const fillStyle = useAnimatedStyle(() => ({ width: width * progress.value }));\n\n return (\n \n {textChildren(children, (text) => (\n {text}\n ))}\n\n {/* The wipe. `pointerEvents` off so it never takes the press it is\n drawn on top of, and `width` fixed to the button so the copy inside\n it is laid out exactly where the original is rather than reflowing\n into whatever the clip currently allows. */}\n \n \n {textChildren(children, (text) => (\n {text}\n ))}\n \n \n \n );\n}\nProgressButtonLabel.displayName = 'ProgressButton.Label';\n\nexport interface ProgressButtonDoneProps extends ViewProps {\n className?: string;\n /**\n * What the button shows once the hold has landed. A tick on its own by\n * default; children replace it, so a word beside one is\n * `Paid`.\n */\n children?: ReactNode;\n}\n\n/**\n * The drawing the button lands on.\n *\n * It sits over the finished fill rather than beside the label, so the button\n * does not change width at the moment it completes — a control that resizes as\n * it succeeds moves everything under it, and the reader's eye is on the button.\n *\n * One is added for you unless you write your own, because a hold that lands and\n * shows nothing leaves the reader checking whether it worked.\n */\nfunction ProgressButtonDone({ className, children, ...props }: ProgressButtonDoneProps) {\n const { done, completed, variant, slots } = useProgressButtonContext('ProgressButton.Done');\n const tint = useCSSVariable(DONE_TINT[variant]);\n\n /*\n * It carries the fill's own colour and covers the button edge to edge.\n *\n * The alternative — fading the label out from under it — takes the fill with\n * it, because the fill is drawn inside the label so that the two copies of\n * the text line up. The button would empty at the exact moment it succeeded,\n * and the tick, drawn in the colour that reads against a full fill, would be\n * left standing on nothing.\n */\n const style = useAnimatedStyle(() => ({ opacity: done.value }));\n // The tick arrives from slightly under full size. The ground it lands on\n // does not: a background box that grows reads as the button resizing.\n const markStyle = useAnimatedStyle(() => ({ transform: [{ scale: 0.7 + done.value * 0.3 }] }));\n\n return (\n \n \n \n {children ?? }\n \n \n \n );\n}\nProgressButtonDone.displayName = 'ProgressButton.Done';\n\nProgressButtonRoot.displayName = 'ProgressButton';\n\nexport const ProgressButton = Object.assign(ProgressButtonRoot, {\n Label: ProgressButtonLabel,\n Done: ProgressButtonDone,\n});\n\nexport {\n DEFAULT_AUTO_RESET_DELAY,\n DEFAULT_HOLD_DURATION,\n DEFAULT_RELEASE_DURATION,\n fillDuration,\n isComplete,\n releaseDuration,\n resolveHoldDuration,\n} from '@/components/ui/progress-button-hold';\n" }, { "path": "ui/progress-button-hold.ts", diff --git a/apps/docs/public/r/progress.json b/apps/docs/public/r/progress.json index b77de573f..f6bf77871 100644 --- a/apps/docs/public/r/progress.json +++ b/apps/docs/public/r/progress.json @@ -4,6 +4,7 @@ "description": "Determinate and indeterminate progress bar.", "registryDependencies": [ "cn", + "refs", "text", "use-direction" ], @@ -16,7 +17,7 @@ { "path": "ui/progress.tsx", "type": "registry:ui", - "content": "import { forwardRef, useEffect } from 'react';\nimport { View, type LayoutChangeEvent, type ViewProps } from 'react-native';\nimport Animated, {\n Easing,\n cancelAnimation,\n interpolate,\n useAnimatedStyle,\n useReducedMotion,\n useSharedValue,\n withRepeat,\n withSpring,\n withTiming,\n} from 'react-native-reanimated';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport { useDirectionSign } from '@/hooks/use-direction';\nimport { Text } from '@/components/ui/text';\nimport { cn } from '@/lib/cn';\n\nconst SPRING = { damping: 20, stiffness: 180, mass: 0.6 } as const;\n/** Fraction of the track covered by the sliding bar in indeterminate mode. */\nconst INDETERMINATE_WIDTH = 0.4;\n/** Milliseconds for the bar to cross the track once. */\nconst SWEEP_DURATION = 1100;\n/** Milliseconds for one half of the fade that stands in for the sweep. */\nconst PULSE_DURATION = 900;\n/** How far down the fade goes. Faint enough to read as a pulse, not a flicker. */\nconst PULSE_FLOOR = 0.35;\n\nconst progressVariants = tv({\n slots: {\n root: 'w-full gap-2',\n header: 'flex-row items-center justify-between',\n track: 'w-full overflow-hidden rounded-full',\n indicator: 'h-full rounded-full',\n },\n variants: {\n color: {\n primary: { track: 'bg-primary/16', indicator: 'bg-primary' },\n success: { track: 'bg-success/16', indicator: 'bg-success' },\n warning: { track: 'bg-warning/16', indicator: 'bg-warning' },\n destructive: { track: 'bg-destructive/16', indicator: 'bg-destructive' },\n info: { track: 'bg-info/16', indicator: 'bg-info' },\n },\n size: {\n sm: { track: 'h-1.5' },\n md: { track: 'h-2' },\n lg: { track: 'h-3' },\n },\n },\n defaultVariants: {\n color: 'primary',\n size: 'md',\n },\n});\n\ntype ProgressVariantProps = VariantProps;\n\nexport interface ProgressProps\n extends Omit,\n ProgressVariantProps {\n className?: string;\n /**\n * Where the work has got to, somewhere between `minValue` and `maxValue`.\n * Ignored when `indeterminate` is set.\n */\n value?: number;\n /**\n * The bottom of the range — the value at which the bar reads as empty.\n * Defaults to `0`.\n */\n minValue?: number;\n /**\n * The top of the range — the value at which the bar reads as full. Defaults\n * to `100`, so a bare percentage keeps working with neither bound set.\n */\n maxValue?: number;\n /**\n * Show a looping animation for unknown-duration work. Under the platform's\n * reduce-motion setting the bar fills the track and pulses instead of\n * travelling across it, so it still reads as running.\n */\n indeterminate?: boolean;\n /** Extra classes for the moving indicator. */\n indicatorClassName?: string;\n /**\n * Caption drawn above the track, on the left. Supplying it (or\n * `showValueLabel`) wraps the bar in a header row; the track alone renders\n * otherwise.\n */\n label?: string;\n /**\n * Draw the percentage above the track, on the right. Hidden while\n * `indeterminate` — there is nothing meaningful to show.\n */\n showValueLabel?: boolean;\n /**\n * Text for the value label. Overrides the formatted percentage — use it for\n * a byte count, a step tally, anything that is not a bare percent.\n */\n valueLabel?: string;\n /**\n * How to write the value, through `Intl.NumberFormat`. A `percent` style\n * formats how far along the bar is; every other style formats the value\n * itself, so `{ style: 'currency', currency: 'USD' }` against a `maxValue`\n * of `2000` reads `$1,250.00` rather than a percentage of it. Falls back to\n * a rounded percent when omitted.\n */\n formatOptions?: Intl.NumberFormatOptions;\n /** Extra classes for the label + value-label row. */\n headerClassName?: string;\n}\n\n/** `value` held inside the range, so a stray number cannot escape the track. */\nfunction clamp(value: number, min: number, max: number) {\n if (!(value > min)) return min;\n if (value > max) return max;\n return value;\n}\n\n/**\n * How far along the bar is, 0–1. An empty or inverted range has no meaningful\n * position in it, so it reads as empty rather than dividing by zero.\n */\nfunction fractionOf(value: number, min: number, max: number) {\n const span = max - min;\n if (!(span > 0)) return 0;\n return clamp((value - min) / span, 0, 1);\n}\n\n/**\n * The right-hand caption: an explicit override, an `Intl` rendering, or a\n * rounded percent.\n *\n * A `percent` style is given the fraction, because that is what a percentage\n * of the range means; every other style is given the value, because a currency\n * or a byte count is a quantity and not a proportion.\n */\nfunction formatValue(\n value: number,\n fraction: number,\n valueLabel?: string,\n formatOptions?: Intl.NumberFormatOptions\n) {\n if (valueLabel != null) return valueLabel;\n if (formatOptions) {\n try {\n return new Intl.NumberFormat(undefined, formatOptions).format(\n formatOptions.style === 'percent' ? fraction : value\n );\n } catch {\n // Some engines ship a partial Intl; fall through to the plain percent.\n }\n }\n return `${Math.round(fraction * 100)}%`;\n}\n\n/**\n * Determinate or indeterminate progress bar. The fill width (determinate) and\n * the sliding bar (indeterminate) are both driven on the UI thread, so updates\n * never re-render past the value change itself.\n *\n * Pass `label` or `showValueLabel` to caption the bar with a header row; the\n * bare track renders when neither is set, so existing call sites are untouched.\n *\n * The value is read against `minValue` / `maxValue`, which default to 0 and\n * 100 — so a percentage needs neither. Set them and the bar speaks in whatever\n * the work is actually counted in: bytes uploaded, seats filled, points\n * scored. Nothing has to be converted to a percent on the way in, and the\n * screen reader is told the real range rather than a derived one.\n *\n * Reduce motion is honoured in both modes: the fill lands on its value rather\n * than springing to it, and the indeterminate bar pulses in place rather than\n * crossing the track.\n */\nexport const Progress = forwardRef(\n (\n {\n className,\n indicatorClassName,\n headerClassName,\n value = 0,\n minValue = 0,\n maxValue = 100,\n indeterminate = false,\n label,\n showValueLabel = false,\n valueLabel,\n formatOptions,\n color,\n size,\n ...props\n },\n ref\n ) => {\n const slots = progressVariants({ color, size });\n const trackWidth = useSharedValue(0);\n // Yoga mirrors the track; the bar sliding along it is a transform, so it\n // has to be turned around itself.\n const sign = useDirectionSign();\n const target = fractionOf(value, minValue, maxValue);\n const progress = useSharedValue(target);\n const slide = useSharedValue(0);\n const reducedMotion = useReducedMotion();\n\n /**\n * The loop is tied to `indeterminate` and to how much motion is wanted.\n * Restarting it whenever the value moved used to leave `withRepeat`\n * cycling from wherever the bar had got to, so a bar that kept receiving\n * values while looping ended up sweeping a shrinking sliver of the track\n * instead of crossing it.\n *\n * Under reduce motion the same loop drives a fade rather than a journey\n * across the track. Freezing it outright is the other option and it is the\n * wrong one: a bar that stops moving reads as a bar that has hung, which is\n * the one thing an indeterminate bar exists to rule out. Reduce motion asks\n * for less travel, not for less news — so the travel goes and the pulse\n * stays, running slower than the sweep it replaces.\n */\n useEffect(() => {\n if (!indeterminate) return undefined;\n slide.value = 0;\n slide.value = withRepeat(\n withTiming(1, {\n duration: reducedMotion ? PULSE_DURATION : SWEEP_DURATION,\n easing: Easing.inOut(Easing.ease),\n }),\n -1,\n // The sweep restarts at the left edge; the fade turns around, because a\n // pulse that snapped back to full brightness would be the flicker the\n // setting is there to avoid.\n reducedMotion\n );\n return () => cancelAnimation(slide);\n }, [indeterminate, reducedMotion, slide]);\n\n // The fill follows the value, and picks up from wherever the loop left it\n // when a bar stops being indeterminate mid-flight. Reduce motion lands on\n // the value instead of springing to it — the number is the information, and\n // the overshoot is decoration.\n useEffect(() => {\n if (indeterminate) return;\n progress.value = reducedMotion ? target : withSpring(target, SPRING);\n }, [indeterminate, target, progress, reducedMotion]);\n\n const onLayout = (event: LayoutChangeEvent) => {\n trackWidth.value = event.nativeEvent.layout.width;\n };\n\n /*\n * Both styles set every property either of them sets. The two are swapped\n * on the same view when a bar stops being indeterminate, and an animated\n * property that one style drops is not reset by the other — it is simply\n * left at whatever it was last given, which would strand a half-faded bar\n * at that opacity for the rest of its life.\n */\n const determinateStyle = useAnimatedStyle(() => ({\n width: trackWidth.value * progress.value,\n opacity: 1,\n transform: [{ translateX: 0 }],\n }));\n\n const indeterminateStyle = useAnimatedStyle(() => {\n if (reducedMotion) {\n // Nothing travels: the whole track carries the bar and the bar breathes.\n return {\n width: trackWidth.value,\n opacity: interpolate(slide.value, [0, 1], [1, PULSE_FLOOR]),\n transform: [{ translateX: 0 }],\n };\n }\n const barWidth = trackWidth.value * INDETERMINATE_WIDTH;\n return {\n width: barWidth,\n opacity: 1,\n transform: [\n {\n // The loop travels the way the text does. Yoga mirrors the track\n // but not the transform sliding along it.\n translateX: interpolate(\n slide.value,\n [0, 1],\n sign === 1\n ? [-barWidth, trackWidth.value]\n : [trackWidth.value, -barWidth]\n ),\n },\n ],\n };\n });\n\n // The value label is meaningless while looping, so it is dropped there.\n const showValue = showValueLabel && !indeterminate;\n const hasHeader = label != null || showValue;\n const spoken = formatValue(\n clamp(value, minValue, maxValue),\n target,\n valueLabel,\n formatOptions\n );\n\n const track = (\n \n \n \n );\n\n if (!hasHeader) return track;\n\n return (\n \n \n {label != null ? (\n \n {label}\n \n ) : (\n \n )}\n {showValue ? (\n \n {spoken}\n \n ) : null}\n \n {track}\n \n );\n }\n);\n\nProgress.displayName = 'Progress';\n" + "content": "import { forwardRef, useEffect } from 'react';\nimport { View, type LayoutChangeEvent, type ViewProps } from 'react-native';\nimport Animated, {\n Easing,\n cancelAnimation,\n interpolate,\n useAnimatedStyle,\n useReducedMotion,\n useSharedValue,\n withRepeat,\n withSpring,\n withTiming,\n} from 'react-native-reanimated';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport { useDirectionSign } from '@/hooks/use-direction';\nimport { Text } from '@/components/ui/text';\nimport { cn } from '@/lib/cn';\nimport type { ViewRef } from '@/components/ui/refs';\n\nconst SPRING = { damping: 20, stiffness: 180, mass: 0.6 } as const;\n/** Fraction of the track covered by the sliding bar in indeterminate mode. */\nconst INDETERMINATE_WIDTH = 0.4;\n/** Milliseconds for the bar to cross the track once. */\nconst SWEEP_DURATION = 1100;\n/** Milliseconds for one half of the fade that stands in for the sweep. */\nconst PULSE_DURATION = 900;\n/** How far down the fade goes. Faint enough to read as a pulse, not a flicker. */\nconst PULSE_FLOOR = 0.35;\n\nconst progressVariants = tv({\n slots: {\n root: 'w-full gap-2',\n header: 'flex-row items-center justify-between',\n track: 'w-full overflow-hidden rounded-full',\n indicator: 'h-full rounded-full',\n },\n variants: {\n color: {\n primary: { track: 'bg-primary/16', indicator: 'bg-primary' },\n success: { track: 'bg-success/16', indicator: 'bg-success' },\n warning: { track: 'bg-warning/16', indicator: 'bg-warning' },\n destructive: { track: 'bg-destructive/16', indicator: 'bg-destructive' },\n info: { track: 'bg-info/16', indicator: 'bg-info' },\n },\n size: {\n sm: { track: 'h-1.5' },\n md: { track: 'h-2' },\n lg: { track: 'h-3' },\n },\n },\n defaultVariants: {\n color: 'primary',\n size: 'md',\n },\n});\n\ntype ProgressVariantProps = VariantProps;\n\nexport interface ProgressProps\n extends Omit,\n ProgressVariantProps {\n className?: string;\n /**\n * Where the work has got to, somewhere between `minValue` and `maxValue`.\n * Ignored when `indeterminate` is set.\n */\n value?: number;\n /**\n * The bottom of the range — the value at which the bar reads as empty.\n * Defaults to `0`.\n */\n minValue?: number;\n /**\n * The top of the range — the value at which the bar reads as full. Defaults\n * to `100`, so a bare percentage keeps working with neither bound set.\n */\n maxValue?: number;\n /**\n * Show a looping animation for unknown-duration work. Under the platform's\n * reduce-motion setting the bar fills the track and pulses instead of\n * travelling across it, so it still reads as running.\n */\n indeterminate?: boolean;\n /** Extra classes for the moving indicator. */\n indicatorClassName?: string;\n /**\n * Caption drawn above the track, on the left. Supplying it (or\n * `showValueLabel`) wraps the bar in a header row; the track alone renders\n * otherwise.\n */\n label?: string;\n /**\n * Draw the percentage above the track, on the right. Hidden while\n * `indeterminate` — there is nothing meaningful to show.\n */\n showValueLabel?: boolean;\n /**\n * Text for the value label. Overrides the formatted percentage — use it for\n * a byte count, a step tally, anything that is not a bare percent.\n */\n valueLabel?: string;\n /**\n * How to write the value, through `Intl.NumberFormat`. A `percent` style\n * formats how far along the bar is; every other style formats the value\n * itself, so `{ style: 'currency', currency: 'USD' }` against a `maxValue`\n * of `2000` reads `$1,250.00` rather than a percentage of it. Falls back to\n * a rounded percent when omitted.\n */\n formatOptions?: Intl.NumberFormatOptions;\n /** Extra classes for the label + value-label row. */\n headerClassName?: string;\n}\n\n/** `value` held inside the range, so a stray number cannot escape the track. */\nfunction clamp(value: number, min: number, max: number) {\n if (!(value > min)) return min;\n if (value > max) return max;\n return value;\n}\n\n/**\n * How far along the bar is, 0–1. An empty or inverted range has no meaningful\n * position in it, so it reads as empty rather than dividing by zero.\n */\nfunction fractionOf(value: number, min: number, max: number) {\n const span = max - min;\n if (!(span > 0)) return 0;\n return clamp((value - min) / span, 0, 1);\n}\n\n/**\n * The right-hand caption: an explicit override, an `Intl` rendering, or a\n * rounded percent.\n *\n * A `percent` style is given the fraction, because that is what a percentage\n * of the range means; every other style is given the value, because a currency\n * or a byte count is a quantity and not a proportion.\n */\nfunction formatValue(\n value: number,\n fraction: number,\n valueLabel?: string,\n formatOptions?: Intl.NumberFormatOptions\n) {\n if (valueLabel != null) return valueLabel;\n if (formatOptions) {\n try {\n return new Intl.NumberFormat(undefined, formatOptions).format(\n formatOptions.style === 'percent' ? fraction : value\n );\n } catch {\n // Some engines ship a partial Intl; fall through to the plain percent.\n }\n }\n return `${Math.round(fraction * 100)}%`;\n}\n\n/**\n * Determinate or indeterminate progress bar. The fill width (determinate) and\n * the sliding bar (indeterminate) are both driven on the UI thread, so updates\n * never re-render past the value change itself.\n *\n * Pass `label` or `showValueLabel` to caption the bar with a header row; the\n * bare track renders when neither is set, so existing call sites are untouched.\n *\n * The value is read against `minValue` / `maxValue`, which default to 0 and\n * 100 — so a percentage needs neither. Set them and the bar speaks in whatever\n * the work is actually counted in: bytes uploaded, seats filled, points\n * scored. Nothing has to be converted to a percent on the way in, and the\n * screen reader is told the real range rather than a derived one.\n *\n * Reduce motion is honoured in both modes: the fill lands on its value rather\n * than springing to it, and the indeterminate bar pulses in place rather than\n * crossing the track.\n */\nexport const Progress = forwardRef(\n (\n {\n className,\n indicatorClassName,\n headerClassName,\n value = 0,\n minValue = 0,\n maxValue = 100,\n indeterminate = false,\n label,\n showValueLabel = false,\n valueLabel,\n formatOptions,\n color,\n size,\n ...props\n },\n ref\n ) => {\n const slots = progressVariants({ color, size });\n const trackWidth = useSharedValue(0);\n // Yoga mirrors the track; the bar sliding along it is a transform, so it\n // has to be turned around itself.\n const sign = useDirectionSign();\n const target = fractionOf(value, minValue, maxValue);\n const progress = useSharedValue(target);\n const slide = useSharedValue(0);\n const reducedMotion = useReducedMotion();\n\n /**\n * The loop is tied to `indeterminate` and to how much motion is wanted.\n * Restarting it whenever the value moved used to leave `withRepeat`\n * cycling from wherever the bar had got to, so a bar that kept receiving\n * values while looping ended up sweeping a shrinking sliver of the track\n * instead of crossing it.\n *\n * Under reduce motion the same loop drives a fade rather than a journey\n * across the track. Freezing it outright is the other option and it is the\n * wrong one: a bar that stops moving reads as a bar that has hung, which is\n * the one thing an indeterminate bar exists to rule out. Reduce motion asks\n * for less travel, not for less news — so the travel goes and the pulse\n * stays, running slower than the sweep it replaces.\n */\n useEffect(() => {\n if (!indeterminate) return undefined;\n slide.value = 0;\n slide.value = withRepeat(\n withTiming(1, {\n duration: reducedMotion ? PULSE_DURATION : SWEEP_DURATION,\n easing: Easing.inOut(Easing.ease),\n }),\n -1,\n // The sweep restarts at the left edge; the fade turns around, because a\n // pulse that snapped back to full brightness would be the flicker the\n // setting is there to avoid.\n reducedMotion\n );\n return () => cancelAnimation(slide);\n }, [indeterminate, reducedMotion, slide]);\n\n // The fill follows the value, and picks up from wherever the loop left it\n // when a bar stops being indeterminate mid-flight. Reduce motion lands on\n // the value instead of springing to it — the number is the information, and\n // the overshoot is decoration.\n useEffect(() => {\n if (indeterminate) return;\n progress.value = reducedMotion ? target : withSpring(target, SPRING);\n }, [indeterminate, target, progress, reducedMotion]);\n\n const onLayout = (event: LayoutChangeEvent) => {\n trackWidth.value = event.nativeEvent.layout.width;\n };\n\n /*\n * Both styles set every property either of them sets. The two are swapped\n * on the same view when a bar stops being indeterminate, and an animated\n * property that one style drops is not reset by the other — it is simply\n * left at whatever it was last given, which would strand a half-faded bar\n * at that opacity for the rest of its life.\n */\n const determinateStyle = useAnimatedStyle(() => ({\n width: trackWidth.value * progress.value,\n opacity: 1,\n transform: [{ translateX: 0 }],\n }));\n\n const indeterminateStyle = useAnimatedStyle(() => {\n if (reducedMotion) {\n // Nothing travels: the whole track carries the bar and the bar breathes.\n return {\n width: trackWidth.value,\n opacity: interpolate(slide.value, [0, 1], [1, PULSE_FLOOR]),\n transform: [{ translateX: 0 }],\n };\n }\n const barWidth = trackWidth.value * INDETERMINATE_WIDTH;\n return {\n width: barWidth,\n opacity: 1,\n transform: [\n {\n // The loop travels the way the text does. Yoga mirrors the track\n // but not the transform sliding along it.\n translateX: interpolate(\n slide.value,\n [0, 1],\n sign === 1\n ? [-barWidth, trackWidth.value]\n : [trackWidth.value, -barWidth]\n ),\n },\n ],\n };\n });\n\n // The value label is meaningless while looping, so it is dropped there.\n const showValue = showValueLabel && !indeterminate;\n const hasHeader = label != null || showValue;\n const spoken = formatValue(\n clamp(value, minValue, maxValue),\n target,\n valueLabel,\n formatOptions\n );\n\n const track = (\n \n \n \n );\n\n if (!hasHeader) return track;\n\n return (\n \n \n {label != null ? (\n \n {label}\n \n ) : (\n \n )}\n {showValue ? (\n \n {spoken}\n \n ) : null}\n \n {track}\n \n );\n }\n);\n\nProgress.displayName = 'Progress';\n" } ] } diff --git a/apps/docs/public/r/qr-code.json b/apps/docs/public/r/qr-code.json index 4b0a11af6..84dc7eb67 100644 --- a/apps/docs/public/r/qr-code.json +++ b/apps/docs/public/r/qr-code.json @@ -5,6 +5,7 @@ "registryDependencies": [ "cn", "popover", + "refs", "text" ], "dependencies": [ @@ -17,7 +18,7 @@ { "path": "ui/qr-code.tsx", "type": "registry:ui", - "content": "/**\n * QRCode — a string, drawn as something a camera can read.\n *\n * ```tsx\n * \n * \n * \n * \n * Scan to open the docs\n * \n * ```\n *\n * Composition is the API, as everywhere else here: a code in a titled panel is\n * one with a `QRCode.Header` in it, and a code behind a button is one wrapped\n * in a `QRCode.Trigger` and a `QRCode.Content` — not one with a prop turned on.\n *\n * ## What it draws\n *\n * Three ``s: the body, the three corner rings, and the three corner\n * centres. Every module is a subpath of one of them, not a `` of its\n * own. A version 10 code is 3,481 modules; half of them dark is seventeen\n * hundred native views for a picture that never changes, and the difference\n * between that and three nodes is the difference between a code that appears\n * and one that appears eventually.\n *\n * Three rather than one because they are the three things worth colouring and\n * shaping separately. A code is found by its corner eyes before a single\n * module is read, so they are the part a design touches first — and the part\n * it is most dangerous to touch badly.\n *\n * The colours come from the theme, so a code drawn on a card is legible on\n * every one of them — and light-on-dark is drawn the way scanners expect it,\n * with the quiet zone painted rather than left transparent. A code with\n * nothing behind it reads at about half the distance.\n *\n * ## Shaping it\n *\n * `moduleShape`, `eyeFrameShape` and `eyeBallShape` on `QRCode.Canvas` change\n * the geometry; `color`, `eyeFrameColor`, `eyeBallColor` and `backgroundColor`\n * change the ink. A scanner samples the centre of each cell, so every shape\n * here reads exactly as a square does — what changes is how much of the cell\n * is covered. `dot` fills about two thirds of it and `diamond` half, which is\n * read distance spent on a look. Raise `errorCorrection` to pay for it, and\n * check a printed code rather than one on a screen.\n *\n * ## The hole in the middle\n *\n * `QRCode.Logo` clears a square of modules and puts its children there.\n * Error correction is what makes that survivable — the modules are gone, and\n * the code still reads because there is enough redundancy to reconstruct them.\n * How much is `errorCorrection`, and a logo raises the level it needs: the\n * default `M` tolerates about 15% loss, `H` about 30%. A logo is measured\n * against that budget and the level is raised for you if it has to be.\n */\nimport {\n createContext,\n forwardRef,\n useContext,\n useMemo,\n type ReactElement,\n type ReactNode,\n} from 'react';\nimport { View, type ViewProps } from 'react-native';\nimport Svg, { Path, Rect } from 'react-native-svg';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport { useCSSVariable } from 'uniwind';\nimport { Text } from '@/components/ui/text';\nimport { cn } from '@/lib/cn';\nimport { Popover, type PopoverContentProps } from '@/components/ui/popover';\nimport { encodeQr, type ErrorCorrectionLevel, type QrMatrix } from '@/components/ui/qr-encode';\nimport {\n eyeBallPath,\n eyeFramePath,\n finderOrigins,\n inFinder,\n modulePath,\n type QRCodeEyeBallShape,\n type QRCodeEyeFrameShape,\n type QRCodeModuleShape,\n} from '@/components/ui/qr-shapes';\n\nexport type { ErrorCorrectionLevel };\nexport type { QRCodeModuleShape, QRCodeEyeFrameShape, QRCodeEyeBallShape };\n\nconst qrCodeVariants = tv({\n slots: {\n root: 'items-center gap-3',\n // The widget shell every chart in the library is shown in: a titled tray\n // with the card flush inside it. A code is the same kind of thing — one\n // object with a label over it — so it is the same shape.\n frame: 'w-full overflow-hidden rounded-3xl border border-border bg-surface',\n header: 'w-full flex-row items-center justify-between gap-3 px-4 pb-3 pt-2.5',\n // Flush left, right and bottom: the shell's own edge is already there.\n panel: 'relative items-center justify-center overflow-hidden rounded-t-2xl border-t border-border bg-card p-4',\n caption: 'text-center text-sm text-muted-foreground',\n value: 'text-center text-xs text-muted-foreground',\n },\n variants: {\n size: {\n sm: {},\n md: {},\n lg: {},\n },\n },\n defaultVariants: {\n size: 'md',\n },\n});\n\ntype QrCodeVariantProps = VariantProps;\n\n/** How big the drawn code is. */\nexport type QRCodeSize = NonNullable;\n\n/** Side length in points per size. The module grid divides into it. */\nconst CANVAS_SIZE: Record = { sm: 128, md: 180, lg: 240 };\n\n/**\n * The quiet zone, in modules. Four is what the specification asks for and what\n * scanners are tuned to; less and a code against a busy background stops being\n * found at all.\n */\nconst QUIET_ZONE = 4;\n\n/** Roughly how much of the code each level can lose and still be read. */\nconst TOLERANCE: Record = { L: 0.07, M: 0.15, Q: 0.25, H: 0.3 };\n\n/** Ascending, so \"the next level up\" is a step through this. */\nconst LEVELS: ErrorCorrectionLevel[] = ['L', 'M', 'Q', 'H'];\n\n/** How much of the code a logo of this fraction covers. */\nfunction coverage(logoFraction: number) {\n return logoFraction * logoFraction;\n}\n\n/**\n * The cleared square, in module coordinates.\n *\n * Both the canvas and the logo compute it from this, so the hole and the thing\n * filling it are the same square. Two nearly-equal calculations is how you get\n * a dark ring around a logo, which is the failure the screenshot showed.\n */\nfunction holeBounds(matrixSize: number, logoFraction: number) {\n const from = Math.floor((matrixSize * (1 - logoFraction)) / 2);\n const to = Math.ceil((matrixSize * (1 + logoFraction)) / 2);\n return { from, to };\n}\n\n/**\n * The level actually used: the one asked for, unless a logo would eat more of\n * the code than it can afford, in which case the next one that can.\n */\nfunction levelFor(requested: ErrorCorrectionLevel, logoFraction: number): ErrorCorrectionLevel {\n const needed = coverage(logoFraction);\n // Half the budget, not all of it: the rest is for the things a code in the\n // world actually loses — glare, a fold, a thumb.\n const affordable = (level: ErrorCorrectionLevel) => TOLERANCE[level] / 2 >= needed;\n\n if (affordable(requested)) return requested;\n return LEVELS.slice(LEVELS.indexOf(requested)).find(affordable) ?? 'H';\n}\n\ninterface QRCodeContextValue {\n matrix: QrMatrix | null;\n /** What went wrong, when there is no matrix. */\n error: string | null;\n value: string;\n size: QRCodeSize;\n /** Side of the logo hole as a fraction of the code, `0` for none. */\n logoFraction: number;\n slots: ReturnType;\n}\n\nconst QRCodeContext = createContext(null);\n\nfunction useQRCode(part: string) {\n const context = useContext(QRCodeContext);\n if (!context) throw new Error(`${part} must be used inside .`);\n return context;\n}\n\n/** Where the code is drawn: in place, in a popover, or up from the bottom. */\nexport type QRCodePresentation = 'inline' | 'popover' | 'bottom-sheet';\n\nexport interface QRCodeProps extends Omit, QrCodeVariantProps {\n className?: string;\n /**\n * What the code encodes. Anything: a URL, a WiFi string, a vCard. Encoded\n * as UTF-8, and the version grows to fit it.\n */\n value: string;\n /**\n * How much of the code can be lost and still read — `L` about 7%, `M` 15%,\n * `Q` 25%, `H` 30%. More correction means a denser code at the same size, so\n * `M` is the default. Raised automatically when a `QRCode.Logo` needs it.\n */\n errorCorrection?: ErrorCorrectionLevel;\n /**\n * Fix the QR version, 1–40, instead of taking the smallest that fits. Worth\n * setting when the content changes and the code should not visibly change\n * density with it.\n */\n version?: number;\n /**\n * Where the code appears. `inline` draws it where it sits; the other two\n * put it behind a `QRCode.Trigger` and draw it in a `QRCode.Content`.\n */\n presentation?: QRCodePresentation;\n /** Controlled open state. Ignored while `presentation` is `inline`. */\n open?: boolean;\n onOpenChange?: (open: boolean) => void;\n children?: ReactNode;\n}\n\nconst QRCodeRoot = forwardRef(\n (\n {\n className,\n value,\n errorCorrection = 'M',\n version,\n size = 'md',\n presentation = 'inline',\n open,\n onOpenChange,\n children,\n ...props\n },\n ref\n ) => {\n const slots = qrCodeVariants({ size });\n\n /*\n * A logo is a child, and the matrix has to know about it before the child\n * renders — the level it forces changes the matrix itself. Rather than\n * inspecting children, the fraction is a constant the Logo part also uses,\n * and the root asks whether one is present at all.\n */\n const logoFraction = hasLogo(children) ? LOGO_FRACTION : 0;\n const level = levelFor(errorCorrection, logoFraction);\n\n const { matrix, error } = useMemo(() => {\n try {\n return { matrix: encodeQr(value, { errorCorrection: level, version }), error: null };\n } catch (cause) {\n // Too much data, or a version too small for it. A throw here would\n // take a screen down over a string, so it draws nothing and says why.\n return { matrix: null, error: (cause as Error).message };\n }\n }, [value, level, version]);\n\n const context: QRCodeContextValue = { matrix, error, value, size, logoFraction, slots };\n\n if (presentation !== 'inline') {\n // The provider stays outside the Popover, and Content re-provides it\n // inside — portal content mounts under the portal host, which is not in\n // this subtree.\n return (\n \n \n {children}\n \n \n );\n }\n\n return (\n \n \n {children}\n \n \n );\n }\n);\nQRCodeRoot.displayName = 'QRCode';\n\n/* ------------------------------------------------------------------ *\n * The code itself.\n * ------------------------------------------------------------------ */\n\nexport interface QRCodeCanvasProps extends Omit {\n className?: string;\n /** Side length in points. Defaults to the size variant's. */\n pixelSize?: number;\n /** Dark modules. See the note below before overriding this. */\n color?: string;\n /** The plate the modules sit on. See the note below. */\n backgroundColor?: string;\n /**\n * How a data module is drawn.\n *\n * `rounded` and `classy` join to their neighbours — a corner is rounded only\n * where both cells touching it are light — so a run reads as one stroke\n * rather than as a string of beads with a light seam through it.\n *\n * `dot` and `diamond` do not tile, and cost read distance for it: `dot`\n * covers about two thirds of its cell and `diamond` exactly half, so the\n * same code is a fainter code at the same size. Raise `errorCorrection` with\n * them, and check a printed one rather than a screen.\n */\n moduleShape?: QRCodeModuleShape;\n /** How the ring around each of the three corner eyes is drawn. */\n eyeFrameShape?: QRCodeEyeFrameShape;\n /** How the square inside each corner eye is drawn. */\n eyeBallShape?: QRCodeEyeBallShape;\n /** The three corner rings. Defaults to `color`. */\n eyeFrameColor?: string;\n /** The three corner centres. Defaults to `eyeFrameColor`, then `color`. */\n eyeBallColor?: string;\n}\n\n/**\n * Dark modules on a light plate, whatever the theme is doing.\n *\n * This is the one place in the library that does not follow the tokens, and it\n * is deliberate. A QR code is not a surface — it is a thing a camera has to\n * read, and readers expect dark on light. Inverted, a code is rejected outright\n * by a good share of scanners and found late by most of the rest, which turns a\n * dark theme into a bug report about a code that \"sometimes does not work\".\n *\n * On a light theme the plate is white on a near-white card, which looks like\n * nothing at all — correct, and the intended outcome. On a dark theme it reads\n * as a light plate holding a code, which is the shape everyone recognises.\n *\n * `color` and `backgroundColor` override both, for a code that is definitely\n * being read by something you control.\n */\nconst PLATE = '#ffffff';\nconst MODULE = '#111111';\n\nconst QRCodeCanvas = forwardRef(\n (\n {\n className,\n pixelSize,\n color,\n backgroundColor,\n moduleShape = 'square',\n eyeFrameShape = 'square',\n eyeBallShape = 'square',\n eyeFrameColor,\n eyeBallColor,\n ...props\n },\n ref\n ) => {\n const { matrix, error, value, size, logoFraction } = useQRCode('QRCode.Canvas');\n\n const side = pixelSize ?? CANVAS_SIZE[size];\n const dark = color ?? MODULE;\n const light = backgroundColor ?? PLATE;\n const frameInk = eyeFrameColor ?? dark;\n const ballInk = eyeBallColor ?? frameInk;\n\n /*\n * Three paths for the whole code: the body, the three corner rings, and\n * the three corner centres.\n *\n * Three rather than one because they are three different things a reader\n * can colour and shape separately — and still three rather than one node\n * per module, which is what a version 10 code would cost: 3,481 cells,\n * half of them dark, seventeen hundred native views for a picture that\n * never changes.\n *\n * The viewBox is in module units, so a shape is written once and scales to\n * whatever `side` is — no arithmetic per module, and no rounding gaps\n * between neighbours at fractional sizes.\n */\n const paths = useMemo(() => {\n if (!matrix) return { body: '', frames: '', balls: '' };\n\n const hole = logoFraction ? holeBounds(matrix.size, logoFraction) : null;\n\n /*\n * The body, as its own matrix rather than as a filter inside the loop.\n * The rounded shapes ask their neighbours whether a corner is free, and\n * the answer has to be about what is *drawn* — a module beside the logo\n * hole or beside an eye has nothing there to join to, and joining to it\n * leaves a squared-off corner facing a gap.\n */\n const body: boolean[][] = [];\n for (let y = 0; y < matrix.size; y++) {\n const row: boolean[] = [];\n for (let x = 0; x < matrix.size; x++) {\n const lit =\n !!matrix.modules[y]![x] &&\n !inFinder(x, y, matrix.size) &&\n // Modules under the logo are not drawn at all — drawing them and\n // covering them leaves a dark edge wherever the logo is smaller\n // than the square it cleared.\n !(hole && x >= hole.from && x < hole.to && y >= hole.from && y < hole.to);\n row.push(lit);\n }\n body.push(row);\n }\n\n let d = '';\n for (let y = 0; y < matrix.size; y++) {\n for (let x = 0; x < matrix.size; x++) {\n if (!body[y]![x]) continue;\n d += modulePath(moduleShape, body, x, y, matrix.size, QUIET_ZONE);\n }\n }\n\n let frames = '';\n let balls = '';\n for (const [x, y, corner] of finderOrigins(matrix.size)) {\n frames += eyeFramePath(eyeFrameShape, x, y, QUIET_ZONE, corner);\n balls += eyeBallPath(eyeBallShape, x, y, QUIET_ZONE);\n }\n\n return { body: d, frames, balls };\n }, [matrix, logoFraction, moduleShape, eyeFrameShape, eyeBallShape]);\n\n if (error) {\n // A box the size the code would have been, so the layout does not jump\n // when the value becomes something that fits.\n return (\n \n );\n }\n\n if (!matrix) return null;\n\n const grid = matrix.size + QUIET_ZONE * 2;\n\n return (\n \n \n {/* Painted, not left transparent: a scanner needs the quiet zone to\n be lighter than the code, and \"whatever is behind it\" is not.\n Rounded, because at this point it is an object on the card rather\n than a rectangle of paper. */}\n \n \n {/* `evenodd` is what makes the ring a ring: the 5×5 subpath inside\n the 7×7 one is a hole rather than a second filled square. A\n stroke would be centred on its path and put the eye's outer edge\n half a module outside the seven the specification allows, and the\n width of that square is the one measurement a scanner takes\n before it reads anything. */}\n \n \n \n \n );\n }\n);\nQRCodeCanvas.displayName = 'QRCode.Canvas';\n\n/* ------------------------------------------------------------------ *\n * The shells around it.\n * ------------------------------------------------------------------ */\n\nexport interface QRCodeFrameProps extends ViewProps {\n className?: string;\n children?: ReactNode;\n}\n\n/**\n * The tray the code sits in — the same widget shell the charts use: a titled\n * strip, and a card flush inside it holding the thing itself.\n */\nconst QRCodeFrame = forwardRef(\n ({ className, children, ...props }, ref) => {\n const { slots } = useQRCode('QRCode.Frame');\n return (\n \n {children}\n \n );\n }\n);\nQRCodeFrame.displayName = 'QRCode.Frame';\n\nexport interface QRCodeHeaderProps extends ViewProps {\n className?: string;\n children?: ReactNode;\n}\n\n/**\n * The strip across the top of the tray. `QRCode.Title` takes the flexible\n * side and `QRCode.Action` the end, so a long title truncates rather than\n * shoving the trailing slot off the edge.\n */\nconst QRCodeHeader = forwardRef(\n ({ className, children, ...props }, ref) => {\n const { slots } = useQRCode('QRCode.Header');\n return (\n \n {children}\n \n );\n }\n);\nQRCodeHeader.displayName = 'QRCode.Header';\n\nexport interface QRCodeTitleProps {\n className?: string;\n children?: ReactNode;\n}\n\n/** What the code is for. Muted — it is a caption on the tray, not a heading. */\nfunction QRCodeTitle({ className, children }: QRCodeTitleProps) {\n useQRCode('QRCode.Title');\n return (\n \n {children}\n \n );\n}\nQRCodeTitle.displayName = 'QRCode.Title';\n\nexport interface QRCodeActionProps extends ViewProps {\n className?: string;\n children?: ReactNode;\n}\n\n/**\n * The trailing slot on the header row — an expiry, a count, a button. A plain\n * string draws as muted text; anything else draws as itself.\n */\nconst QRCodeAction = forwardRef(\n ({ className, children, ...props }, ref) => {\n useQRCode('QRCode.Action');\n return (\n \n {typeof children === 'string' ? (\n \n {children}\n \n ) : (\n children\n )}\n \n );\n }\n);\nQRCodeAction.displayName = 'QRCode.Action';\n\nexport interface QRCodePanelProps extends ViewProps {\n className?: string;\n children?: ReactNode;\n}\n\n/**\n * The card the code is drawn on, flush inside the tray. It is also what the\n * logo positions itself against, so a `QRCode.Logo` belongs in here beside the\n * canvas rather than anywhere else.\n */\nconst QRCodePanel = forwardRef(\n ({ className, children, ...props }, ref) => {\n const { slots } = useQRCode('QRCode.Panel');\n return (\n \n {children}\n \n );\n }\n);\nQRCodePanel.displayName = 'QRCode.Panel';\n\n/* ------------------------------------------------------------------ *\n * The readouts.\n * ------------------------------------------------------------------ */\n\nexport interface QRCodeDescriptionProps {\n className?: string;\n children?: ReactNode;\n}\n\n/** A muted line inside the panel, under the code. */\nfunction QRCodeDescription({ className, children }: QRCodeDescriptionProps) {\n useQRCode('QRCode.Description');\n return (\n \n {children}\n \n );\n}\nQRCodeDescription.displayName = 'QRCode.Description';\n\nexport interface QRCodeCaptionProps {\n className?: string;\n children?: ReactNode;\n}\n\n/** A line under the code saying what scanning it does. */\nfunction QRCodeCaption({ className, children }: QRCodeCaptionProps) {\n const { slots } = useQRCode('QRCode.Caption');\n return {children};\n}\nQRCodeCaption.displayName = 'QRCode.Caption';\n\nexport interface QRCodeValueProps {\n className?: string;\n /** Show the whole string rather than one line of it. */\n full?: boolean;\n}\n\n/**\n * The encoded string itself, for someone who cannot scan it — typing a URL out\n * is slower than pointing a camera at it and it is the only way through when\n * the camera is the thing you are setting up.\n */\nfunction QRCodeValue({ className, full }: QRCodeValueProps) {\n const { value, slots } = useQRCode('QRCode.Value');\n return (\n \n {value}\n \n );\n}\nQRCodeValue.displayName = 'QRCode.Value';\n\n/* ------------------------------------------------------------------ *\n * The logo.\n * ------------------------------------------------------------------ */\n\n/**\n * How much of the code's width the logo hole takes.\n *\n * Constant rather than a prop, because the value has to be known before the\n * matrix is built — it decides the error-correction level — and a prop on a\n * child cannot be read from the parent without inspecting the tree. A quarter\n * costs about 6% of the modules, which every level above `L` can afford.\n */\nconst LOGO_FRACTION = 0.25;\n\nexport interface QRCodeLogoProps extends ViewProps {\n className?: string;\n children?: ReactNode;\n}\n\n/** Whether the tree contains a `QRCode.Logo`, without walking it deeply. */\nfunction hasLogo(children: ReactNode): boolean {\n let found = false;\n\n const walk = (node: ReactNode) => {\n if (found || !node) return;\n if (Array.isArray(node)) {\n node.forEach(walk);\n return;\n }\n const element = node as ReactElement<{ children?: ReactNode }> & { type?: unknown };\n if (typeof element !== 'object' || !('type' in element)) return;\n if (element.type === QRCodeLogo) {\n found = true;\n return;\n }\n walk(element.props?.children);\n };\n\n walk(children);\n return found;\n}\n\n/**\n * Content for the cleared square in the middle — a mark, an avatar, an icon.\n * Its presence is what clears the square, and what raises the error-correction\n * level if the one asked for could not afford it.\n */\nconst QRCodeLogo = forwardRef(\n ({ className, children, ...props }, ref) => {\n const { size, matrix } = useQRCode('QRCode.Logo');\n if (!matrix) return null;\n\n /*\n * Sized from the same square the canvas cleared, in the same units.\n *\n * Taking a flat 25% of the canvas instead is close, but not equal — the\n * hole is a whole number of modules and the canvas is not necessarily\n * divisible by them, so the two disagreed by a module or so and the\n * difference showed as a dark ring around the logo.\n */\n const { from, to } = holeBounds(matrix.size, LOGO_FRACTION);\n const grid = matrix.size + QUIET_ZONE * 2;\n const box = ((to - from) / grid) * CANVAS_SIZE[size];\n\n return (\n // `inset-0` and centred, not `absolute` with nothing else: an absolute\n // box with no offsets takes its parent's alignment, which put the logo\n // at the top of the panel rather than over the middle of the code.\n \n \n {children}\n \n \n );\n }\n);\nQRCodeLogo.displayName = 'QRCode.Logo';\n\n/* ------------------------------------------------------------------ *\n * Folded away.\n * ------------------------------------------------------------------ */\n\nexport interface QRCodeTriggerProps {\n /** The element that opens it. Must accept `onPress`. */\n children: ReactElement<{ onPress?: (...args: unknown[]) => void }>;\n}\n\n/** What opens a `popover` or `bottom-sheet` code. */\nfunction QRCodeTrigger({ children }: QRCodeTriggerProps) {\n useQRCode('QRCode.Trigger');\n return {children};\n}\nQRCodeTrigger.displayName = 'QRCode.Trigger';\n\nexport interface QRCodeContentProps extends PopoverContentProps {}\n\n/** The panel the code is drawn in, when it is not drawn in place. */\nfunction QRCodeContent({ className, width = 'content-fit', children, ...props }: QRCodeContentProps) {\n const context = useQRCode('QRCode.Content');\n return (\n \n {children}\n \n );\n}\nQRCodeContent.displayName = 'QRCode.Content';\n\nexport const QRCode = Object.assign(QRCodeRoot, {\n Canvas: QRCodeCanvas,\n Frame: QRCodeFrame,\n Header: QRCodeHeader,\n Title: QRCodeTitle,\n Action: QRCodeAction,\n Panel: QRCodePanel,\n Description: QRCodeDescription,\n Caption: QRCodeCaption,\n Value: QRCodeValue,\n Logo: QRCodeLogo,\n Trigger: QRCodeTrigger,\n Content: QRCodeContent,\n});\n" + "content": "/**\n * QRCode — a string, drawn as something a camera can read.\n *\n * ```tsx\n * \n * \n * \n * \n * Scan to open the docs\n * \n * ```\n *\n * Composition is the API, as everywhere else here: a code in a titled panel is\n * one with a `QRCode.Header` in it, and a code behind a button is one wrapped\n * in a `QRCode.Trigger` and a `QRCode.Content` — not one with a prop turned on.\n *\n * ## What it draws\n *\n * Three ``s: the body, the three corner rings, and the three corner\n * centres. Every module is a subpath of one of them, not a `` of its\n * own. A version 10 code is 3,481 modules; half of them dark is seventeen\n * hundred native views for a picture that never changes, and the difference\n * between that and three nodes is the difference between a code that appears\n * and one that appears eventually.\n *\n * Three rather than one because they are the three things worth colouring and\n * shaping separately. A code is found by its corner eyes before a single\n * module is read, so they are the part a design touches first — and the part\n * it is most dangerous to touch badly.\n *\n * The colours come from the theme, so a code drawn on a card is legible on\n * every one of them — and light-on-dark is drawn the way scanners expect it,\n * with the quiet zone painted rather than left transparent. A code with\n * nothing behind it reads at about half the distance.\n *\n * ## Shaping it\n *\n * `moduleShape`, `eyeFrameShape` and `eyeBallShape` on `QRCode.Canvas` change\n * the geometry; `color`, `eyeFrameColor`, `eyeBallColor` and `backgroundColor`\n * change the ink. A scanner samples the centre of each cell, so every shape\n * here reads exactly as a square does — what changes is how much of the cell\n * is covered. `dot` fills about two thirds of it and `diamond` half, which is\n * read distance spent on a look. Raise `errorCorrection` to pay for it, and\n * check a printed code rather than one on a screen.\n *\n * ## The hole in the middle\n *\n * `QRCode.Logo` clears a square of modules and puts its children there.\n * Error correction is what makes that survivable — the modules are gone, and\n * the code still reads because there is enough redundancy to reconstruct them.\n * How much is `errorCorrection`, and a logo raises the level it needs: the\n * default `M` tolerates about 15% loss, `H` about 30%. A logo is measured\n * against that budget and the level is raised for you if it has to be.\n */\nimport {\n createContext,\n forwardRef,\n useContext,\n useMemo,\n type ReactElement,\n type ReactNode,\n} from 'react';\nimport { View, type ViewProps } from 'react-native';\nimport Svg, { Path, Rect } from 'react-native-svg';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport { useCSSVariable } from 'uniwind';\nimport { Text } from '@/components/ui/text';\nimport { cn } from '@/lib/cn';\nimport { Popover, type PopoverContentProps } from '@/components/ui/popover';\nimport { encodeQr, type ErrorCorrectionLevel, type QrMatrix } from '@/components/ui/qr-encode';\nimport {\n eyeBallPath,\n eyeFramePath,\n finderOrigins,\n inFinder,\n modulePath,\n type QRCodeEyeBallShape,\n type QRCodeEyeFrameShape,\n type QRCodeModuleShape,\n} from '@/components/ui/qr-shapes';\nimport type { ViewRef } from '@/components/ui/refs';\n\nexport type { ErrorCorrectionLevel };\nexport type { QRCodeModuleShape, QRCodeEyeFrameShape, QRCodeEyeBallShape };\n\nconst qrCodeVariants = tv({\n slots: {\n root: 'items-center gap-3',\n // The widget shell every chart in the library is shown in: a titled tray\n // with the card flush inside it. A code is the same kind of thing — one\n // object with a label over it — so it is the same shape.\n frame: 'w-full overflow-hidden rounded-3xl border border-border bg-surface',\n header: 'w-full flex-row items-center justify-between gap-3 px-4 pb-3 pt-2.5',\n // Flush left, right and bottom: the shell's own edge is already there.\n panel: 'relative items-center justify-center overflow-hidden rounded-t-2xl border-t border-border bg-card p-4',\n caption: 'text-center text-sm text-muted-foreground',\n value: 'text-center text-xs text-muted-foreground',\n },\n variants: {\n size: {\n sm: {},\n md: {},\n lg: {},\n },\n },\n defaultVariants: {\n size: 'md',\n },\n});\n\ntype QrCodeVariantProps = VariantProps;\n\n/** How big the drawn code is. */\nexport type QRCodeSize = NonNullable;\n\n/** Side length in points per size. The module grid divides into it. */\nconst CANVAS_SIZE: Record = { sm: 128, md: 180, lg: 240 };\n\n/**\n * The quiet zone, in modules. Four is what the specification asks for and what\n * scanners are tuned to; less and a code against a busy background stops being\n * found at all.\n */\nconst QUIET_ZONE = 4;\n\n/** Roughly how much of the code each level can lose and still be read. */\nconst TOLERANCE: Record = { L: 0.07, M: 0.15, Q: 0.25, H: 0.3 };\n\n/** Ascending, so \"the next level up\" is a step through this. */\nconst LEVELS: ErrorCorrectionLevel[] = ['L', 'M', 'Q', 'H'];\n\n/** How much of the code a logo of this fraction covers. */\nfunction coverage(logoFraction: number) {\n return logoFraction * logoFraction;\n}\n\n/**\n * The cleared square, in module coordinates.\n *\n * Both the canvas and the logo compute it from this, so the hole and the thing\n * filling it are the same square. Two nearly-equal calculations is how you get\n * a dark ring around a logo, which is the failure the screenshot showed.\n */\nfunction holeBounds(matrixSize: number, logoFraction: number) {\n const from = Math.floor((matrixSize * (1 - logoFraction)) / 2);\n const to = Math.ceil((matrixSize * (1 + logoFraction)) / 2);\n return { from, to };\n}\n\n/**\n * The level actually used: the one asked for, unless a logo would eat more of\n * the code than it can afford, in which case the next one that can.\n */\nfunction levelFor(requested: ErrorCorrectionLevel, logoFraction: number): ErrorCorrectionLevel {\n const needed = coverage(logoFraction);\n // Half the budget, not all of it: the rest is for the things a code in the\n // world actually loses — glare, a fold, a thumb.\n const affordable = (level: ErrorCorrectionLevel) => TOLERANCE[level] / 2 >= needed;\n\n if (affordable(requested)) return requested;\n return LEVELS.slice(LEVELS.indexOf(requested)).find(affordable) ?? 'H';\n}\n\ninterface QRCodeContextValue {\n matrix: QrMatrix | null;\n /** What went wrong, when there is no matrix. */\n error: string | null;\n value: string;\n size: QRCodeSize;\n /** Side of the logo hole as a fraction of the code, `0` for none. */\n logoFraction: number;\n slots: ReturnType;\n}\n\nconst QRCodeContext = createContext(null);\n\nfunction useQRCode(part: string) {\n const context = useContext(QRCodeContext);\n if (!context) throw new Error(`${part} must be used inside .`);\n return context;\n}\n\n/** Where the code is drawn: in place, in a popover, or up from the bottom. */\nexport type QRCodePresentation = 'inline' | 'popover' | 'bottom-sheet';\n\nexport interface QRCodeProps extends Omit, QrCodeVariantProps {\n className?: string;\n /**\n * What the code encodes. Anything: a URL, a WiFi string, a vCard. Encoded\n * as UTF-8, and the version grows to fit it.\n */\n value: string;\n /**\n * How much of the code can be lost and still read — `L` about 7%, `M` 15%,\n * `Q` 25%, `H` 30%. More correction means a denser code at the same size, so\n * `M` is the default. Raised automatically when a `QRCode.Logo` needs it.\n */\n errorCorrection?: ErrorCorrectionLevel;\n /**\n * Fix the QR version, 1–40, instead of taking the smallest that fits. Worth\n * setting when the content changes and the code should not visibly change\n * density with it.\n */\n version?: number;\n /**\n * Where the code appears. `inline` draws it where it sits; the other two\n * put it behind a `QRCode.Trigger` and draw it in a `QRCode.Content`.\n */\n presentation?: QRCodePresentation;\n /** Controlled open state. Ignored while `presentation` is `inline`. */\n open?: boolean;\n onOpenChange?: (open: boolean) => void;\n children?: ReactNode;\n}\n\nconst QRCodeRoot = forwardRef(\n (\n {\n className,\n value,\n errorCorrection = 'M',\n version,\n size = 'md',\n presentation = 'inline',\n open,\n onOpenChange,\n children,\n ...props\n },\n ref\n ) => {\n const slots = qrCodeVariants({ size });\n\n /*\n * A logo is a child, and the matrix has to know about it before the child\n * renders — the level it forces changes the matrix itself. Rather than\n * inspecting children, the fraction is a constant the Logo part also uses,\n * and the root asks whether one is present at all.\n */\n const logoFraction = hasLogo(children) ? LOGO_FRACTION : 0;\n const level = levelFor(errorCorrection, logoFraction);\n\n const { matrix, error } = useMemo(() => {\n try {\n return { matrix: encodeQr(value, { errorCorrection: level, version }), error: null };\n } catch (cause) {\n // Too much data, or a version too small for it. A throw here would\n // take a screen down over a string, so it draws nothing and says why.\n return { matrix: null, error: (cause as Error).message };\n }\n }, [value, level, version]);\n\n const context: QRCodeContextValue = { matrix, error, value, size, logoFraction, slots };\n\n if (presentation !== 'inline') {\n // The provider stays outside the Popover, and Content re-provides it\n // inside — portal content mounts under the portal host, which is not in\n // this subtree.\n return (\n \n \n {children}\n \n \n );\n }\n\n return (\n \n \n {children}\n \n \n );\n }\n);\nQRCodeRoot.displayName = 'QRCode';\n\n/* ------------------------------------------------------------------ *\n * The code itself.\n * ------------------------------------------------------------------ */\n\nexport interface QRCodeCanvasProps extends Omit {\n className?: string;\n /** Side length in points. Defaults to the size variant's. */\n pixelSize?: number;\n /** Dark modules. See the note below before overriding this. */\n color?: string;\n /** The plate the modules sit on. See the note below. */\n backgroundColor?: string;\n /**\n * How a data module is drawn.\n *\n * `rounded` and `classy` join to their neighbours — a corner is rounded only\n * where both cells touching it are light — so a run reads as one stroke\n * rather than as a string of beads with a light seam through it.\n *\n * `dot` and `diamond` do not tile, and cost read distance for it: `dot`\n * covers about two thirds of its cell and `diamond` exactly half, so the\n * same code is a fainter code at the same size. Raise `errorCorrection` with\n * them, and check a printed one rather than a screen.\n */\n moduleShape?: QRCodeModuleShape;\n /** How the ring around each of the three corner eyes is drawn. */\n eyeFrameShape?: QRCodeEyeFrameShape;\n /** How the square inside each corner eye is drawn. */\n eyeBallShape?: QRCodeEyeBallShape;\n /** The three corner rings. Defaults to `color`. */\n eyeFrameColor?: string;\n /** The three corner centres. Defaults to `eyeFrameColor`, then `color`. */\n eyeBallColor?: string;\n}\n\n/**\n * Dark modules on a light plate, whatever the theme is doing.\n *\n * This is the one place in the library that does not follow the tokens, and it\n * is deliberate. A QR code is not a surface — it is a thing a camera has to\n * read, and readers expect dark on light. Inverted, a code is rejected outright\n * by a good share of scanners and found late by most of the rest, which turns a\n * dark theme into a bug report about a code that \"sometimes does not work\".\n *\n * On a light theme the plate is white on a near-white card, which looks like\n * nothing at all — correct, and the intended outcome. On a dark theme it reads\n * as a light plate holding a code, which is the shape everyone recognises.\n *\n * `color` and `backgroundColor` override both, for a code that is definitely\n * being read by something you control.\n */\nconst PLATE = '#ffffff';\nconst MODULE = '#111111';\n\nconst QRCodeCanvas = forwardRef(\n (\n {\n className,\n pixelSize,\n color,\n backgroundColor,\n moduleShape = 'square',\n eyeFrameShape = 'square',\n eyeBallShape = 'square',\n eyeFrameColor,\n eyeBallColor,\n ...props\n },\n ref\n ) => {\n const { matrix, error, value, size, logoFraction } = useQRCode('QRCode.Canvas');\n\n const side = pixelSize ?? CANVAS_SIZE[size];\n const dark = color ?? MODULE;\n const light = backgroundColor ?? PLATE;\n const frameInk = eyeFrameColor ?? dark;\n const ballInk = eyeBallColor ?? frameInk;\n\n /*\n * Three paths for the whole code: the body, the three corner rings, and\n * the three corner centres.\n *\n * Three rather than one because they are three different things a reader\n * can colour and shape separately — and still three rather than one node\n * per module, which is what a version 10 code would cost: 3,481 cells,\n * half of them dark, seventeen hundred native views for a picture that\n * never changes.\n *\n * The viewBox is in module units, so a shape is written once and scales to\n * whatever `side` is — no arithmetic per module, and no rounding gaps\n * between neighbours at fractional sizes.\n */\n const paths = useMemo(() => {\n if (!matrix) return { body: '', frames: '', balls: '' };\n\n const hole = logoFraction ? holeBounds(matrix.size, logoFraction) : null;\n\n /*\n * The body, as its own matrix rather than as a filter inside the loop.\n * The rounded shapes ask their neighbours whether a corner is free, and\n * the answer has to be about what is *drawn* — a module beside the logo\n * hole or beside an eye has nothing there to join to, and joining to it\n * leaves a squared-off corner facing a gap.\n */\n const body: boolean[][] = [];\n for (let y = 0; y < matrix.size; y++) {\n const row: boolean[] = [];\n for (let x = 0; x < matrix.size; x++) {\n const lit =\n !!matrix.modules[y]![x] &&\n !inFinder(x, y, matrix.size) &&\n // Modules under the logo are not drawn at all — drawing them and\n // covering them leaves a dark edge wherever the logo is smaller\n // than the square it cleared.\n !(hole && x >= hole.from && x < hole.to && y >= hole.from && y < hole.to);\n row.push(lit);\n }\n body.push(row);\n }\n\n let d = '';\n for (let y = 0; y < matrix.size; y++) {\n for (let x = 0; x < matrix.size; x++) {\n if (!body[y]![x]) continue;\n d += modulePath(moduleShape, body, x, y, matrix.size, QUIET_ZONE);\n }\n }\n\n let frames = '';\n let balls = '';\n for (const [x, y, corner] of finderOrigins(matrix.size)) {\n frames += eyeFramePath(eyeFrameShape, x, y, QUIET_ZONE, corner);\n balls += eyeBallPath(eyeBallShape, x, y, QUIET_ZONE);\n }\n\n return { body: d, frames, balls };\n }, [matrix, logoFraction, moduleShape, eyeFrameShape, eyeBallShape]);\n\n if (error) {\n // A box the size the code would have been, so the layout does not jump\n // when the value becomes something that fits.\n return (\n \n );\n }\n\n if (!matrix) return null;\n\n const grid = matrix.size + QUIET_ZONE * 2;\n\n return (\n \n \n {/* Painted, not left transparent: a scanner needs the quiet zone to\n be lighter than the code, and \"whatever is behind it\" is not.\n Rounded, because at this point it is an object on the card rather\n than a rectangle of paper. */}\n \n \n {/* `evenodd` is what makes the ring a ring: the 5×5 subpath inside\n the 7×7 one is a hole rather than a second filled square. A\n stroke would be centred on its path and put the eye's outer edge\n half a module outside the seven the specification allows, and the\n width of that square is the one measurement a scanner takes\n before it reads anything. */}\n \n \n \n \n );\n }\n);\nQRCodeCanvas.displayName = 'QRCode.Canvas';\n\n/* ------------------------------------------------------------------ *\n * The shells around it.\n * ------------------------------------------------------------------ */\n\nexport interface QRCodeFrameProps extends ViewProps {\n className?: string;\n children?: ReactNode;\n}\n\n/**\n * The tray the code sits in — the same widget shell the charts use: a titled\n * strip, and a card flush inside it holding the thing itself.\n */\nconst QRCodeFrame = forwardRef(\n ({ className, children, ...props }, ref) => {\n const { slots } = useQRCode('QRCode.Frame');\n return (\n \n {children}\n \n );\n }\n);\nQRCodeFrame.displayName = 'QRCode.Frame';\n\nexport interface QRCodeHeaderProps extends ViewProps {\n className?: string;\n children?: ReactNode;\n}\n\n/**\n * The strip across the top of the tray. `QRCode.Title` takes the flexible\n * side and `QRCode.Action` the end, so a long title truncates rather than\n * shoving the trailing slot off the edge.\n */\nconst QRCodeHeader = forwardRef(\n ({ className, children, ...props }, ref) => {\n const { slots } = useQRCode('QRCode.Header');\n return (\n \n {children}\n \n );\n }\n);\nQRCodeHeader.displayName = 'QRCode.Header';\n\nexport interface QRCodeTitleProps {\n className?: string;\n children?: ReactNode;\n}\n\n/** What the code is for. Muted — it is a caption on the tray, not a heading. */\nfunction QRCodeTitle({ className, children }: QRCodeTitleProps) {\n useQRCode('QRCode.Title');\n return (\n \n {children}\n \n );\n}\nQRCodeTitle.displayName = 'QRCode.Title';\n\nexport interface QRCodeActionProps extends ViewProps {\n className?: string;\n children?: ReactNode;\n}\n\n/**\n * The trailing slot on the header row — an expiry, a count, a button. A plain\n * string draws as muted text; anything else draws as itself.\n */\nconst QRCodeAction = forwardRef(\n ({ className, children, ...props }, ref) => {\n useQRCode('QRCode.Action');\n return (\n \n {typeof children === 'string' ? (\n \n {children}\n \n ) : (\n children\n )}\n \n );\n }\n);\nQRCodeAction.displayName = 'QRCode.Action';\n\nexport interface QRCodePanelProps extends ViewProps {\n className?: string;\n children?: ReactNode;\n}\n\n/**\n * The card the code is drawn on, flush inside the tray. It is also what the\n * logo positions itself against, so a `QRCode.Logo` belongs in here beside the\n * canvas rather than anywhere else.\n */\nconst QRCodePanel = forwardRef(\n ({ className, children, ...props }, ref) => {\n const { slots } = useQRCode('QRCode.Panel');\n return (\n \n {children}\n \n );\n }\n);\nQRCodePanel.displayName = 'QRCode.Panel';\n\n/* ------------------------------------------------------------------ *\n * The readouts.\n * ------------------------------------------------------------------ */\n\nexport interface QRCodeDescriptionProps {\n className?: string;\n children?: ReactNode;\n}\n\n/** A muted line inside the panel, under the code. */\nfunction QRCodeDescription({ className, children }: QRCodeDescriptionProps) {\n useQRCode('QRCode.Description');\n return (\n \n {children}\n \n );\n}\nQRCodeDescription.displayName = 'QRCode.Description';\n\nexport interface QRCodeCaptionProps {\n className?: string;\n children?: ReactNode;\n}\n\n/** A line under the code saying what scanning it does. */\nfunction QRCodeCaption({ className, children }: QRCodeCaptionProps) {\n const { slots } = useQRCode('QRCode.Caption');\n return {children};\n}\nQRCodeCaption.displayName = 'QRCode.Caption';\n\nexport interface QRCodeValueProps {\n className?: string;\n /** Show the whole string rather than one line of it. */\n full?: boolean;\n}\n\n/**\n * The encoded string itself, for someone who cannot scan it — typing a URL out\n * is slower than pointing a camera at it and it is the only way through when\n * the camera is the thing you are setting up.\n */\nfunction QRCodeValue({ className, full }: QRCodeValueProps) {\n const { value, slots } = useQRCode('QRCode.Value');\n return (\n \n {value}\n \n );\n}\nQRCodeValue.displayName = 'QRCode.Value';\n\n/* ------------------------------------------------------------------ *\n * The logo.\n * ------------------------------------------------------------------ */\n\n/**\n * How much of the code's width the logo hole takes.\n *\n * Constant rather than a prop, because the value has to be known before the\n * matrix is built — it decides the error-correction level — and a prop on a\n * child cannot be read from the parent without inspecting the tree. A quarter\n * costs about 6% of the modules, which every level above `L` can afford.\n */\nconst LOGO_FRACTION = 0.25;\n\nexport interface QRCodeLogoProps extends ViewProps {\n className?: string;\n children?: ReactNode;\n}\n\n/** Whether the tree contains a `QRCode.Logo`, without walking it deeply. */\nfunction hasLogo(children: ReactNode): boolean {\n let found = false;\n\n const walk = (node: ReactNode) => {\n if (found || !node) return;\n if (Array.isArray(node)) {\n node.forEach(walk);\n return;\n }\n const element = node as ReactElement<{ children?: ReactNode }> & { type?: unknown };\n if (typeof element !== 'object' || !('type' in element)) return;\n if (element.type === QRCodeLogo) {\n found = true;\n return;\n }\n walk(element.props?.children);\n };\n\n walk(children);\n return found;\n}\n\n/**\n * Content for the cleared square in the middle — a mark, an avatar, an icon.\n * Its presence is what clears the square, and what raises the error-correction\n * level if the one asked for could not afford it.\n */\nconst QRCodeLogo = forwardRef(\n ({ className, children, ...props }, ref) => {\n const { size, matrix } = useQRCode('QRCode.Logo');\n if (!matrix) return null;\n\n /*\n * Sized from the same square the canvas cleared, in the same units.\n *\n * Taking a flat 25% of the canvas instead is close, but not equal — the\n * hole is a whole number of modules and the canvas is not necessarily\n * divisible by them, so the two disagreed by a module or so and the\n * difference showed as a dark ring around the logo.\n */\n const { from, to } = holeBounds(matrix.size, LOGO_FRACTION);\n const grid = matrix.size + QUIET_ZONE * 2;\n const box = ((to - from) / grid) * CANVAS_SIZE[size];\n\n return (\n // `inset-0` and centred, not `absolute` with nothing else: an absolute\n // box with no offsets takes its parent's alignment, which put the logo\n // at the top of the panel rather than over the middle of the code.\n \n \n {children}\n \n \n );\n }\n);\nQRCodeLogo.displayName = 'QRCode.Logo';\n\n/* ------------------------------------------------------------------ *\n * Folded away.\n * ------------------------------------------------------------------ */\n\nexport interface QRCodeTriggerProps {\n /** The element that opens it. Must accept `onPress`. */\n children: ReactElement<{ onPress?: (...args: unknown[]) => void }>;\n}\n\n/** What opens a `popover` or `bottom-sheet` code. */\nfunction QRCodeTrigger({ children }: QRCodeTriggerProps) {\n useQRCode('QRCode.Trigger');\n return {children};\n}\nQRCodeTrigger.displayName = 'QRCode.Trigger';\n\nexport interface QRCodeContentProps extends PopoverContentProps {}\n\n/** The panel the code is drawn in, when it is not drawn in place. */\nfunction QRCodeContent({ className, width = 'content-fit', children, ...props }: QRCodeContentProps) {\n const context = useQRCode('QRCode.Content');\n return (\n \n {children}\n \n );\n}\nQRCodeContent.displayName = 'QRCode.Content';\n\nexport const QRCode = Object.assign(QRCodeRoot, {\n Canvas: QRCodeCanvas,\n Frame: QRCodeFrame,\n Header: QRCodeHeader,\n Title: QRCodeTitle,\n Action: QRCodeAction,\n Panel: QRCodePanel,\n Description: QRCodeDescription,\n Caption: QRCodeCaption,\n Value: QRCodeValue,\n Logo: QRCodeLogo,\n Trigger: QRCodeTrigger,\n Content: QRCodeContent,\n});\n" }, { "path": "ui/qr-encode.ts", diff --git a/apps/docs/public/r/questionnaire.json b/apps/docs/public/r/questionnaire.json index 03b2663da..89a2abd50 100644 --- a/apps/docs/public/r/questionnaire.json +++ b/apps/docs/public/r/questionnaire.json @@ -8,6 +8,7 @@ "frame", "icons", "input", + "refs", "text" ], "dependencies": [ @@ -22,7 +23,7 @@ { "path": "ui/questionnaire.tsx", "type": "registry:ui", - "content": "/**\n * Questionnaire — one question at a time, with progress, validation and a way\n * back.\n *\n * Unlike `Steps`, which reflects a flow the app owns, Questionnaire *owns* the\n * flow: it holds the answers, decides which question is current, gates the way\n * forward on the current one being answered, and reports the whole set back\n * when it is done. The caller supplies the questions and does something with\n * the answers; everything between those two points belongs here.\n *\n * ```tsx\n * save(answers)}>\n * Project setup\n * \n * \n * What should we build next?\n * Choose one, or write your own.\n * \n * \n * \n * \n * \n * \n * \n * \n * \n * \n * \n * \n * \n * ```\n *\n * ## Why it draws its own Frame\n *\n * A survey is a widget, not a paragraph: it wants a boundary, a title strip and\n * a footer that stays put while the middle changes. That is exactly `Frame`, so\n * the root renders one rather than leaving every caller to assemble the same\n * shell. `Frame.Panel`'s `overflow-hidden` also does the clipping the sliding\n * question needs, for free. Pass `frame={false}` to drop it — for a\n * questionnaire inside a `BottomSheet` or a card that already draws a border.\n *\n * It is the `inset` variant, so the panel the question is written on floats in\n * a recessed band rather than sitting flush in a tray, and the actions go in\n * the band rather than in a section under the question. That separation is the\n * point: the question changes and the row under it does not, and a row drawn\n * on the band is visibly not part of the card that keeps being replaced.\n *\n * The band shapes its actions into equal pills, which is why\n * `Questionnaire.Spacer` is dropped on the way in — see `bandActions`.\n *\n * ## Why the root reads its children instead of collecting registrations\n *\n * Only the active question is mounted, so an unmounted one cannot report that\n * it exists — and without knowing the full set there is no total to count\n * against, no \"is this the last one\", and no way to disable a question the\n * user has not reached. So the root inspects its children once per render and\n * reads `name`, `required`, `multiple` and `disabled` straight off the\n * elements. React elements carry their props before anything renders them,\n * which makes the whole set knowable without mounting any of it.\n *\n * That same pass sorts the parts into the shell: the title and progress go to\n * the header strip above the panel, the footer's actions to the band around\n * it, and everything else is a question.\n *\n * ## Answers are one record, the way a form would submit them\n *\n * `answers[name]` is a string for a single-answer question and an array for a\n * `multiple` one. A freeform answer lands under the same name — it is another\n * answer to the same question, not a separate field — which is why the text\n * input shows whatever value does not match one of the question's own choices.\n * Picking a choice and typing therefore replace each other, without either one\n * having to know the other exists.\n *\n * ## What blocks the way forward\n *\n * A required question blocks until it has an answer. An optional one never\n * blocks. `Questionnaire.Skip` does not unblock anything, then — it *records*\n * that the question was deliberately left out, moving its status from\n * `unanswered` to `skipped` so the app can tell the two apart. Making an\n * optional question demand an explicit skip would trap anyone who did not\n * render the Skip button, and a question that cannot be ignored is not\n * optional.\n */\nimport {\n Children,\n cloneElement,\n createContext,\n forwardRef,\n isValidElement,\n useCallback,\n useContext,\n useEffect,\n useMemo,\n useRef,\n useState,\n type ReactElement,\n type ReactNode,\n} from 'react';\nimport {\n AccessibilityInfo,\n findNodeHandle,\n Pressable,\n View,\n type LayoutChangeEvent,\n type TextInput,\n type ViewProps,\n} from 'react-native';\nimport { Gesture, GestureDetector } from 'react-native-gesture-handler';\nimport Animated, {\n Easing,\n FadeOut,\n runOnJS,\n useAnimatedProps,\n useAnimatedStyle,\n useReducedMotion,\n useSharedValue,\n withSpring,\n withTiming,\n type EntryExitAnimationFunction,\n} from 'react-native-reanimated';\nimport Svg, { Circle } from 'react-native-svg';\nimport { tv } from 'tailwind-variants';\nimport { useCSSVariable } from 'uniwind';\nimport { CheckIcon, ChevronLeftIcon, ChevronRightIcon } from '@/components/ui/icons';\nimport { Text, textChildren } from '@/components/ui/text';\nimport { cn } from '@/lib/cn';\nimport { Button, type ButtonProps } from '@/components/ui/button';\nimport { Frame } from '@/components/ui/frame';\nimport { Input, type InputProps } from '@/components/ui/input';\n\n/** How long the body takes to settle into the next question's height. */\nconst HEIGHT_DURATION = 240;\n\n/** How long the arriving question takes to slide and fade in. */\nconst ENTER_DURATION = 220;\n\n/**\n * How long the leaving one takes to fade out — shorter, so it is gone before\n * the arriving one is fully in and the two are never both legible.\n */\nconst EXIT_DURATION = 140;\n\n/** How long a progress pip takes to fill once its question has been reached. */\nconst PIP_DURATION = 260;\n\n/**\n * The progress ring: its diameter, and the weight of the arc drawn round it.\n *\n * Sized against the title beside it rather than against the strip it sits on.\n * A ring the height of a line of text reads as the trailing half of a pair;\n * one much larger reads as the header's subject, which it is not.\n */\nconst RING_SIZE = 22;\nconst RING_STROKE = 2.5;\n\n/** How far across the body a swipe has to travel before it commits. */\nconst SWIPE_FRACTION = 0.25;\n\n/** Where the sliding question starts from and exits to, as a fraction of width. */\nconst SLIDE_FRACTION = 0.35;\n\nconst EASE = Easing.out(Easing.cubic);\n\n/** What `Questionnaire.Error` says when the caller gives it no message. */\nconst DEFAULT_ERROR = 'Choose an answer to continue.';\n\nconst questionnaireVariants = tv({\n slots: {\n body: 'overflow-hidden',\n // Absolute, so the question measures its own height rather than being\n // clamped by the animated box above it — and so the question leaving and\n // the one arriving overlap instead of stacking during the transition.\n slide: 'absolute inset-x-0 top-0',\n pane: 'gap-4',\n choices: 'gap-2.5',\n footer: 'flex-row items-center gap-2',\n error: 'text-sm text-destructive',\n },\n variants: {\n /**\n * With the frame off, only the vertical rhythm is the questionnaire's —\n * the container it was placed in is already holding it off the edges, and\n * insetting it again would inset it twice.\n */\n framed: {\n true: { pane: 'p-4', footer: 'p-3' },\n false: { pane: 'py-4', footer: 'pt-3' },\n },\n },\n defaultVariants: {\n framed: true,\n },\n});\n\nconst choiceVariants = tv({\n slots: {\n row: 'w-full flex-row items-start gap-3 rounded-xl border border-border bg-card px-3.5 py-3',\n /*\n * `mt-px` against a `leading-snug` first line: the indicator is centred on\n * the label's cap height rather than on its line box, which is where the\n * eye reads the two as being on the same line.\n */\n indicator:\n 'mt-px h-5 w-5 shrink-0 items-center justify-center border border-input bg-background',\n label: 'text-base font-medium leading-snug text-foreground',\n description: 'text-sm leading-snug text-muted-foreground',\n shortcut:\n 'mt-px h-5 min-w-[22px] shrink-0 items-center justify-center rounded-md border border-border bg-muted px-1',\n shortcutLabel: 'text-[11px] font-medium tabular-nums text-muted-foreground',\n },\n variants: {\n /** A disc for one-of, a rounded square for many-of — the usual grammar. */\n multiple: {\n true: { indicator: 'rounded-md' },\n false: { indicator: 'rounded-full' },\n },\n selected: {\n true: {\n row: 'border-primary bg-accent',\n // The badge follows the row rather than staying grey against a filled\n // surface, where it would read as the one part that did not respond.\n shortcut: 'border-primary/40 bg-primary/10',\n shortcutLabel: 'text-primary',\n },\n },\n disabled: {\n true: { row: 'opacity-50' },\n },\n /** The question failed validation, so every answer in it reads as at fault. */\n invalid: {\n true: { row: 'border-destructive' },\n },\n },\n defaultVariants: {\n multiple: false,\n },\n});\n\n/**\n * How many questions can be drawn as pips before the count is the clearer\n * thing. Past this they stop being countable at a glance and become a texture.\n */\nconst MAX_PIPS = 8;\n\n/** Where a question stands: never touched, answered, or deliberately left out. */\nexport type QuestionnaireItemStatus = 'unanswered' | 'answered' | 'skipped';\n\n/** Which key each answer is badged with. */\nexport type QuestionnaireShortcutMode = 'letters' | 'numbers';\n\n/** Every answer given so far, keyed by question name. */\nexport type QuestionnaireAnswers = Record;\n\n/**\n * A question, described rather than rendered. Pass these as `items` so the\n * questionnaire knows its full set before any of it mounts — which is what\n * makes a conditional question countable and a total meaningful.\n */\nexport interface QuestionnaireItemDefinition {\n /** Unique name — the key this question's answer is stored under. */\n name: string;\n /** Blocks the way forward until it has an answer. */\n required?: boolean;\n /** Accepts more than one answer, so its answer is an array. */\n multiple?: boolean;\n /** Left out of the count and never navigated to. */\n disabled?: boolean;\n}\n\n/** The whole set, as everything downstream sees it. */\ninterface ResolvedItem extends QuestionnaireItemDefinition {\n element: ReactElement;\n onStatusChange?: (status: QuestionnaireItemStatus) => void;\n}\n\ninterface QuestionnaireContextValue {\n /** One-based position of the active question among the enabled ones. */\n current: number;\n /** How many questions are enabled. */\n total: number;\n first: boolean;\n last: boolean;\n activeName: string | null;\n activeItem: ResolvedItem | null;\n answers: QuestionnaireAnswers;\n statusOf: (name: string) => QuestionnaireItemStatus;\n invalid: ReadonlySet;\n setAnswer: (name: string, value: string | string[] | undefined) => void;\n toggleAnswer: (name: string, value: string, multiple: boolean) => void;\n goNext: () => void;\n goBack: () => void;\n skip: () => void;\n submit: () => void;\n shortcuts: QuestionnaireShortcutMode | null;\n /** Whether the root drew the frame, which decides who owns the insets. */\n framed: boolean;\n /**\n * The question is drawn on the header strip rather than in the panel, so\n * `Questionnaire.Question` renders nothing where it stands.\n */\n questionOnStrip: boolean;\n /** The active question is required and has no answer, so the way on is shut. */\n blocked: boolean;\n}\n\nconst QuestionnaireContext = createContext(null);\n\nfunction useQuestionnaire(component: string): QuestionnaireContextValue {\n const context = useContext(QuestionnaireContext);\n if (!context) {\n throw new Error(`${component} must be used within a `);\n }\n return context;\n}\n\ninterface QuestionnaireItemContextValue {\n name: string;\n required: boolean;\n multiple: boolean;\n invalid: boolean;\n /** Every fixed value this question offers — what tells a typed answer apart. */\n choiceValues: ReadonlySet;\n}\n\nconst QuestionnaireItemContext = createContext(null);\n\nfunction useQuestionnaireItem(component: string): QuestionnaireItemContextValue {\n const context = useContext(QuestionnaireItemContext);\n if (!context) {\n throw new Error(`${component} must be used within a `);\n }\n return context;\n}\n\n/** The shortcut `Questionnaire.Choices` hands the choice it wraps. */\nconst ShortcutContext = createContext(null);\n\n/** True when the question has an answer of some kind. */\nfunction isAnswered(value: string | string[] | undefined): boolean {\n if (Array.isArray(value)) return value.length > 0;\n return typeof value === 'string' && value.trim().length > 0;\n}\n\n/**\n * The footer's actions, on their way into the band.\n *\n * `Frame.Footer` shapes its *direct* children into the band's pills, so the\n * row itself has to be unwrapped — handed the `Questionnaire.Footer` element\n * whole it would be the wrapper that came out a pill, with the buttons\n * untouched inside it.\n *\n * The spacer goes at the same time. It exists to push the actions apart in a\n * row that lays them out at their own widths; in a band where every action is\n * already a share of the row it would take a share of its own, and the buttons\n * would come out narrower the more carefully the caller had spaced them.\n *\n * The band's own rule is equal widths, and it is the wrong one here. A row of\n * equal pills says these are equal decisions; Back and Skip are ways off the\n * path and Next is the path, so they are sized to their labels and the primary\n * action takes what is left. Equal thirds also truncate it — \"Continue\" does\n * not fit in a third of the band, and the button that carries the flow is the\n * worst one to lose the end of.\n *\n * `flex-none` and nothing else on those two: the button's own padding is what\n * a label needs either side of it, and anything added here comes off the\n * primary action, which has the longest label and the least room to lose.\n *\n * The primary action goes up one size instead — `lg`, not the default. It is\n * the only thing on the band anybody is aiming for: the whole widget is one\n * decision repeated, and this is the button that takes the reader to the next\n * one, so it carries a larger label and a taller box than the way back beside\n * it. One size and no more; `xl` is for a button that is the whole of a\n * screen, and this one shares its row. The band's fixed height comes off with\n * it, or the taller box would be clamped back to the row's.\n */\nfunction bandActions(footer: ReactNode): ReactNode {\n if (!isValidElement(footer)) return footer;\n return Children.toArray(footer.props.children).flatMap((child) => {\n if (!isValidElement(child)) return [child];\n if (child.type === QuestionnaireSpacer) return [];\n\n // Merged after the band's own classes rather than before, so what is set\n // here is what survives.\n if (child.type === QuestionnaireBack || child.type === QuestionnaireSkip) {\n return [cloneElement(child, { className: cn('flex-none', child.props.className) })];\n }\n\n return [\n cloneElement(child, {\n // A caller who asked for a size meant it.\n size: child.props.size ?? 'lg',\n className: cn('h-auto', child.props.className),\n }),\n ];\n });\n}\n\n/**\n * The text of a question's `Questionnaire.Question`, for the header strip.\n *\n * Read off the element rather than reported up by the mounted question: only\n * the active one is mounted, and the strip has to be drawn in the same render\n * as the pane it labels — a value arriving from below would land one frame\n * late, which on a question change is a header still naming the question that\n * has just left.\n */\nfunction questionOf(item: ReactElement | undefined): ReactNode {\n if (!item) return null;\n let found: ReactNode = null;\n Children.forEach(item.props.children, (child) => {\n if (found != null) return;\n if (!isValidElement(child)) return;\n if (child.type === QuestionnaireQuestion) found = child.props.children;\n });\n return found;\n}\n\n/** The letter or number an answer at this position is badged with. */\nfunction shortcutAt(mode: QuestionnaireShortcutMode, index: number): string | null {\n if (mode === 'numbers') return index < 9 ? String(index + 1) : null;\n return index < 26 ? String.fromCharCode(65 + index) : null;\n}\n\nexport interface QuestionnaireProps extends Omit {\n className?: string;\n /**\n * The full set of questions, in order. Optional: without it the order and\n * the totals come from the `Questionnaire.Item` children instead. Pass it\n * when a question is conditional, since a question the user has not reached\n * still has to be counted — or not counted, if it no longer applies.\n */\n items?: readonly QuestionnaireItemDefinition[];\n /** Controlled active question, by name. */\n item?: string;\n /** Which question to open on. Defaults to the first enabled one. */\n defaultItem?: string;\n /** Called with the name of the question being moved to. */\n onItemChange?: (name: string) => void;\n /** Controlled answers. */\n answers?: QuestionnaireAnswers;\n /** Answers to start with — for resuming a part-finished questionnaire. */\n defaultAnswers?: QuestionnaireAnswers;\n /** Called with the whole set every time any answer changes. */\n onAnswersChange?: (answers: QuestionnaireAnswers) => void;\n /** Called with every answer once the last question validates. */\n onSubmit?: (answers: QuestionnaireAnswers) => void;\n /**\n * Badge every answer with a letter (`A`, `B`, `C`) or a number (`1`, `2`,\n * `3`). Disabled answers are skipped rather than taking a badge with them.\n *\n * The badge is an affordance, not a binding: React Native surfaces hardware\n * key events only to a focused text field, so nothing here can listen for\n * the key itself.\n */\n shortcuts?: QuestionnaireShortcutMode;\n /**\n * Let a horizontal drag move between questions. Going forward is gated on\n * the same answer the button is, so a swipe off an unanswered required\n * question springs back and shows its error.\n */\n swipeable?: boolean;\n /**\n * Draw the surrounding `Frame`. Turn it off to place the questionnaire in a\n * sheet, a dialog or a card that already draws its own boundary.\n */\n frame?: boolean;\n children?: ReactNode;\n}\n\nfunction QuestionnaireRoot({\n className,\n items,\n item: itemProp,\n defaultItem,\n onItemChange,\n answers: answersProp,\n defaultAnswers,\n onAnswersChange,\n onSubmit,\n shortcuts,\n swipeable = true,\n frame = true,\n children,\n ...props\n}: QuestionnaireProps) {\n /*\n * One pass over the children does two jobs: it sorts the parts into the\n * shell's three regions, and it reads the questions' props off the elements\n * so the set is known without mounting any of it.\n */\n const { titleNode, progressNode, footerNode, elements } = useMemo(() => {\n let title: ReactNode = null;\n let progress: ReactNode = null;\n let footer: ReactNode = null;\n const found: ReactElement[] = [];\n\n Children.forEach(children, (child) => {\n if (!isValidElement(child)) return;\n if (child.type === QuestionnaireTitle) title = child;\n else if (child.type === QuestionnaireProgress) progress = child;\n else if (child.type === QuestionnaireFooter) footer = child;\n else if (child.type === QuestionnaireItem) {\n found.push(child as ReactElement);\n }\n });\n\n return { titleNode: title, progressNode: progress, footerNode: footer, elements: found };\n }, [children]);\n\n /*\n * `items` decides the order when it is given, because a question that has\n * not rendered still has to hold its place in the count. A question's own\n * props win over the definition wherever both say something, so a\n * conditional `disabled` can be computed at the point it is rendered.\n */\n const resolved = useMemo(() => {\n const byName = new Map(elements.map((element) => [element.props.name, element]));\n\n const merge = (\n definition: QuestionnaireItemDefinition,\n element: ReactElement | undefined\n ): ResolvedItem | null => {\n if (!element) return null;\n const p = element.props;\n return {\n name: definition.name,\n required: p.required ?? definition.required,\n multiple: p.multiple ?? definition.multiple,\n disabled: p.disabled ?? definition.disabled,\n onStatusChange: p.onStatusChange,\n element,\n };\n };\n\n if (items?.length) {\n return items\n .map((definition) => merge(definition, byName.get(definition.name)))\n .filter((entry): entry is ResolvedItem => entry !== null);\n }\n\n return elements.map((element) => ({\n name: element.props.name,\n required: element.props.required,\n multiple: element.props.multiple,\n disabled: element.props.disabled,\n onStatusChange: element.props.onStatusChange,\n element,\n }));\n }, [items, elements]);\n\n /** The ones that count: disabled questions are neither shown nor tallied. */\n const enabled = useMemo(() => resolved.filter((entry) => !entry.disabled), [resolved]);\n\n const [internalItem, setInternalItem] = useState(defaultItem ?? null);\n const [internalAnswers, setInternalAnswers] = useState(\n () => defaultAnswers ?? {}\n );\n const [skipped, setSkipped] = useState>(() => new Set());\n const [invalid, setInvalid] = useState>(() => new Set());\n\n const isItemControlled = itemProp !== undefined;\n const isAnswersControlled = answersProp !== undefined;\n const answers = isAnswersControlled ? answersProp : internalAnswers;\n\n /*\n * Falling back to the first enabled question rather than storing it means a\n * questionnaire whose first question becomes disabled moves off it by\n * itself, instead of sitting on a question it has been told not to show.\n */\n const requested = isItemControlled ? itemProp : internalItem;\n const activeIndex = Math.max(\n 0,\n enabled.findIndex((entry) => entry.name === requested)\n );\n const activeItem = enabled[activeIndex] ?? null;\n const activeName = activeItem?.name ?? null;\n\n const total = enabled.length;\n const current = total === 0 ? 0 : activeIndex + 1;\n const first = activeIndex === 0;\n const last = total === 0 || activeIndex === total - 1;\n\n const statusOf = useCallback(\n (name: string): QuestionnaireItemStatus => {\n if (isAnswered(answers[name])) return 'answered';\n if (skipped.has(name)) return 'skipped';\n return 'unanswered';\n },\n [answers, skipped]\n );\n\n /*\n * Reported from here rather than from the question itself: only the active\n * question is mounted, and skipping one is immediately followed by leaving\n * it, so an effect inside it would be racing its own unmount.\n */\n const emitStatus = useCallback(\n (name: string, status: QuestionnaireItemStatus) => {\n resolved.find((entry) => entry.name === name)?.onStatusChange?.(status);\n },\n [resolved]\n );\n\n const commitAnswers = useCallback(\n (next: QuestionnaireAnswers) => {\n if (!isAnswersControlled) setInternalAnswers(next);\n onAnswersChange?.(next);\n },\n [isAnswersControlled, onAnswersChange]\n );\n\n const setAnswer = useCallback(\n (name: string, value: string | string[] | undefined) => {\n const next = { ...answers };\n if (value === undefined || (Array.isArray(value) && value.length === 0)) {\n delete next[name];\n } else {\n next[name] = value;\n }\n\n commitAnswers(next);\n\n // Answering clears both a recorded skip and a failed validation: the\n // reason for either has just stopped being true.\n setSkipped((previous) => {\n if (!previous.has(name)) return previous;\n const copy = new Set(previous);\n copy.delete(name);\n return copy;\n });\n setInvalid((previous) => {\n if (!previous.has(name)) return previous;\n const copy = new Set(previous);\n copy.delete(name);\n return copy;\n });\n\n emitStatus(name, isAnswered(next[name]) ? 'answered' : 'unanswered');\n },\n [answers, commitAnswers, emitStatus]\n );\n\n const toggleAnswer = useCallback(\n (name: string, value: string, multiple: boolean) => {\n if (!multiple) {\n // Pressing the selected answer again clears it, which is the only way\n // to undo an answer to an optional question without a Skip button.\n setAnswer(name, answers[name] === value ? undefined : value);\n return;\n }\n\n const currentValue = answers[name];\n const list = Array.isArray(currentValue) ? currentValue : [];\n setAnswer(\n name,\n list.includes(value) ? list.filter((entry) => entry !== value) : [...list, value]\n );\n },\n [answers, setAnswer]\n );\n\n const moveTo = useCallback(\n (index: number) => {\n const target = enabled[index];\n if (!target) return;\n if (!isItemControlled) setInternalItem(target.name);\n onItemChange?.(target.name);\n },\n [enabled, isItemControlled, onItemChange]\n );\n\n /** A required question is the only thing that blocks. */\n const validate = useCallback(\n (entry: ResolvedItem | null): boolean => {\n if (!entry || !entry.required) return true;\n if (isAnswered(answers[entry.name])) return true;\n setInvalid((previous) => new Set(previous).add(entry.name));\n return false;\n },\n [answers]\n );\n\n const goNext = useCallback(() => {\n if (!validate(activeItem)) return;\n moveTo(activeIndex + 1);\n }, [validate, activeItem, moveTo, activeIndex]);\n\n // Going back never validates: the way out of a question you cannot answer\n // must not be the same door you came in by.\n const goBack = useCallback(() => moveTo(activeIndex - 1), [moveTo, activeIndex]);\n\n const skip = useCallback(() => {\n if (!activeItem || activeItem.required) return;\n setAnswer(activeItem.name, undefined);\n setSkipped((previous) => new Set(previous).add(activeItem.name));\n emitStatus(activeItem.name, 'skipped');\n if (!last) moveTo(activeIndex + 1);\n }, [activeItem, setAnswer, emitStatus, last, moveTo, activeIndex]);\n\n const submit = useCallback(() => {\n const failed = enabled.filter((entry) => entry.required && !isAnswered(answers[entry.name]));\n if (failed.length > 0) {\n setInvalid((previous) => {\n const copy = new Set(previous);\n failed.forEach((entry) => copy.add(entry.name));\n return copy;\n });\n // Take them to the first question that is missing an answer rather than\n // leaving them on the last one wondering which of the others it was.\n const index = enabled.findIndex((entry) => entry.name === failed[0]!.name);\n if (index !== activeIndex) moveTo(index);\n return;\n }\n onSubmit?.(answers);\n }, [enabled, answers, activeIndex, moveTo, onSubmit]);\n\n /*\n * Whether the way on is shut, as against whether it has been *tried* — the\n * error under the question needs somebody to have pressed the button, but\n * the button's own look must not, or it would read as ready right up until\n * it refused.\n */\n const blocked = !!activeItem?.required && !isAnswered(answers[activeItem.name]);\n\n /*\n * With no title the header strip has nothing on it but the ring, and a ring\n * centred on an empty strip is a widget that will not say what it is asking.\n * The question stands in for the title instead — it is the only line that\n * names the thing, and a questionnaire that has not been given a title is\n * one where the question is the title.\n *\n * It takes the title's place exactly, at the leading edge with the ring\n * still at the trailing one, so a titled questionnaire and an untitled one\n * draw the same strip. Two arrangements of the same two things would make\n * the header look like it meant something different in each.\n *\n * The trade is that the question no longer travels with the pane: the strip\n * stays put while the answers slide under it. That is the right way round\n * for a label, and the wrong way round for a question that is meant to\n * arrive with its answers — which is why giving it a title turns this off.\n */\n const questionOnStrip = frame && !titleNode;\n const stripQuestion = useMemo(\n () => (questionOnStrip ? questionOf(activeItem?.element) : null),\n [questionOnStrip, activeItem]\n );\n\n const context = useMemo(\n () => ({\n current,\n total,\n first,\n last,\n activeName,\n activeItem,\n answers,\n statusOf,\n invalid,\n setAnswer,\n toggleAnswer,\n goNext,\n goBack,\n skip,\n submit,\n shortcuts: shortcuts ?? null,\n framed: frame,\n questionOnStrip,\n blocked,\n }),\n [\n current,\n total,\n first,\n last,\n activeName,\n activeItem,\n answers,\n statusOf,\n invalid,\n setAnswer,\n toggleAnswer,\n goNext,\n goBack,\n skip,\n submit,\n shortcuts,\n frame,\n questionOnStrip,\n blocked,\n ]\n );\n\n const body = (\n \n );\n\n /*\n * A little more room under the strip than the `inset` header takes by\n * default. That default is set for a line of text; the ring is a shape whose\n * stroke reaches the edge of its box, with none of the leading a line of\n * text has, so against the same padding it sits closer to the panel than the\n * title beside it does.\n *\n * With no title, the progress centres rather than staying hard right. Right\n * is where it belongs when it is the trailing half of a pair; on its own at\n * the end of an otherwise empty strip it reads as something left over\n * rather than as the strip's subject.\n */\n const header = (\n \n {titleNode ??\n textChildren(stripQuestion, (text) => (\n // One line, and it truncates rather than wrapping: the strip is a\n // label for the panel under it, and a label that grows to two lines\n // pushes the answers down the screen every time a longer question\n // comes round.\n \n {text}\n \n ))}\n {progressNode ? {progressNode} : null}\n \n );\n\n return (\n \n {frame ? (\n /*\n * The panel floats in a recessed band rather than sitting flush in a\n * tray, and the band is where the actions go. A questionnaire is a\n * screen's worth of one decision repeated — answer, then move — and\n * the band draws that row as equal pills clear of the card the\n * question is written on, which is the distinction the old footer\n * section inside the panel did not make.\n */\n \n {header}\n {body}\n {footerNode ? (\n // Tighter than the band's own gap. That one is set for two or\n // three equal pills; this row is three actions of three different\n // widths, and every point between them comes off the longest label.\n {bandActions(footerNode)}\n ) : null}\n \n ) : (\n \n {titleNode || progressNode ? (\n // The same rule as the framed strip: paired with a title it sits\n // at the trailing edge, alone it centres.\n \n {titleNode}\n {progressNode}\n \n ) : null}\n {body}\n {footerNode}\n \n )}\n \n );\n}\nQuestionnaireRoot.displayName = 'Questionnaire';\n\ninterface QuestionnaireBodyProps {\n activeName: string | null;\n activeItem: ResolvedItem | null;\n /** Position of the active question, which is what says which way a move went. */\n activeIndex: number;\n framed: boolean;\n swipeable: boolean;\n canAdvance: boolean;\n canRetreat: boolean;\n onNext: () => void;\n onBack: () => void;\n}\n\n/**\n * The middle of the questionnaire: the one mounted question, the height it\n * animates to, and the drag that moves between them.\n */\nfunction QuestionnaireBody({\n activeName,\n activeItem,\n activeIndex,\n framed,\n swipeable,\n canAdvance,\n canRetreat,\n onNext,\n onBack,\n}: QuestionnaireBodyProps) {\n const slots = questionnaireVariants({ framed });\n\n const [width, setWidth] = useState(0);\n // -1 means nothing has been measured yet, so the first question takes its\n // height outright instead of growing into it from nothing.\n const height = useSharedValue(-1);\n const drag = useSharedValue(0);\n const paneRef = useRef(null);\n\n /*\n * Until the first question has been measured the pane stays in the flow, so\n * the body has a real height from the moment it first lays out. Absolute\n * from the start would measure zero on that first pass — the container's\n * only child would contribute nothing to it — and anything sizing itself to\n * this content would take the zero and keep it. A sheet set to wrap its\n * content is exactly that, and it would open around a question nobody can\n * see. Once the height is known the pane goes absolute, which is what lets\n * the question leaving and the one arriving overlap.\n */\n const [measured, setMeasured] = useState(false);\n\n /*\n * Which way the question slides in from, worked out from the move itself\n * rather than from whatever triggered it. A button, a swipe and a caller\n * setting `item` directly are all the same move to the reader, and only the\n * change in position says which direction it went.\n *\n * Read during render, not in an effect: the arriving question's animation is\n * fixed when it mounts, and an effect runs after that — which would leave\n * every transition playing the direction of the one before it.\n */\n const previousIndex = useRef(activeIndex);\n const direction: 1 | -1 = activeIndex >= previousIndex.current ? 1 : -1;\n const navigated = useRef(false);\n\n useEffect(() => {\n if (activeIndex !== previousIndex.current) {\n previousIndex.current = activeIndex;\n navigated.current = true;\n }\n }, [activeIndex]);\n\n const navigate = useCallback(\n (delta: 1 | -1) => {\n if (delta === 1) onNext();\n else onBack();\n },\n [onNext, onBack]\n );\n\n useEffect(() => {\n drag.value = 0;\n }, [activeName, drag]);\n\n // Move the reader onto the question that just arrived, so a screen reader\n // reads the new question rather than leaving focus where the button was.\n useEffect(() => {\n if (!navigated.current) return;\n const node = paneRef.current;\n if (!node) return;\n const tag = findNodeHandle(node);\n if (tag != null) AccessibilityInfo.setAccessibilityFocus(tag);\n }, [activeName]);\n\n const onPaneLayout = useCallback(\n (event: LayoutChangeEvent) => {\n const next = event.nativeEvent.layout.height;\n if (next <= 0) return;\n if (measured) {\n height.value = withTiming(next, { duration: HEIGHT_DURATION, easing: EASE });\n return;\n }\n // The first measurement is taken outright: there is no previous height\n // to travel from, and animating in from nothing is a question that\n // unfurls on arrival rather than one that is simply there.\n height.value = next;\n setMeasured(true);\n },\n [measured, height]\n );\n\n const heightStyle = useAnimatedStyle(() =>\n height.value < 0 ? {} : { height: height.value }\n );\n\n const dragStyle = useAnimatedStyle(() => ({\n transform: [{ translateX: drag.value }],\n }));\n\n const pan = useMemo(\n () =>\n Gesture.Pan()\n // A vertical scroll must always win: the questionnaire sits in a page\n // that scrolls, and a drag that is even slightly vertical belongs to it.\n .activeOffsetX([-12, 12])\n .failOffsetY([-8, 8])\n .enabled(swipeable && width > 0)\n .onUpdate((event) => {\n const forward = event.translationX < 0;\n if ((forward && !canAdvance) || (!forward && !canRetreat)) {\n // Nowhere to go this way — let it move a little so the drag is\n // acknowledged, then stop.\n drag.value = event.translationX * 0.2;\n return;\n }\n drag.value = event.translationX;\n })\n .onEnd((event) => {\n const threshold = width * SWIPE_FRACTION;\n if (event.translationX < -threshold && canAdvance) {\n runOnJS(navigate)(1);\n } else if (event.translationX > threshold && canRetreat) {\n runOnJS(navigate)(-1);\n }\n // Springs back either way. When the move is taken the question is\n // replaced outright, so this only ever shows on a move that was not.\n drag.value = withSpring(0, { damping: 20, stiffness: 220, mass: 0.6 });\n }),\n [swipeable, width, canAdvance, canRetreat, drag, navigate]\n );\n\n /*\n * The question arrives from the side it is coming from, over a fraction of\n * the width rather than the whole of it — this is a widget in a page, not a\n * screen, and a slide the full width of it reads as the page moving.\n *\n * Written out rather than assembled from the stock builders because those\n * carry an initial opacity and nothing else: the distance is the part worth\n * controlling here, and none of them lets it be set.\n */\n const offset = width > 0 ? width * SLIDE_FRACTION : 60;\n const entering = useCallback(() => {\n 'worklet';\n return {\n initialValues: { opacity: 0, transform: [{ translateX: direction * offset }] },\n animations: {\n opacity: withTiming(1, { duration: ENTER_DURATION, easing: EASE }),\n transform: [{ translateX: withTiming(0, { duration: ENTER_DURATION, easing: EASE }) }],\n },\n };\n }, [direction, offset]);\n\n const exiting = FadeOut.duration(EXIT_DURATION);\n\n return (\n \n setWidth(event.nativeEvent.layout.width)}\n >\n {/*\n * Two views, not one: the entering animation drives this one's\n * transform, so the drag needs a view of its own underneath it rather\n * than a second transform on the same style the animation is writing.\n */}\n \n \n \n {activeItem?.element ?? null}\n \n \n \n \n \n );\n}\n\nexport interface QuestionnaireTitleProps extends ViewProps {\n className?: string;\n children?: ReactNode;\n}\n\n/**\n * Names the questionnaire as a whole, in the frame's header strip. The\n * current question's own prompt is `Questionnaire.Question`.\n */\nfunction QuestionnaireTitle({ className, children, ...props }: QuestionnaireTitleProps) {\n return (\n \n {textChildren(children, (text) => (\n \n {text}\n \n ))}\n \n );\n}\nQuestionnaireTitle.displayName = 'Questionnaire.Title';\n\n/** What a custom progress indicator is told about where the reader is. */\nexport interface QuestionnaireProgressState {\n /** One-based position of the active question. */\n current: number;\n /** How many questions are enabled. */\n total: number;\n first: boolean;\n last: boolean;\n}\n\n/** How the position is drawn. */\nexport type QuestionnaireProgressVariant = 'ring' | 'pips' | 'numbers' | 'count';\n\nexport interface QuestionnaireProgressProps {\n className?: string;\n /**\n * `ring` is an arc that sweeps round as the reader advances — how far\n * through the set they are, without saying how many questions there are.\n * It is the only one that holds its size and its meaning at any length,\n * which is why it is the default.\n *\n * `pips` is a bar per question, filled up to the one being asked and widened\n * on it. `numbers` counts them out instead, which is what you want when the\n * reader will be sent back to a particular question. `count` is the plain\n * `Question 2 of 5`.\n *\n * `pips` and `numbers` fall back to `count` past eight questions, where\n * neither is countable at a glance any more. `ring` never does.\n */\n variant?: QuestionnaireProgressVariant;\n /**\n * Replace the indicator entirely. Given a function, it is called with the\n * position — for a bar, a row of dots, or a percentage.\n */\n children?: ReactNode | ((state: QuestionnaireProgressState) => ReactNode);\n}\n\nconst AnimatedCircle = Animated.createAnimatedComponent(Circle);\n\n/**\n * How far through the set, as an arc.\n *\n * A fraction rather than a count, which is what lets one ring stand for three\n * questions or thirty: the marks that name each question individually stop\n * being countable somewhere around eight, and this never has that problem.\n * What it gives up is any answer to \"how many left\" — reach for `numbers`\n * where that matters.\n *\n * Drawn with `strokeDasharray` rather than `strokeDashoffset`: a dash the\n * length of the filled arc and a gap the length of the rest leaves exactly one\n * visible stroke, and its length is the only number that has to be animated.\n * The circle is turned back a quarter because a stroke starts at three\n * o'clock, and an arc that begins there reads as a gauge already part-way\n * along.\n */\nfunction ProgressRing({ current, total }: { current: number; total: number }) {\n const reduceMotion = useReducedMotion();\n const tokens = useCSSVariable(['--color-primary', '--color-border']);\n // Narrowed on the way out because `useCSSVariable` resolves to a number for\n // any token that happens to be one.\n const fillColor = typeof tokens[0] === 'string' ? tokens[0] : 'rgb(120,120,255)';\n const trackColor = typeof tokens[1] === 'string' ? tokens[1] : 'rgba(128,128,128,0.2)';\n\n const radius = (RING_SIZE - RING_STROKE) / 2;\n const circumference = 2 * Math.PI * radius;\n const centre = RING_SIZE / 2;\n\n const fraction = total > 0 ? Math.min(Math.max(current / total, 0), 1) : 0;\n const filled = useSharedValue(fraction);\n\n useEffect(() => {\n filled.value = reduceMotion\n ? fraction\n : withTiming(fraction, { duration: PIP_DURATION, easing: EASE });\n }, [fraction, reduceMotion, filled]);\n\n const arc = useAnimatedProps(() => ({\n strokeDasharray: [circumference * filled.value, circumference],\n }));\n\n return (\n \n \n \n \n );\n}\n\n/**\n * One question's worth of the track. Filled once it has been reached, and the\n * one being asked is drawn wider than the rest so the reader's place in the\n * set is legible without counting.\n */\nfunction ProgressPip({ filled, active }: { filled: boolean; active: boolean }) {\n const fill = useSharedValue(filled ? 1 : 0);\n\n useEffect(() => {\n fill.value = withTiming(filled ? 1 : 0, { duration: PIP_DURATION, easing: EASE });\n }, [filled, fill]);\n\n const fillStyle = useAnimatedStyle(() => ({ opacity: fill.value }));\n\n return (\n \n \n \n );\n}\n\n/**\n * The same position, counted out. A number says which question this is in a\n * way a bar cannot — worth it where the reader is going to be asked to go back\n * to one of them, since a bar gives them nothing to go back *to*.\n */\nfunction ProgressNumber({\n value,\n done,\n active,\n}: {\n value: number;\n done: boolean;\n active: boolean;\n}) {\n return (\n \n \n {value}\n \n \n );\n}\n\n/** Where the reader is in the set, announced as a progress bar. */\nfunction QuestionnaireProgress({\n className,\n variant = 'ring',\n children,\n}: QuestionnaireProgressProps) {\n const { current, total, first, last } = useQuestionnaire('Questionnaire.Progress');\n const label = `Question ${current} of ${total}`;\n\n /*\n * Marks while they can still be counted, the count itself once they cannot.\n * Twenty of either is a texture rather than a number, and the text says the\n * same thing in less room. The ring is exempt: it never claimed to be\n * countable, so there is nothing for a long set to take away from it.\n */\n const drawable =\n variant !== 'count' && variant !== 'ring' && total > 0 && total <= MAX_PIPS;\n\n const fallback = variant === 'ring' ? (\n \n ) : drawable ? (\n \n {Array.from({ length: total }, (_, index) =>\n variant === 'numbers' ? (\n \n ) : (\n \n )\n )}\n \n ) : (\n \n {label}\n \n );\n\n const content =\n typeof children === 'function' ? children({ current, total, first, last }) : (children ?? fallback);\n\n return (\n \n {textChildren(content, (text) => (\n \n {text}\n \n ))}\n \n );\n}\nQuestionnaireProgress.displayName = 'Questionnaire.Progress';\n\nexport interface QuestionnaireItemProps extends Omit {\n className?: string;\n /** Unique name — the key this question's answer is stored under. */\n name: string;\n /** Blocks the way forward until it has an answer. */\n required?: boolean;\n /** Accepts more than one answer, so its answer is an array. */\n multiple?: boolean;\n /** Left out of the count and never navigated to. */\n disabled?: boolean;\n /** Mark the question at fault from a validator of your own. */\n invalid?: boolean;\n /** Called whenever this question moves between unanswered, answered and skipped. */\n onStatusChange?: (status: QuestionnaireItemStatus) => void;\n children?: ReactNode;\n}\n\n/**\n * One question. Only the active one is mounted, so anything it holds is built\n * when it is reached and thrown away when it is left.\n */\nfunction QuestionnaireItem({\n className,\n name,\n required,\n multiple,\n invalid: invalidProp,\n children,\n // Read by the root off this element rather than used here.\n disabled: _disabled,\n onStatusChange: _onStatusChange,\n ...props\n}: QuestionnaireItemProps) {\n const { invalid: invalidNames } = useQuestionnaire('Questionnaire.Item');\n\n /*\n * Collected so a typed answer can be told from a chosen one: whatever the\n * question is holding that is not one of these values came from the text\n * field, and that is what puts it back in the field on the way back.\n */\n const choiceValues = useMemo(() => {\n const values = new Set();\n const walk = (node: ReactNode) => {\n Children.forEach(node, (child) => {\n if (!isValidElement(child)) return;\n if (child.type === QuestionnaireChoice) {\n values.add((child.props as QuestionnaireChoiceProps).value);\n return;\n }\n walk((child.props as { children?: ReactNode }).children);\n });\n };\n walk(children);\n return values;\n }, [children]);\n\n const context = useMemo(\n () => ({\n name,\n required: !!required,\n multiple: !!multiple,\n invalid: !!invalidProp || invalidNames.has(name),\n choiceValues,\n }),\n [name, required, multiple, invalidProp, invalidNames, choiceValues]\n );\n\n return (\n \n \n {textChildren(children)}\n \n \n );\n}\nQuestionnaireItem.displayName = 'Questionnaire.Item';\n\nexport interface QuestionnaireQuestionProps extends ViewProps {\n className?: string;\n children?: ReactNode;\n}\n\n/** The question being asked. */\nfunction QuestionnaireQuestion({ className, children, ...props }: QuestionnaireQuestionProps) {\n const { questionOnStrip } = useQuestionnaire('Questionnaire.Question');\n\n // Drawn on the header strip instead, where an untitled questionnaire uses it\n // as its label. Rendering here as well would ask the same thing twice.\n if (questionOnStrip) return null;\n\n return (\n \n {textChildren(children, (text) => (\n // `text-pretty` rather than a hard wrap: a question is a sentence, and\n // the one thing worse than two lines is a second line holding one word.\n \n {text}\n \n ))}\n \n );\n}\nQuestionnaireQuestion.displayName = 'Questionnaire.Question';\n\nexport interface QuestionnaireDescriptionProps extends ViewProps {\n className?: string;\n children?: ReactNode;\n}\n\n/** A line under the question — what to consider, or that it can be skipped. */\nfunction QuestionnaireDescription({\n className,\n children,\n ...props\n}: QuestionnaireDescriptionProps) {\n return (\n // Pulled up against the question it belongs to: the pane's gap is the\n // distance between one part and the next, and these two are one part.\n \n {textChildren(children, (text) => (\n \n {text}\n \n ))}\n \n );\n}\nQuestionnaireDescription.displayName = 'Questionnaire.Description';\n\nexport interface QuestionnaireChoicesProps extends Omit {\n className?: string;\n children?: ReactNode;\n}\n\n/**\n * The answers to a question. It hands each choice its shortcut badge, counting\n * only the ones that can be picked so a disabled answer does not take a letter\n * out of the sequence with it.\n */\nfunction QuestionnaireChoices({ className, children, ...props }: QuestionnaireChoicesProps) {\n const { shortcuts } = useQuestionnaire('Questionnaire.Choices');\n const slots = questionnaireVariants();\n\n const badged = useMemo(() => {\n if (!shortcuts) return children;\n let index = 0;\n return Children.map(children, (child) => {\n if (!isValidElement(child) || child.type !== QuestionnaireChoice) return child;\n if ((child.props as QuestionnaireChoiceProps).disabled) return child;\n const key = shortcutAt(shortcuts, index++);\n if (!key) return child;\n return (\n \n {child}\n \n );\n });\n }, [children, shortcuts]);\n\n return (\n \n {textChildren(badged)}\n \n );\n}\nQuestionnaireChoices.displayName = 'Questionnaire.Choices';\n\nexport interface QuestionnaireChoiceProps {\n className?: string;\n /** The value recorded when this answer is picked. */\n value: string;\n /** The answer itself. */\n label?: string;\n /** A line under the label, for an answer that needs explaining. */\n description?: string;\n disabled?: boolean;\n children?: ReactNode;\n}\n\n/**\n * One fixed answer — the whole row is the target, with the indicator reading\n * as confirmation rather than as the thing to aim at.\n */\nconst QuestionnaireChoice = forwardRef(\n ({ className, value, label, description, disabled, children }, ref) => {\n const { answers, toggleAnswer } = useQuestionnaire('Questionnaire.Choice');\n const item = useQuestionnaireItem('Questionnaire.Choice');\n const shortcut = useContext(ShortcutContext);\n\n const answer = answers[item.name];\n const selected = Array.isArray(answer) ? answer.includes(value) : answer === value;\n\n const progress = useSharedValue(selected ? 1 : 0);\n\n useEffect(() => {\n progress.value = selected\n ? withSpring(1, { damping: 15, stiffness: 300, mass: 0.5 })\n : withTiming(0, { duration: 120 });\n }, [selected, progress]);\n\n const markStyle = useAnimatedStyle(() => ({\n opacity: progress.value,\n transform: [{ scale: progress.value }],\n }));\n\n const checkColor = useCSSVariable('--color-primary-foreground');\n const slots = choiceVariants({\n multiple: item.multiple,\n selected,\n disabled: !!disabled,\n invalid: item.invalid && !selected,\n });\n\n const labelled = label ? (\n {label}\n ) : (\n textChildren(children, (text) => {text})\n );\n\n return (\n toggleAnswer(item.name, value, item.multiple)}\n className={slots.row({ className })}\n >\n \n \n {item.multiple ? (\n \n ) : (\n \n )}\n \n \n \n {labelled}\n {description ? {description} : null}\n \n {shortcut ? (\n \n {shortcut}\n \n ) : null}\n \n );\n }\n);\nQuestionnaireChoice.displayName = 'Questionnaire.Choice';\n\nexport interface QuestionnaireInputProps\n extends Omit {\n className?: string;\n}\n\n/**\n * An answer that is not on the list. It holds whatever the question is\n * answered with that none of its own choices offers, so picking a choice\n * empties it and typing clears the choice — one answer to one question, with\n * neither part having to know about the other.\n */\nconst QuestionnaireInput = forwardRef(\n ({ className, ...props }, ref) => {\n const { answers, setAnswer } = useQuestionnaire('Questionnaire.Input');\n const item = useQuestionnaireItem('Questionnaire.Input');\n\n const answer = answers[item.name];\n const freeform = useMemo(() => {\n if (Array.isArray(answer)) {\n return answer.find((entry) => !item.choiceValues.has(entry)) ?? '';\n }\n return typeof answer === 'string' && !item.choiceValues.has(answer) ? answer : '';\n }, [answer, item.choiceValues]);\n\n const onChangeText = useCallback(\n (text: string) => {\n if (!item.multiple) {\n setAnswer(item.name, text);\n return;\n }\n // Replace this question's one typed entry, leaving every picked one alone.\n const list = Array.isArray(answer) ? answer : [];\n const fixed = list.filter((entry) => item.choiceValues.has(entry));\n setAnswer(item.name, text.length > 0 ? [...fixed, text] : fixed);\n },\n [item.multiple, item.name, item.choiceValues, answer, setAnswer]\n );\n\n return (\n \n );\n }\n);\nQuestionnaireInput.displayName = 'Questionnaire.Input';\n\nexport interface QuestionnaireErrorProps extends ViewProps {\n className?: string;\n /** Replace the default message. */\n children?: ReactNode;\n}\n\n/** Why the way forward is closed. Nothing until the question fails to pass. */\nfunction QuestionnaireError({ className, children, ...props }: QuestionnaireErrorProps) {\n const item = useQuestionnaireItem('Questionnaire.Error');\n const slots = questionnaireVariants();\n\n if (!item.invalid) return null;\n\n return (\n \n {textChildren(children ?? DEFAULT_ERROR, (text) => (\n {text}\n ))}\n \n );\n}\nQuestionnaireError.displayName = 'Questionnaire.Error';\n\nexport interface QuestionnaireFooterProps extends ViewProps {\n className?: string;\n children?: ReactNode;\n}\n\n/**\n * The action row, in its own section at the foot of the panel. It stays put\n * while the question above it changes, which is what keeps the button under\n * the thumb where it was.\n */\nfunction QuestionnaireFooter({ className, children, ...props }: QuestionnaireFooterProps) {\n const { framed } = useQuestionnaire('Questionnaire.Footer');\n const slots = questionnaireVariants({ framed });\n return (\n \n {textChildren(children)}\n \n );\n}\nQuestionnaireFooter.displayName = 'Questionnaire.Footer';\n\n/** What a navigation button is told about the question it is acting on. */\nexport interface QuestionnaireActionState {\n /** Whether the action applies to the active question at all. */\n visible: boolean;\n /** Where the active question stands. */\n status: QuestionnaireItemStatus;\n}\n\nexport interface QuestionnaireActionProps extends Omit {\n /** Replace the label. Given a function, it is called with the question's state. */\n children?: ReactNode | ((state: QuestionnaireActionState) => ReactNode);\n}\n\n/**\n * Builds one of the four navigation buttons. They differ only in when they\n * apply, what they do and how they are labelled by default, so they are made\n * rather than written out four times.\n */\ninterface ActionConfig {\n displayName: string;\n label: string;\n variant: NonNullable;\n /** A chevron on the buttons that move, and nothing on the ones that do not. */\n startContent?: ReactNode;\n endContent?: ReactNode;\n /**\n * Dim this one while the active question is required and unanswered.\n *\n * It stays pressable on purpose. A disabled button says no without saying\n * why, and on a question whose answers have scrolled out of view that is the\n * whole of the feedback; pressing this one puts the reason under the\n * question instead. Dimming is what stops it promising something it will not\n * do — the look says not yet, the press says why not.\n */\n dimWhenBlocked?: boolean;\n use: (context: QuestionnaireContextValue) => { visible: boolean; onPress: () => void };\n}\n\nfunction createAction({\n displayName,\n label: fallbackLabel,\n variant: fallbackVariant,\n startContent,\n endContent,\n dimWhenBlocked,\n use,\n}: ActionConfig) {\n function Action({ children, variant, className, ...props }: QuestionnaireActionProps) {\n const context = useQuestionnaire(displayName);\n const { visible, onPress } = use(context);\n const status = context.activeName ? context.statusOf(context.activeName) : 'unanswered';\n\n // Not rendered at all rather than hidden: React Native has no `inert`, and\n // a button left in the tree is one a screen reader still offers.\n if (!visible) return null;\n\n const label = typeof children === 'function' ? children({ visible, status }) : children;\n const dimmed = !!dimWhenBlocked && context.blocked;\n\n return (\n \n {label ?? fallbackLabel}\n \n );\n }\n\n Action.displayName = displayName;\n return Action;\n}\n\n/** Back to the previous question. Absent on the first one. */\nconst QuestionnaireBack = createAction({\n displayName: 'Questionnaire.Back',\n label: 'Back',\n variant: 'ghost',\n startContent: ,\n use: (context) => ({ visible: !context.first, onPress: context.goBack }),\n});\n\n/** Records that an optional question was deliberately left out. */\nconst QuestionnaireSkip = createAction({\n displayName: 'Questionnaire.Skip',\n label: 'Skip',\n variant: 'ghost',\n use: (context) => ({\n visible: !!context.activeItem && !context.activeItem.required,\n onPress: context.skip,\n }),\n});\n\n/** On to the next question, if the current one lets go. Absent on the last. */\nconst QuestionnaireNext = createAction({\n displayName: 'Questionnaire.Next',\n label: 'Continue',\n variant: 'primary',\n endContent: ,\n dimWhenBlocked: true,\n use: (context) => ({ visible: !context.last, onPress: context.goNext }),\n});\n\n/** Hands over every answer. Only on the last question. */\nconst QuestionnaireSubmit = createAction({\n displayName: 'Questionnaire.Submit',\n label: 'Submit',\n variant: 'primary',\n dimWhenBlocked: true,\n use: (context) => ({ visible: context.last, onPress: context.submit }),\n});\n\n/**\n * A flexible gap for the footer, so the trailing buttons sit against the\n * trailing edge whether or not `Questionnaire.Back` is showing.\n */\nfunction QuestionnaireSpacer({ className, ...props }: ViewProps) {\n return ;\n}\nQuestionnaireSpacer.displayName = 'Questionnaire.Spacer';\n\nexport const Questionnaire = Object.assign(QuestionnaireRoot, {\n Title: QuestionnaireTitle,\n Progress: QuestionnaireProgress,\n Item: QuestionnaireItem,\n Question: QuestionnaireQuestion,\n Description: QuestionnaireDescription,\n Choices: QuestionnaireChoices,\n Choice: QuestionnaireChoice,\n Input: QuestionnaireInput,\n Error: QuestionnaireError,\n Footer: QuestionnaireFooter,\n Spacer: QuestionnaireSpacer,\n Back: QuestionnaireBack,\n Skip: QuestionnaireSkip,\n Next: QuestionnaireNext,\n Submit: QuestionnaireSubmit,\n});\n" + "content": "/**\n * Questionnaire — one question at a time, with progress, validation and a way\n * back.\n *\n * Unlike `Steps`, which reflects a flow the app owns, Questionnaire *owns* the\n * flow: it holds the answers, decides which question is current, gates the way\n * forward on the current one being answered, and reports the whole set back\n * when it is done. The caller supplies the questions and does something with\n * the answers; everything between those two points belongs here.\n *\n * ```tsx\n * save(answers)}>\n * Project setup\n * \n * \n * What should we build next?\n * Choose one, or write your own.\n * \n * \n * \n * \n * \n * \n * \n * \n * \n * \n * \n * \n * \n * ```\n *\n * ## Why it draws its own Frame\n *\n * A survey is a widget, not a paragraph: it wants a boundary, a title strip and\n * a footer that stays put while the middle changes. That is exactly `Frame`, so\n * the root renders one rather than leaving every caller to assemble the same\n * shell. `Frame.Panel`'s `overflow-hidden` also does the clipping the sliding\n * question needs, for free. Pass `frame={false}` to drop it — for a\n * questionnaire inside a `BottomSheet` or a card that already draws a border.\n *\n * It is the `inset` variant, so the panel the question is written on floats in\n * a recessed band rather than sitting flush in a tray, and the actions go in\n * the band rather than in a section under the question. That separation is the\n * point: the question changes and the row under it does not, and a row drawn\n * on the band is visibly not part of the card that keeps being replaced.\n *\n * The band shapes its actions into equal pills, which is why\n * `Questionnaire.Spacer` is dropped on the way in — see `bandActions`.\n *\n * ## Why the root reads its children instead of collecting registrations\n *\n * Only the active question is mounted, so an unmounted one cannot report that\n * it exists — and without knowing the full set there is no total to count\n * against, no \"is this the last one\", and no way to disable a question the\n * user has not reached. So the root inspects its children once per render and\n * reads `name`, `required`, `multiple` and `disabled` straight off the\n * elements. React elements carry their props before anything renders them,\n * which makes the whole set knowable without mounting any of it.\n *\n * That same pass sorts the parts into the shell: the title and progress go to\n * the header strip above the panel, the footer's actions to the band around\n * it, and everything else is a question.\n *\n * ## Answers are one record, the way a form would submit them\n *\n * `answers[name]` is a string for a single-answer question and an array for a\n * `multiple` one. A freeform answer lands under the same name — it is another\n * answer to the same question, not a separate field — which is why the text\n * input shows whatever value does not match one of the question's own choices.\n * Picking a choice and typing therefore replace each other, without either one\n * having to know the other exists.\n *\n * ## What blocks the way forward\n *\n * A required question blocks until it has an answer. An optional one never\n * blocks. `Questionnaire.Skip` does not unblock anything, then — it *records*\n * that the question was deliberately left out, moving its status from\n * `unanswered` to `skipped` so the app can tell the two apart. Making an\n * optional question demand an explicit skip would trap anyone who did not\n * render the Skip button, and a question that cannot be ignored is not\n * optional.\n */\nimport {\n Children,\n cloneElement,\n createContext,\n forwardRef,\n isValidElement,\n useCallback,\n useContext,\n useEffect,\n useMemo,\n useRef,\n useState,\n type ReactElement,\n type ReactNode,\n} from 'react';\nimport {\n AccessibilityInfo,\n findNodeHandle,\n Pressable,\n View,\n type LayoutChangeEvent,\n type TextInput,\n type ViewProps,\n} from 'react-native';\nimport { Gesture, GestureDetector } from 'react-native-gesture-handler';\nimport Animated, {\n Easing,\n FadeOut,\n runOnJS,\n useAnimatedProps,\n useAnimatedStyle,\n useReducedMotion,\n useSharedValue,\n withSpring,\n withTiming,\n type EntryExitAnimationFunction,\n} from 'react-native-reanimated';\nimport Svg, { Circle } from 'react-native-svg';\nimport { tv } from 'tailwind-variants';\nimport { useCSSVariable } from 'uniwind';\nimport { CheckIcon, ChevronLeftIcon, ChevronRightIcon } from '@/components/ui/icons';\nimport { Text, textChildren } from '@/components/ui/text';\nimport { cn } from '@/lib/cn';\nimport { Button, type ButtonProps } from '@/components/ui/button';\nimport { Frame } from '@/components/ui/frame';\nimport { Input, type InputProps } from '@/components/ui/input';\nimport type { TextInputRef, ViewRef } from '@/components/ui/refs';\n\n/** How long the body takes to settle into the next question's height. */\nconst HEIGHT_DURATION = 240;\n\n/** How long the arriving question takes to slide and fade in. */\nconst ENTER_DURATION = 220;\n\n/**\n * How long the leaving one takes to fade out — shorter, so it is gone before\n * the arriving one is fully in and the two are never both legible.\n */\nconst EXIT_DURATION = 140;\n\n/** How long a progress pip takes to fill once its question has been reached. */\nconst PIP_DURATION = 260;\n\n/**\n * The progress ring: its diameter, and the weight of the arc drawn round it.\n *\n * Sized against the title beside it rather than against the strip it sits on.\n * A ring the height of a line of text reads as the trailing half of a pair;\n * one much larger reads as the header's subject, which it is not.\n */\nconst RING_SIZE = 22;\nconst RING_STROKE = 2.5;\n\n/** How far across the body a swipe has to travel before it commits. */\nconst SWIPE_FRACTION = 0.25;\n\n/** Where the sliding question starts from and exits to, as a fraction of width. */\nconst SLIDE_FRACTION = 0.35;\n\nconst EASE = Easing.out(Easing.cubic);\n\n/** What `Questionnaire.Error` says when the caller gives it no message. */\nconst DEFAULT_ERROR = 'Choose an answer to continue.';\n\nconst questionnaireVariants = tv({\n slots: {\n body: 'overflow-hidden',\n // Absolute, so the question measures its own height rather than being\n // clamped by the animated box above it — and so the question leaving and\n // the one arriving overlap instead of stacking during the transition.\n slide: 'absolute inset-x-0 top-0',\n pane: 'gap-4',\n choices: 'gap-2.5',\n footer: 'flex-row items-center gap-2',\n error: 'text-sm text-destructive',\n },\n variants: {\n /**\n * With the frame off, only the vertical rhythm is the questionnaire's —\n * the container it was placed in is already holding it off the edges, and\n * insetting it again would inset it twice.\n */\n framed: {\n true: { pane: 'p-4', footer: 'p-3' },\n false: { pane: 'py-4', footer: 'pt-3' },\n },\n },\n defaultVariants: {\n framed: true,\n },\n});\n\nconst choiceVariants = tv({\n slots: {\n row: 'w-full flex-row items-start gap-3 rounded-xl border border-border bg-card px-3.5 py-3',\n /*\n * `mt-px` against a `leading-snug` first line: the indicator is centred on\n * the label's cap height rather than on its line box, which is where the\n * eye reads the two as being on the same line.\n */\n indicator:\n 'mt-px h-5 w-5 shrink-0 items-center justify-center border border-input bg-background',\n label: 'text-base font-medium leading-snug text-foreground',\n description: 'text-sm leading-snug text-muted-foreground',\n shortcut:\n 'mt-px h-5 min-w-[22px] shrink-0 items-center justify-center rounded-md border border-border bg-muted px-1',\n shortcutLabel: 'text-[11px] font-medium tabular-nums text-muted-foreground',\n },\n variants: {\n /** A disc for one-of, a rounded square for many-of — the usual grammar. */\n multiple: {\n true: { indicator: 'rounded-md' },\n false: { indicator: 'rounded-full' },\n },\n selected: {\n true: {\n row: 'border-primary bg-accent',\n // The badge follows the row rather than staying grey against a filled\n // surface, where it would read as the one part that did not respond.\n shortcut: 'border-primary/40 bg-primary/10',\n shortcutLabel: 'text-primary',\n },\n },\n disabled: {\n true: { row: 'opacity-50' },\n },\n /** The question failed validation, so every answer in it reads as at fault. */\n invalid: {\n true: { row: 'border-destructive' },\n },\n },\n defaultVariants: {\n multiple: false,\n },\n});\n\n/**\n * How many questions can be drawn as pips before the count is the clearer\n * thing. Past this they stop being countable at a glance and become a texture.\n */\nconst MAX_PIPS = 8;\n\n/** Where a question stands: never touched, answered, or deliberately left out. */\nexport type QuestionnaireItemStatus = 'unanswered' | 'answered' | 'skipped';\n\n/** Which key each answer is badged with. */\nexport type QuestionnaireShortcutMode = 'letters' | 'numbers';\n\n/** Every answer given so far, keyed by question name. */\nexport type QuestionnaireAnswers = Record;\n\n/**\n * A question, described rather than rendered. Pass these as `items` so the\n * questionnaire knows its full set before any of it mounts — which is what\n * makes a conditional question countable and a total meaningful.\n */\nexport interface QuestionnaireItemDefinition {\n /** Unique name — the key this question's answer is stored under. */\n name: string;\n /** Blocks the way forward until it has an answer. */\n required?: boolean;\n /** Accepts more than one answer, so its answer is an array. */\n multiple?: boolean;\n /** Left out of the count and never navigated to. */\n disabled?: boolean;\n}\n\n/** The whole set, as everything downstream sees it. */\ninterface ResolvedItem extends QuestionnaireItemDefinition {\n element: ReactElement;\n onStatusChange?: (status: QuestionnaireItemStatus) => void;\n}\n\ninterface QuestionnaireContextValue {\n /** One-based position of the active question among the enabled ones. */\n current: number;\n /** How many questions are enabled. */\n total: number;\n first: boolean;\n last: boolean;\n activeName: string | null;\n activeItem: ResolvedItem | null;\n answers: QuestionnaireAnswers;\n statusOf: (name: string) => QuestionnaireItemStatus;\n invalid: ReadonlySet;\n setAnswer: (name: string, value: string | string[] | undefined) => void;\n toggleAnswer: (name: string, value: string, multiple: boolean) => void;\n goNext: () => void;\n goBack: () => void;\n skip: () => void;\n submit: () => void;\n shortcuts: QuestionnaireShortcutMode | null;\n /** Whether the root drew the frame, which decides who owns the insets. */\n framed: boolean;\n /**\n * The question is drawn on the header strip rather than in the panel, so\n * `Questionnaire.Question` renders nothing where it stands.\n */\n questionOnStrip: boolean;\n /** The active question is required and has no answer, so the way on is shut. */\n blocked: boolean;\n}\n\nconst QuestionnaireContext = createContext(null);\n\nfunction useQuestionnaire(component: string): QuestionnaireContextValue {\n const context = useContext(QuestionnaireContext);\n if (!context) {\n throw new Error(`${component} must be used within a `);\n }\n return context;\n}\n\ninterface QuestionnaireItemContextValue {\n name: string;\n required: boolean;\n multiple: boolean;\n invalid: boolean;\n /** Every fixed value this question offers — what tells a typed answer apart. */\n choiceValues: ReadonlySet;\n}\n\nconst QuestionnaireItemContext = createContext(null);\n\nfunction useQuestionnaireItem(component: string): QuestionnaireItemContextValue {\n const context = useContext(QuestionnaireItemContext);\n if (!context) {\n throw new Error(`${component} must be used within a `);\n }\n return context;\n}\n\n/** The shortcut `Questionnaire.Choices` hands the choice it wraps. */\nconst ShortcutContext = createContext(null);\n\n/** True when the question has an answer of some kind. */\nfunction isAnswered(value: string | string[] | undefined): boolean {\n if (Array.isArray(value)) return value.length > 0;\n return typeof value === 'string' && value.trim().length > 0;\n}\n\n/**\n * The footer's actions, on their way into the band.\n *\n * `Frame.Footer` shapes its *direct* children into the band's pills, so the\n * row itself has to be unwrapped — handed the `Questionnaire.Footer` element\n * whole it would be the wrapper that came out a pill, with the buttons\n * untouched inside it.\n *\n * The spacer goes at the same time. It exists to push the actions apart in a\n * row that lays them out at their own widths; in a band where every action is\n * already a share of the row it would take a share of its own, and the buttons\n * would come out narrower the more carefully the caller had spaced them.\n *\n * The band's own rule is equal widths, and it is the wrong one here. A row of\n * equal pills says these are equal decisions; Back and Skip are ways off the\n * path and Next is the path, so they are sized to their labels and the primary\n * action takes what is left. Equal thirds also truncate it — \"Continue\" does\n * not fit in a third of the band, and the button that carries the flow is the\n * worst one to lose the end of.\n *\n * `flex-none` and nothing else on those two: the button's own padding is what\n * a label needs either side of it, and anything added here comes off the\n * primary action, which has the longest label and the least room to lose.\n *\n * The primary action goes up one size instead — `lg`, not the default. It is\n * the only thing on the band anybody is aiming for: the whole widget is one\n * decision repeated, and this is the button that takes the reader to the next\n * one, so it carries a larger label and a taller box than the way back beside\n * it. One size and no more; `xl` is for a button that is the whole of a\n * screen, and this one shares its row. The band's fixed height comes off with\n * it, or the taller box would be clamped back to the row's.\n */\nfunction bandActions(footer: ReactNode): ReactNode {\n if (!isValidElement(footer)) return footer;\n return Children.toArray(footer.props.children).flatMap((child) => {\n if (!isValidElement(child)) return [child];\n if (child.type === QuestionnaireSpacer) return [];\n\n // Merged after the band's own classes rather than before, so what is set\n // here is what survives.\n if (child.type === QuestionnaireBack || child.type === QuestionnaireSkip) {\n return [cloneElement(child, { className: cn('flex-none', child.props.className) })];\n }\n\n return [\n cloneElement(child, {\n // A caller who asked for a size meant it.\n size: child.props.size ?? 'lg',\n className: cn('h-auto', child.props.className),\n }),\n ];\n });\n}\n\n/**\n * The text of a question's `Questionnaire.Question`, for the header strip.\n *\n * Read off the element rather than reported up by the mounted question: only\n * the active one is mounted, and the strip has to be drawn in the same render\n * as the pane it labels — a value arriving from below would land one frame\n * late, which on a question change is a header still naming the question that\n * has just left.\n */\nfunction questionOf(item: ReactElement | undefined): ReactNode {\n if (!item) return null;\n let found: ReactNode = null;\n Children.forEach(item.props.children, (child) => {\n if (found != null) return;\n if (!isValidElement(child)) return;\n if (child.type === QuestionnaireQuestion) found = child.props.children;\n });\n return found;\n}\n\n/** The letter or number an answer at this position is badged with. */\nfunction shortcutAt(mode: QuestionnaireShortcutMode, index: number): string | null {\n if (mode === 'numbers') return index < 9 ? String(index + 1) : null;\n return index < 26 ? String.fromCharCode(65 + index) : null;\n}\n\nexport interface QuestionnaireProps extends Omit {\n className?: string;\n /**\n * The full set of questions, in order. Optional: without it the order and\n * the totals come from the `Questionnaire.Item` children instead. Pass it\n * when a question is conditional, since a question the user has not reached\n * still has to be counted — or not counted, if it no longer applies.\n */\n items?: readonly QuestionnaireItemDefinition[];\n /** Controlled active question, by name. */\n item?: string;\n /** Which question to open on. Defaults to the first enabled one. */\n defaultItem?: string;\n /** Called with the name of the question being moved to. */\n onItemChange?: (name: string) => void;\n /** Controlled answers. */\n answers?: QuestionnaireAnswers;\n /** Answers to start with — for resuming a part-finished questionnaire. */\n defaultAnswers?: QuestionnaireAnswers;\n /** Called with the whole set every time any answer changes. */\n onAnswersChange?: (answers: QuestionnaireAnswers) => void;\n /** Called with every answer once the last question validates. */\n onSubmit?: (answers: QuestionnaireAnswers) => void;\n /**\n * Badge every answer with a letter (`A`, `B`, `C`) or a number (`1`, `2`,\n * `3`). Disabled answers are skipped rather than taking a badge with them.\n *\n * The badge is an affordance, not a binding: React Native surfaces hardware\n * key events only to a focused text field, so nothing here can listen for\n * the key itself.\n */\n shortcuts?: QuestionnaireShortcutMode;\n /**\n * Let a horizontal drag move between questions. Going forward is gated on\n * the same answer the button is, so a swipe off an unanswered required\n * question springs back and shows its error.\n */\n swipeable?: boolean;\n /**\n * Draw the surrounding `Frame`. Turn it off to place the questionnaire in a\n * sheet, a dialog or a card that already draws its own boundary.\n */\n frame?: boolean;\n children?: ReactNode;\n}\n\nfunction QuestionnaireRoot({\n className,\n items,\n item: itemProp,\n defaultItem,\n onItemChange,\n answers: answersProp,\n defaultAnswers,\n onAnswersChange,\n onSubmit,\n shortcuts,\n swipeable = true,\n frame = true,\n children,\n ...props\n}: QuestionnaireProps) {\n /*\n * One pass over the children does two jobs: it sorts the parts into the\n * shell's three regions, and it reads the questions' props off the elements\n * so the set is known without mounting any of it.\n */\n const { titleNode, progressNode, footerNode, elements } = useMemo(() => {\n let title: ReactNode = null;\n let progress: ReactNode = null;\n let footer: ReactNode = null;\n const found: ReactElement[] = [];\n\n Children.forEach(children, (child) => {\n if (!isValidElement(child)) return;\n if (child.type === QuestionnaireTitle) title = child;\n else if (child.type === QuestionnaireProgress) progress = child;\n else if (child.type === QuestionnaireFooter) footer = child;\n else if (child.type === QuestionnaireItem) {\n found.push(child as ReactElement);\n }\n });\n\n return { titleNode: title, progressNode: progress, footerNode: footer, elements: found };\n }, [children]);\n\n /*\n * `items` decides the order when it is given, because a question that has\n * not rendered still has to hold its place in the count. A question's own\n * props win over the definition wherever both say something, so a\n * conditional `disabled` can be computed at the point it is rendered.\n */\n const resolved = useMemo(() => {\n const byName = new Map(elements.map((element) => [element.props.name, element]));\n\n const merge = (\n definition: QuestionnaireItemDefinition,\n element: ReactElement | undefined\n ): ResolvedItem | null => {\n if (!element) return null;\n const p = element.props;\n return {\n name: definition.name,\n required: p.required ?? definition.required,\n multiple: p.multiple ?? definition.multiple,\n disabled: p.disabled ?? definition.disabled,\n onStatusChange: p.onStatusChange,\n element,\n };\n };\n\n if (items?.length) {\n return items\n .map((definition) => merge(definition, byName.get(definition.name)))\n .filter((entry): entry is ResolvedItem => entry !== null);\n }\n\n return elements.map((element) => ({\n name: element.props.name,\n required: element.props.required,\n multiple: element.props.multiple,\n disabled: element.props.disabled,\n onStatusChange: element.props.onStatusChange,\n element,\n }));\n }, [items, elements]);\n\n /** The ones that count: disabled questions are neither shown nor tallied. */\n const enabled = useMemo(() => resolved.filter((entry) => !entry.disabled), [resolved]);\n\n const [internalItem, setInternalItem] = useState(defaultItem ?? null);\n const [internalAnswers, setInternalAnswers] = useState(\n () => defaultAnswers ?? {}\n );\n const [skipped, setSkipped] = useState>(() => new Set());\n const [invalid, setInvalid] = useState>(() => new Set());\n\n const isItemControlled = itemProp !== undefined;\n const isAnswersControlled = answersProp !== undefined;\n const answers = isAnswersControlled ? answersProp : internalAnswers;\n\n /*\n * Falling back to the first enabled question rather than storing it means a\n * questionnaire whose first question becomes disabled moves off it by\n * itself, instead of sitting on a question it has been told not to show.\n */\n const requested = isItemControlled ? itemProp : internalItem;\n const activeIndex = Math.max(\n 0,\n enabled.findIndex((entry) => entry.name === requested)\n );\n const activeItem = enabled[activeIndex] ?? null;\n const activeName = activeItem?.name ?? null;\n\n const total = enabled.length;\n const current = total === 0 ? 0 : activeIndex + 1;\n const first = activeIndex === 0;\n const last = total === 0 || activeIndex === total - 1;\n\n const statusOf = useCallback(\n (name: string): QuestionnaireItemStatus => {\n if (isAnswered(answers[name])) return 'answered';\n if (skipped.has(name)) return 'skipped';\n return 'unanswered';\n },\n [answers, skipped]\n );\n\n /*\n * Reported from here rather than from the question itself: only the active\n * question is mounted, and skipping one is immediately followed by leaving\n * it, so an effect inside it would be racing its own unmount.\n */\n const emitStatus = useCallback(\n (name: string, status: QuestionnaireItemStatus) => {\n resolved.find((entry) => entry.name === name)?.onStatusChange?.(status);\n },\n [resolved]\n );\n\n const commitAnswers = useCallback(\n (next: QuestionnaireAnswers) => {\n if (!isAnswersControlled) setInternalAnswers(next);\n onAnswersChange?.(next);\n },\n [isAnswersControlled, onAnswersChange]\n );\n\n const setAnswer = useCallback(\n (name: string, value: string | string[] | undefined) => {\n const next = { ...answers };\n if (value === undefined || (Array.isArray(value) && value.length === 0)) {\n delete next[name];\n } else {\n next[name] = value;\n }\n\n commitAnswers(next);\n\n // Answering clears both a recorded skip and a failed validation: the\n // reason for either has just stopped being true.\n setSkipped((previous) => {\n if (!previous.has(name)) return previous;\n const copy = new Set(previous);\n copy.delete(name);\n return copy;\n });\n setInvalid((previous) => {\n if (!previous.has(name)) return previous;\n const copy = new Set(previous);\n copy.delete(name);\n return copy;\n });\n\n emitStatus(name, isAnswered(next[name]) ? 'answered' : 'unanswered');\n },\n [answers, commitAnswers, emitStatus]\n );\n\n const toggleAnswer = useCallback(\n (name: string, value: string, multiple: boolean) => {\n if (!multiple) {\n // Pressing the selected answer again clears it, which is the only way\n // to undo an answer to an optional question without a Skip button.\n setAnswer(name, answers[name] === value ? undefined : value);\n return;\n }\n\n const currentValue = answers[name];\n const list = Array.isArray(currentValue) ? currentValue : [];\n setAnswer(\n name,\n list.includes(value) ? list.filter((entry) => entry !== value) : [...list, value]\n );\n },\n [answers, setAnswer]\n );\n\n const moveTo = useCallback(\n (index: number) => {\n const target = enabled[index];\n if (!target) return;\n if (!isItemControlled) setInternalItem(target.name);\n onItemChange?.(target.name);\n },\n [enabled, isItemControlled, onItemChange]\n );\n\n /** A required question is the only thing that blocks. */\n const validate = useCallback(\n (entry: ResolvedItem | null): boolean => {\n if (!entry || !entry.required) return true;\n if (isAnswered(answers[entry.name])) return true;\n setInvalid((previous) => new Set(previous).add(entry.name));\n return false;\n },\n [answers]\n );\n\n const goNext = useCallback(() => {\n if (!validate(activeItem)) return;\n moveTo(activeIndex + 1);\n }, [validate, activeItem, moveTo, activeIndex]);\n\n // Going back never validates: the way out of a question you cannot answer\n // must not be the same door you came in by.\n const goBack = useCallback(() => moveTo(activeIndex - 1), [moveTo, activeIndex]);\n\n const skip = useCallback(() => {\n if (!activeItem || activeItem.required) return;\n setAnswer(activeItem.name, undefined);\n setSkipped((previous) => new Set(previous).add(activeItem.name));\n emitStatus(activeItem.name, 'skipped');\n if (!last) moveTo(activeIndex + 1);\n }, [activeItem, setAnswer, emitStatus, last, moveTo, activeIndex]);\n\n const submit = useCallback(() => {\n const failed = enabled.filter((entry) => entry.required && !isAnswered(answers[entry.name]));\n if (failed.length > 0) {\n setInvalid((previous) => {\n const copy = new Set(previous);\n failed.forEach((entry) => copy.add(entry.name));\n return copy;\n });\n // Take them to the first question that is missing an answer rather than\n // leaving them on the last one wondering which of the others it was.\n const index = enabled.findIndex((entry) => entry.name === failed[0]!.name);\n if (index !== activeIndex) moveTo(index);\n return;\n }\n onSubmit?.(answers);\n }, [enabled, answers, activeIndex, moveTo, onSubmit]);\n\n /*\n * Whether the way on is shut, as against whether it has been *tried* — the\n * error under the question needs somebody to have pressed the button, but\n * the button's own look must not, or it would read as ready right up until\n * it refused.\n */\n const blocked = !!activeItem?.required && !isAnswered(answers[activeItem.name]);\n\n /*\n * With no title the header strip has nothing on it but the ring, and a ring\n * centred on an empty strip is a widget that will not say what it is asking.\n * The question stands in for the title instead — it is the only line that\n * names the thing, and a questionnaire that has not been given a title is\n * one where the question is the title.\n *\n * It takes the title's place exactly, at the leading edge with the ring\n * still at the trailing one, so a titled questionnaire and an untitled one\n * draw the same strip. Two arrangements of the same two things would make\n * the header look like it meant something different in each.\n *\n * The trade is that the question no longer travels with the pane: the strip\n * stays put while the answers slide under it. That is the right way round\n * for a label, and the wrong way round for a question that is meant to\n * arrive with its answers — which is why giving it a title turns this off.\n */\n const questionOnStrip = frame && !titleNode;\n const stripQuestion = useMemo(\n () => (questionOnStrip ? questionOf(activeItem?.element) : null),\n [questionOnStrip, activeItem]\n );\n\n const context = useMemo(\n () => ({\n current,\n total,\n first,\n last,\n activeName,\n activeItem,\n answers,\n statusOf,\n invalid,\n setAnswer,\n toggleAnswer,\n goNext,\n goBack,\n skip,\n submit,\n shortcuts: shortcuts ?? null,\n framed: frame,\n questionOnStrip,\n blocked,\n }),\n [\n current,\n total,\n first,\n last,\n activeName,\n activeItem,\n answers,\n statusOf,\n invalid,\n setAnswer,\n toggleAnswer,\n goNext,\n goBack,\n skip,\n submit,\n shortcuts,\n frame,\n questionOnStrip,\n blocked,\n ]\n );\n\n const body = (\n \n );\n\n /*\n * A little more room under the strip than the `inset` header takes by\n * default. That default is set for a line of text; the ring is a shape whose\n * stroke reaches the edge of its box, with none of the leading a line of\n * text has, so against the same padding it sits closer to the panel than the\n * title beside it does.\n *\n * With no title, the progress centres rather than staying hard right. Right\n * is where it belongs when it is the trailing half of a pair; on its own at\n * the end of an otherwise empty strip it reads as something left over\n * rather than as the strip's subject.\n */\n const header = (\n \n {titleNode ??\n textChildren(stripQuestion, (text) => (\n // One line, and it truncates rather than wrapping: the strip is a\n // label for the panel under it, and a label that grows to two lines\n // pushes the answers down the screen every time a longer question\n // comes round.\n \n {text}\n \n ))}\n {progressNode ? {progressNode} : null}\n \n );\n\n return (\n \n {frame ? (\n /*\n * The panel floats in a recessed band rather than sitting flush in a\n * tray, and the band is where the actions go. A questionnaire is a\n * screen's worth of one decision repeated — answer, then move — and\n * the band draws that row as equal pills clear of the card the\n * question is written on, which is the distinction the old footer\n * section inside the panel did not make.\n */\n \n {header}\n {body}\n {footerNode ? (\n // Tighter than the band's own gap. That one is set for two or\n // three equal pills; this row is three actions of three different\n // widths, and every point between them comes off the longest label.\n {bandActions(footerNode)}\n ) : null}\n \n ) : (\n \n {titleNode || progressNode ? (\n // The same rule as the framed strip: paired with a title it sits\n // at the trailing edge, alone it centres.\n \n {titleNode}\n {progressNode}\n \n ) : null}\n {body}\n {footerNode}\n \n )}\n \n );\n}\nQuestionnaireRoot.displayName = 'Questionnaire';\n\ninterface QuestionnaireBodyProps {\n activeName: string | null;\n activeItem: ResolvedItem | null;\n /** Position of the active question, which is what says which way a move went. */\n activeIndex: number;\n framed: boolean;\n swipeable: boolean;\n canAdvance: boolean;\n canRetreat: boolean;\n onNext: () => void;\n onBack: () => void;\n}\n\n/**\n * The middle of the questionnaire: the one mounted question, the height it\n * animates to, and the drag that moves between them.\n */\nfunction QuestionnaireBody({\n activeName,\n activeItem,\n activeIndex,\n framed,\n swipeable,\n canAdvance,\n canRetreat,\n onNext,\n onBack,\n}: QuestionnaireBodyProps) {\n const slots = questionnaireVariants({ framed });\n\n const [width, setWidth] = useState(0);\n // -1 means nothing has been measured yet, so the first question takes its\n // height outright instead of growing into it from nothing.\n const height = useSharedValue(-1);\n const drag = useSharedValue(0);\n const paneRef = useRef(null);\n\n /*\n * Until the first question has been measured the pane stays in the flow, so\n * the body has a real height from the moment it first lays out. Absolute\n * from the start would measure zero on that first pass — the container's\n * only child would contribute nothing to it — and anything sizing itself to\n * this content would take the zero and keep it. A sheet set to wrap its\n * content is exactly that, and it would open around a question nobody can\n * see. Once the height is known the pane goes absolute, which is what lets\n * the question leaving and the one arriving overlap.\n */\n const [measured, setMeasured] = useState(false);\n\n /*\n * Which way the question slides in from, worked out from the move itself\n * rather than from whatever triggered it. A button, a swipe and a caller\n * setting `item` directly are all the same move to the reader, and only the\n * change in position says which direction it went.\n *\n * Read during render, not in an effect: the arriving question's animation is\n * fixed when it mounts, and an effect runs after that — which would leave\n * every transition playing the direction of the one before it.\n */\n const previousIndex = useRef(activeIndex);\n const direction: 1 | -1 = activeIndex >= previousIndex.current ? 1 : -1;\n const navigated = useRef(false);\n\n useEffect(() => {\n if (activeIndex !== previousIndex.current) {\n previousIndex.current = activeIndex;\n navigated.current = true;\n }\n }, [activeIndex]);\n\n const navigate = useCallback(\n (delta: 1 | -1) => {\n if (delta === 1) onNext();\n else onBack();\n },\n [onNext, onBack]\n );\n\n useEffect(() => {\n drag.value = 0;\n }, [activeName, drag]);\n\n // Move the reader onto the question that just arrived, so a screen reader\n // reads the new question rather than leaving focus where the button was.\n useEffect(() => {\n if (!navigated.current) return;\n const node = paneRef.current;\n if (!node) return;\n const tag = findNodeHandle(node);\n if (tag != null) AccessibilityInfo.setAccessibilityFocus(tag);\n }, [activeName]);\n\n const onPaneLayout = useCallback(\n (event: LayoutChangeEvent) => {\n const next = event.nativeEvent.layout.height;\n if (next <= 0) return;\n if (measured) {\n height.value = withTiming(next, { duration: HEIGHT_DURATION, easing: EASE });\n return;\n }\n // The first measurement is taken outright: there is no previous height\n // to travel from, and animating in from nothing is a question that\n // unfurls on arrival rather than one that is simply there.\n height.value = next;\n setMeasured(true);\n },\n [measured, height]\n );\n\n const heightStyle = useAnimatedStyle(() =>\n height.value < 0 ? {} : { height: height.value }\n );\n\n const dragStyle = useAnimatedStyle(() => ({\n transform: [{ translateX: drag.value }],\n }));\n\n const pan = useMemo(\n () =>\n Gesture.Pan()\n // A vertical scroll must always win: the questionnaire sits in a page\n // that scrolls, and a drag that is even slightly vertical belongs to it.\n .activeOffsetX([-12, 12])\n .failOffsetY([-8, 8])\n .enabled(swipeable && width > 0)\n .onUpdate((event) => {\n const forward = event.translationX < 0;\n if ((forward && !canAdvance) || (!forward && !canRetreat)) {\n // Nowhere to go this way — let it move a little so the drag is\n // acknowledged, then stop.\n drag.value = event.translationX * 0.2;\n return;\n }\n drag.value = event.translationX;\n })\n .onEnd((event) => {\n const threshold = width * SWIPE_FRACTION;\n if (event.translationX < -threshold && canAdvance) {\n runOnJS(navigate)(1);\n } else if (event.translationX > threshold && canRetreat) {\n runOnJS(navigate)(-1);\n }\n // Springs back either way. When the move is taken the question is\n // replaced outright, so this only ever shows on a move that was not.\n drag.value = withSpring(0, { damping: 20, stiffness: 220, mass: 0.6 });\n }),\n [swipeable, width, canAdvance, canRetreat, drag, navigate]\n );\n\n /*\n * The question arrives from the side it is coming from, over a fraction of\n * the width rather than the whole of it — this is a widget in a page, not a\n * screen, and a slide the full width of it reads as the page moving.\n *\n * Written out rather than assembled from the stock builders because those\n * carry an initial opacity and nothing else: the distance is the part worth\n * controlling here, and none of them lets it be set.\n */\n const offset = width > 0 ? width * SLIDE_FRACTION : 60;\n const entering = useCallback(() => {\n 'worklet';\n return {\n initialValues: { opacity: 0, transform: [{ translateX: direction * offset }] },\n animations: {\n opacity: withTiming(1, { duration: ENTER_DURATION, easing: EASE }),\n transform: [{ translateX: withTiming(0, { duration: ENTER_DURATION, easing: EASE }) }],\n },\n };\n }, [direction, offset]);\n\n const exiting = FadeOut.duration(EXIT_DURATION);\n\n return (\n \n setWidth(event.nativeEvent.layout.width)}\n >\n {/*\n * Two views, not one: the entering animation drives this one's\n * transform, so the drag needs a view of its own underneath it rather\n * than a second transform on the same style the animation is writing.\n */}\n \n \n \n {activeItem?.element ?? null}\n \n \n \n \n \n );\n}\n\nexport interface QuestionnaireTitleProps extends ViewProps {\n className?: string;\n children?: ReactNode;\n}\n\n/**\n * Names the questionnaire as a whole, in the frame's header strip. The\n * current question's own prompt is `Questionnaire.Question`.\n */\nfunction QuestionnaireTitle({ className, children, ...props }: QuestionnaireTitleProps) {\n return (\n \n {textChildren(children, (text) => (\n \n {text}\n \n ))}\n \n );\n}\nQuestionnaireTitle.displayName = 'Questionnaire.Title';\n\n/** What a custom progress indicator is told about where the reader is. */\nexport interface QuestionnaireProgressState {\n /** One-based position of the active question. */\n current: number;\n /** How many questions are enabled. */\n total: number;\n first: boolean;\n last: boolean;\n}\n\n/** How the position is drawn. */\nexport type QuestionnaireProgressVariant = 'ring' | 'pips' | 'numbers' | 'count';\n\nexport interface QuestionnaireProgressProps {\n className?: string;\n /**\n * `ring` is an arc that sweeps round as the reader advances — how far\n * through the set they are, without saying how many questions there are.\n * It is the only one that holds its size and its meaning at any length,\n * which is why it is the default.\n *\n * `pips` is a bar per question, filled up to the one being asked and widened\n * on it. `numbers` counts them out instead, which is what you want when the\n * reader will be sent back to a particular question. `count` is the plain\n * `Question 2 of 5`.\n *\n * `pips` and `numbers` fall back to `count` past eight questions, where\n * neither is countable at a glance any more. `ring` never does.\n */\n variant?: QuestionnaireProgressVariant;\n /**\n * Replace the indicator entirely. Given a function, it is called with the\n * position — for a bar, a row of dots, or a percentage.\n */\n children?: ReactNode | ((state: QuestionnaireProgressState) => ReactNode);\n}\n\nconst AnimatedCircle = Animated.createAnimatedComponent(Circle);\n\n/**\n * How far through the set, as an arc.\n *\n * A fraction rather than a count, which is what lets one ring stand for three\n * questions or thirty: the marks that name each question individually stop\n * being countable somewhere around eight, and this never has that problem.\n * What it gives up is any answer to \"how many left\" — reach for `numbers`\n * where that matters.\n *\n * Drawn with `strokeDasharray` rather than `strokeDashoffset`: a dash the\n * length of the filled arc and a gap the length of the rest leaves exactly one\n * visible stroke, and its length is the only number that has to be animated.\n * The circle is turned back a quarter because a stroke starts at three\n * o'clock, and an arc that begins there reads as a gauge already part-way\n * along.\n */\nfunction ProgressRing({ current, total }: { current: number; total: number }) {\n const reduceMotion = useReducedMotion();\n const tokens = useCSSVariable(['--color-primary', '--color-border']);\n // Narrowed on the way out because `useCSSVariable` resolves to a number for\n // any token that happens to be one.\n const fillColor = typeof tokens[0] === 'string' ? tokens[0] : 'rgb(120,120,255)';\n const trackColor = typeof tokens[1] === 'string' ? tokens[1] : 'rgba(128,128,128,0.2)';\n\n const radius = (RING_SIZE - RING_STROKE) / 2;\n const circumference = 2 * Math.PI * radius;\n const centre = RING_SIZE / 2;\n\n const fraction = total > 0 ? Math.min(Math.max(current / total, 0), 1) : 0;\n const filled = useSharedValue(fraction);\n\n useEffect(() => {\n filled.value = reduceMotion\n ? fraction\n : withTiming(fraction, { duration: PIP_DURATION, easing: EASE });\n }, [fraction, reduceMotion, filled]);\n\n const arc = useAnimatedProps(() => ({\n strokeDasharray: [circumference * filled.value, circumference],\n }));\n\n return (\n \n \n \n \n );\n}\n\n/**\n * One question's worth of the track. Filled once it has been reached, and the\n * one being asked is drawn wider than the rest so the reader's place in the\n * set is legible without counting.\n */\nfunction ProgressPip({ filled, active }: { filled: boolean; active: boolean }) {\n const fill = useSharedValue(filled ? 1 : 0);\n\n useEffect(() => {\n fill.value = withTiming(filled ? 1 : 0, { duration: PIP_DURATION, easing: EASE });\n }, [filled, fill]);\n\n const fillStyle = useAnimatedStyle(() => ({ opacity: fill.value }));\n\n return (\n \n \n \n );\n}\n\n/**\n * The same position, counted out. A number says which question this is in a\n * way a bar cannot — worth it where the reader is going to be asked to go back\n * to one of them, since a bar gives them nothing to go back *to*.\n */\nfunction ProgressNumber({\n value,\n done,\n active,\n}: {\n value: number;\n done: boolean;\n active: boolean;\n}) {\n return (\n \n \n {value}\n \n \n );\n}\n\n/** Where the reader is in the set, announced as a progress bar. */\nfunction QuestionnaireProgress({\n className,\n variant = 'ring',\n children,\n}: QuestionnaireProgressProps) {\n const { current, total, first, last } = useQuestionnaire('Questionnaire.Progress');\n const label = `Question ${current} of ${total}`;\n\n /*\n * Marks while they can still be counted, the count itself once they cannot.\n * Twenty of either is a texture rather than a number, and the text says the\n * same thing in less room. The ring is exempt: it never claimed to be\n * countable, so there is nothing for a long set to take away from it.\n */\n const drawable =\n variant !== 'count' && variant !== 'ring' && total > 0 && total <= MAX_PIPS;\n\n const fallback = variant === 'ring' ? (\n \n ) : drawable ? (\n \n {Array.from({ length: total }, (_, index) =>\n variant === 'numbers' ? (\n \n ) : (\n \n )\n )}\n \n ) : (\n \n {label}\n \n );\n\n const content =\n typeof children === 'function' ? children({ current, total, first, last }) : (children ?? fallback);\n\n return (\n \n {textChildren(content, (text) => (\n \n {text}\n \n ))}\n \n );\n}\nQuestionnaireProgress.displayName = 'Questionnaire.Progress';\n\nexport interface QuestionnaireItemProps extends Omit {\n className?: string;\n /** Unique name — the key this question's answer is stored under. */\n name: string;\n /** Blocks the way forward until it has an answer. */\n required?: boolean;\n /** Accepts more than one answer, so its answer is an array. */\n multiple?: boolean;\n /** Left out of the count and never navigated to. */\n disabled?: boolean;\n /** Mark the question at fault from a validator of your own. */\n invalid?: boolean;\n /** Called whenever this question moves between unanswered, answered and skipped. */\n onStatusChange?: (status: QuestionnaireItemStatus) => void;\n children?: ReactNode;\n}\n\n/**\n * One question. Only the active one is mounted, so anything it holds is built\n * when it is reached and thrown away when it is left.\n */\nfunction QuestionnaireItem({\n className,\n name,\n required,\n multiple,\n invalid: invalidProp,\n children,\n // Read by the root off this element rather than used here.\n disabled: _disabled,\n onStatusChange: _onStatusChange,\n ...props\n}: QuestionnaireItemProps) {\n const { invalid: invalidNames } = useQuestionnaire('Questionnaire.Item');\n\n /*\n * Collected so a typed answer can be told from a chosen one: whatever the\n * question is holding that is not one of these values came from the text\n * field, and that is what puts it back in the field on the way back.\n */\n const choiceValues = useMemo(() => {\n const values = new Set();\n const walk = (node: ReactNode) => {\n Children.forEach(node, (child) => {\n if (!isValidElement(child)) return;\n if (child.type === QuestionnaireChoice) {\n values.add((child.props as QuestionnaireChoiceProps).value);\n return;\n }\n walk((child.props as { children?: ReactNode }).children);\n });\n };\n walk(children);\n return values;\n }, [children]);\n\n const context = useMemo(\n () => ({\n name,\n required: !!required,\n multiple: !!multiple,\n invalid: !!invalidProp || invalidNames.has(name),\n choiceValues,\n }),\n [name, required, multiple, invalidProp, invalidNames, choiceValues]\n );\n\n return (\n \n \n {textChildren(children)}\n \n \n );\n}\nQuestionnaireItem.displayName = 'Questionnaire.Item';\n\nexport interface QuestionnaireQuestionProps extends ViewProps {\n className?: string;\n children?: ReactNode;\n}\n\n/** The question being asked. */\nfunction QuestionnaireQuestion({ className, children, ...props }: QuestionnaireQuestionProps) {\n const { questionOnStrip } = useQuestionnaire('Questionnaire.Question');\n\n // Drawn on the header strip instead, where an untitled questionnaire uses it\n // as its label. Rendering here as well would ask the same thing twice.\n if (questionOnStrip) return null;\n\n return (\n \n {textChildren(children, (text) => (\n // `text-pretty` rather than a hard wrap: a question is a sentence, and\n // the one thing worse than two lines is a second line holding one word.\n \n {text}\n \n ))}\n \n );\n}\nQuestionnaireQuestion.displayName = 'Questionnaire.Question';\n\nexport interface QuestionnaireDescriptionProps extends ViewProps {\n className?: string;\n children?: ReactNode;\n}\n\n/** A line under the question — what to consider, or that it can be skipped. */\nfunction QuestionnaireDescription({\n className,\n children,\n ...props\n}: QuestionnaireDescriptionProps) {\n return (\n // Pulled up against the question it belongs to: the pane's gap is the\n // distance between one part and the next, and these two are one part.\n \n {textChildren(children, (text) => (\n \n {text}\n \n ))}\n \n );\n}\nQuestionnaireDescription.displayName = 'Questionnaire.Description';\n\nexport interface QuestionnaireChoicesProps extends Omit {\n className?: string;\n children?: ReactNode;\n}\n\n/**\n * The answers to a question. It hands each choice its shortcut badge, counting\n * only the ones that can be picked so a disabled answer does not take a letter\n * out of the sequence with it.\n */\nfunction QuestionnaireChoices({ className, children, ...props }: QuestionnaireChoicesProps) {\n const { shortcuts } = useQuestionnaire('Questionnaire.Choices');\n const slots = questionnaireVariants();\n\n const badged = useMemo(() => {\n if (!shortcuts) return children;\n let index = 0;\n return Children.map(children, (child) => {\n if (!isValidElement(child) || child.type !== QuestionnaireChoice) return child;\n if ((child.props as QuestionnaireChoiceProps).disabled) return child;\n const key = shortcutAt(shortcuts, index++);\n if (!key) return child;\n return (\n \n {child}\n \n );\n });\n }, [children, shortcuts]);\n\n return (\n \n {textChildren(badged)}\n \n );\n}\nQuestionnaireChoices.displayName = 'Questionnaire.Choices';\n\nexport interface QuestionnaireChoiceProps {\n className?: string;\n /** The value recorded when this answer is picked. */\n value: string;\n /** The answer itself. */\n label?: string;\n /** A line under the label, for an answer that needs explaining. */\n description?: string;\n disabled?: boolean;\n children?: ReactNode;\n}\n\n/**\n * One fixed answer — the whole row is the target, with the indicator reading\n * as confirmation rather than as the thing to aim at.\n */\nconst QuestionnaireChoice = forwardRef(\n ({ className, value, label, description, disabled, children }, ref) => {\n const { answers, toggleAnswer } = useQuestionnaire('Questionnaire.Choice');\n const item = useQuestionnaireItem('Questionnaire.Choice');\n const shortcut = useContext(ShortcutContext);\n\n const answer = answers[item.name];\n const selected = Array.isArray(answer) ? answer.includes(value) : answer === value;\n\n const progress = useSharedValue(selected ? 1 : 0);\n\n useEffect(() => {\n progress.value = selected\n ? withSpring(1, { damping: 15, stiffness: 300, mass: 0.5 })\n : withTiming(0, { duration: 120 });\n }, [selected, progress]);\n\n const markStyle = useAnimatedStyle(() => ({\n opacity: progress.value,\n transform: [{ scale: progress.value }],\n }));\n\n const checkColor = useCSSVariable('--color-primary-foreground');\n const slots = choiceVariants({\n multiple: item.multiple,\n selected,\n disabled: !!disabled,\n invalid: item.invalid && !selected,\n });\n\n const labelled = label ? (\n {label}\n ) : (\n textChildren(children, (text) => {text})\n );\n\n return (\n toggleAnswer(item.name, value, item.multiple)}\n className={slots.row({ className })}\n >\n \n \n {item.multiple ? (\n \n ) : (\n \n )}\n \n \n \n {labelled}\n {description ? {description} : null}\n \n {shortcut ? (\n \n {shortcut}\n \n ) : null}\n \n );\n }\n);\nQuestionnaireChoice.displayName = 'Questionnaire.Choice';\n\nexport interface QuestionnaireInputProps\n extends Omit {\n className?: string;\n}\n\n/**\n * An answer that is not on the list. It holds whatever the question is\n * answered with that none of its own choices offers, so picking a choice\n * empties it and typing clears the choice — one answer to one question, with\n * neither part having to know about the other.\n */\nconst QuestionnaireInput = forwardRef(\n ({ className, ...props }, ref) => {\n const { answers, setAnswer } = useQuestionnaire('Questionnaire.Input');\n const item = useQuestionnaireItem('Questionnaire.Input');\n\n const answer = answers[item.name];\n const freeform = useMemo(() => {\n if (Array.isArray(answer)) {\n return answer.find((entry) => !item.choiceValues.has(entry)) ?? '';\n }\n return typeof answer === 'string' && !item.choiceValues.has(answer) ? answer : '';\n }, [answer, item.choiceValues]);\n\n const onChangeText = useCallback(\n (text: string) => {\n if (!item.multiple) {\n setAnswer(item.name, text);\n return;\n }\n // Replace this question's one typed entry, leaving every picked one alone.\n const list = Array.isArray(answer) ? answer : [];\n const fixed = list.filter((entry) => item.choiceValues.has(entry));\n setAnswer(item.name, text.length > 0 ? [...fixed, text] : fixed);\n },\n [item.multiple, item.name, item.choiceValues, answer, setAnswer]\n );\n\n return (\n \n );\n }\n);\nQuestionnaireInput.displayName = 'Questionnaire.Input';\n\nexport interface QuestionnaireErrorProps extends ViewProps {\n className?: string;\n /** Replace the default message. */\n children?: ReactNode;\n}\n\n/** Why the way forward is closed. Nothing until the question fails to pass. */\nfunction QuestionnaireError({ className, children, ...props }: QuestionnaireErrorProps) {\n const item = useQuestionnaireItem('Questionnaire.Error');\n const slots = questionnaireVariants();\n\n if (!item.invalid) return null;\n\n return (\n \n {textChildren(children ?? DEFAULT_ERROR, (text) => (\n {text}\n ))}\n \n );\n}\nQuestionnaireError.displayName = 'Questionnaire.Error';\n\nexport interface QuestionnaireFooterProps extends ViewProps {\n className?: string;\n children?: ReactNode;\n}\n\n/**\n * The action row, in its own section at the foot of the panel. It stays put\n * while the question above it changes, which is what keeps the button under\n * the thumb where it was.\n */\nfunction QuestionnaireFooter({ className, children, ...props }: QuestionnaireFooterProps) {\n const { framed } = useQuestionnaire('Questionnaire.Footer');\n const slots = questionnaireVariants({ framed });\n return (\n \n {textChildren(children)}\n \n );\n}\nQuestionnaireFooter.displayName = 'Questionnaire.Footer';\n\n/** What a navigation button is told about the question it is acting on. */\nexport interface QuestionnaireActionState {\n /** Whether the action applies to the active question at all. */\n visible: boolean;\n /** Where the active question stands. */\n status: QuestionnaireItemStatus;\n}\n\nexport interface QuestionnaireActionProps extends Omit {\n /** Replace the label. Given a function, it is called with the question's state. */\n children?: ReactNode | ((state: QuestionnaireActionState) => ReactNode);\n}\n\n/**\n * Builds one of the four navigation buttons. They differ only in when they\n * apply, what they do and how they are labelled by default, so they are made\n * rather than written out four times.\n */\ninterface ActionConfig {\n displayName: string;\n label: string;\n variant: NonNullable;\n /** A chevron on the buttons that move, and nothing on the ones that do not. */\n startContent?: ReactNode;\n endContent?: ReactNode;\n /**\n * Dim this one while the active question is required and unanswered.\n *\n * It stays pressable on purpose. A disabled button says no without saying\n * why, and on a question whose answers have scrolled out of view that is the\n * whole of the feedback; pressing this one puts the reason under the\n * question instead. Dimming is what stops it promising something it will not\n * do — the look says not yet, the press says why not.\n */\n dimWhenBlocked?: boolean;\n use: (context: QuestionnaireContextValue) => { visible: boolean; onPress: () => void };\n}\n\nfunction createAction({\n displayName,\n label: fallbackLabel,\n variant: fallbackVariant,\n startContent,\n endContent,\n dimWhenBlocked,\n use,\n}: ActionConfig) {\n function Action({ children, variant, className, ...props }: QuestionnaireActionProps) {\n const context = useQuestionnaire(displayName);\n const { visible, onPress } = use(context);\n const status = context.activeName ? context.statusOf(context.activeName) : 'unanswered';\n\n // Not rendered at all rather than hidden: React Native has no `inert`, and\n // a button left in the tree is one a screen reader still offers.\n if (!visible) return null;\n\n const label = typeof children === 'function' ? children({ visible, status }) : children;\n const dimmed = !!dimWhenBlocked && context.blocked;\n\n return (\n \n {label ?? fallbackLabel}\n \n );\n }\n\n Action.displayName = displayName;\n return Action;\n}\n\n/** Back to the previous question. Absent on the first one. */\nconst QuestionnaireBack = createAction({\n displayName: 'Questionnaire.Back',\n label: 'Back',\n variant: 'ghost',\n startContent: ,\n use: (context) => ({ visible: !context.first, onPress: context.goBack }),\n});\n\n/** Records that an optional question was deliberately left out. */\nconst QuestionnaireSkip = createAction({\n displayName: 'Questionnaire.Skip',\n label: 'Skip',\n variant: 'ghost',\n use: (context) => ({\n visible: !!context.activeItem && !context.activeItem.required,\n onPress: context.skip,\n }),\n});\n\n/** On to the next question, if the current one lets go. Absent on the last. */\nconst QuestionnaireNext = createAction({\n displayName: 'Questionnaire.Next',\n label: 'Continue',\n variant: 'primary',\n endContent: ,\n dimWhenBlocked: true,\n use: (context) => ({ visible: !context.last, onPress: context.goNext }),\n});\n\n/** Hands over every answer. Only on the last question. */\nconst QuestionnaireSubmit = createAction({\n displayName: 'Questionnaire.Submit',\n label: 'Submit',\n variant: 'primary',\n dimWhenBlocked: true,\n use: (context) => ({ visible: context.last, onPress: context.submit }),\n});\n\n/**\n * A flexible gap for the footer, so the trailing buttons sit against the\n * trailing edge whether or not `Questionnaire.Back` is showing.\n */\nfunction QuestionnaireSpacer({ className, ...props }: ViewProps) {\n return ;\n}\nQuestionnaireSpacer.displayName = 'Questionnaire.Spacer';\n\nexport const Questionnaire = Object.assign(QuestionnaireRoot, {\n Title: QuestionnaireTitle,\n Progress: QuestionnaireProgress,\n Item: QuestionnaireItem,\n Question: QuestionnaireQuestion,\n Description: QuestionnaireDescription,\n Choices: QuestionnaireChoices,\n Choice: QuestionnaireChoice,\n Input: QuestionnaireInput,\n Error: QuestionnaireError,\n Footer: QuestionnaireFooter,\n Spacer: QuestionnaireSpacer,\n Back: QuestionnaireBack,\n Skip: QuestionnaireSkip,\n Next: QuestionnaireNext,\n Submit: QuestionnaireSubmit,\n});\n" } ] } diff --git a/apps/docs/public/r/radio-group.json b/apps/docs/public/r/radio-group.json index ead3dfdc6..c62dd34c1 100644 --- a/apps/docs/public/r/radio-group.json +++ b/apps/docs/public/r/radio-group.json @@ -4,6 +4,7 @@ "description": "Single-select list of options.", "registryDependencies": [ "cn", + "refs", "text" ], "dependencies": [ @@ -15,7 +16,7 @@ { "path": "ui/radio-group.tsx", "type": "registry:ui", - "content": "/**\n * RadioGroup — one choice from a set.\n *\n * The group owns the value and hands each item its selected state through\n * context, so an item is a thin pressable that reports its own value up and\n * reads selection back down.\n *\n * Two shapes, chosen on the group and inherited by every item: `dot` is the\n * classic label-beside-a-disc row; `card` turns the whole surface into the\n * target — for pickable options where the disc is a confirmation rather than\n * the thing you aim at. The card is the same treatment `Checkbox` offers, so a\n * form mixing single- and multi-select choices reads as one family.\n *\n * ```tsx\n * \n * \n * \n * \n * ```\n */\nimport {\n createContext,\n forwardRef,\n useContext,\n useEffect,\n useMemo,\n type ReactNode,\n} from 'react';\nimport { Pressable, View, type ViewProps } from 'react-native';\nimport Animated, {\n useAnimatedStyle,\n useSharedValue,\n withSpring,\n withTiming,\n} from 'react-native-reanimated';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport { cn } from '@/lib/cn';\nimport { Text, textChildren } from '@/components/ui/text';\nimport { requestRadioValueChange } from '@/components/ui/radio-group-value';\n\ntype RadioVariant = 'dot' | 'card';\ntype RadioOrientation = 'vertical' | 'horizontal';\n\nconst itemVariants = tv({\n slots: {\n // `self-start` matters: the group is a column, so without it Yoga stretches\n // the row to the full width and the dead space to the right of the label\n // becomes part of the target. Tapping nothing would select the option.\n row: 'min-h-12 min-w-12 flex-row items-center gap-2.5 self-start',\n indicator:\n 'h-5 w-5 items-center justify-center rounded-full border border-input bg-background',\n // Half the disc it sits in, at every size.\n dot: 'h-2.5 w-2.5 rounded-full bg-primary',\n label: 'text-sm text-foreground',\n description: 'text-xs text-muted-foreground',\n },\n variants: {\n /*\n * The disc, its dot and the text beside it move together, on the same\n * steps as Checkbox so the two line up in one form. The row stays 48\n * points tall at every size, because that is the touch target.\n *\n * Listed before `variant` so the card keeps the label weight it sets.\n */\n size: {\n xs: {\n row: 'gap-2',\n indicator: 'h-3.5 w-3.5',\n dot: 'h-1.5 w-1.5',\n label: 'text-xs',\n description: 'text-[11px]',\n },\n sm: {\n row: 'gap-2',\n indicator: 'h-4 w-4',\n dot: 'h-2 w-2',\n label: 'text-[13px]',\n },\n md: {},\n lg: {\n row: 'gap-3',\n indicator: 'h-6 w-6',\n dot: 'h-3 w-3',\n label: 'text-base',\n description: 'text-sm',\n },\n xl: {\n row: 'gap-3',\n indicator: 'h-7 w-7',\n dot: 'h-3.5 w-3.5',\n label: 'text-lg',\n description: 'text-sm',\n },\n },\n variant: {\n dot: {},\n card: {\n // The whole surface is the target; the disc moves to the trailing edge\n // as a confirmation of the row, not the affordance for it. Here the\n // full width is deliberate, so it takes `self-stretch` back.\n row: 'w-full self-stretch items-start justify-between gap-3 rounded-xl border border-border bg-card p-4',\n label: 'text-base font-medium',\n },\n },\n selected: {\n true: {},\n },\n disabled: {\n true: { row: 'opacity-50' },\n },\n /**\n * A card in a row shares the width rather than filling it, so two or three\n * options sit side by side instead of each taking the whole line.\n */\n horizontal: {\n true: {},\n },\n },\n compoundVariants: [\n { variant: 'card', selected: true, class: { row: 'border-primary bg-accent' } },\n // Share the row instead of filling it, keeping the disc on the trailing\n // edge of each narrower card.\n { variant: 'card', horizontal: true, class: { row: 'w-auto flex-1' } },\n ],\n defaultVariants: {\n variant: 'dot',\n size: 'md',\n },\n});\n\n/** How big a group's discs and labels are. */\ntype RadioSize = NonNullable['size']>;\n\ninterface RadioGroupContextValue {\n value: string | undefined;\n onValueChange: (value: string) => void;\n disabled?: boolean;\n variant: RadioVariant;\n size: RadioSize;\n orientation: RadioOrientation;\n}\n\nconst RadioGroupContext = createContext(null);\n\nexport interface RadioGroupProps extends ViewProps {\n className?: string;\n value?: string;\n onValueChange: (value: string) => void;\n disabled?: boolean;\n /**\n * `dot` is the label-beside-a-disc row. `card` makes the whole surface the\n * target and highlights the selected option — for a plan picker or a\n * settings choice where each option carries a description.\n */\n variant?: RadioVariant;\n /**\n * How big every option's disc and label are. Set on the group rather than\n * per option, because options of different sizes read as though one of them\n * matters more. The row keeps a 48-point target at every size.\n */\n size?: RadioSize;\n /**\n * `horizontal` lays the options out along a row that wraps — for two or\n * three short choices, where a stacked list wastes the width and reads as\n * longer than it is.\n */\n orientation?: RadioOrientation;\n children: ReactNode;\n}\n\nconst RadioGroupRoot = forwardRef(\n (\n {\n className,\n value,\n onValueChange,\n disabled,\n variant = 'dot',\n size = 'md',\n orientation = 'vertical',\n children,\n ...props\n },\n ref\n ) => {\n const context = useMemo(\n () => ({ value, onValueChange, disabled, variant, size, orientation }),\n [value, onValueChange, disabled, variant, size, orientation]\n );\n\n return (\n \n \n {textChildren(children)}\n \n \n );\n }\n);\nRadioGroupRoot.displayName = 'RadioGroup';\n\nexport interface RadioGroupItemProps {\n /**\n * Styles the pressable row — layout, border, fill. It does not reach the\n * label: text colour is not inherited from a view in React Native, so use\n * `labelClassName` for that.\n */\n className?: string;\n value: string;\n label?: string;\n /** Classes for the label text, merged over the defaults — e.g. `text-black`. */\n labelClassName?: string;\n /** Secondary line under the label. Most at home in the `card` variant. */\n description?: string;\n /** Classes for the description text, merged over the defaults. */\n descriptionClassName?: string;\n disabled?: boolean;\n /** Hide the disc entirely — for a card whose selected fill is enough. */\n hideIndicator?: boolean;\n children?: ReactNode;\n}\n\nconst RadioGroupItem = forwardRef(\n (\n {\n className,\n value,\n label,\n labelClassName,\n description,\n descriptionClassName,\n disabled: itemDisabled,\n hideIndicator,\n children,\n },\n ref\n ) => {\n const context = useContext(RadioGroupContext);\n if (!context) {\n throw new Error('RadioGroup.Item must be used within a ');\n }\n\n const selected = context.value === value;\n const disabled = itemDisabled || context.disabled;\n const variant = context.variant;\n const progress = useSharedValue(selected ? 1 : 0);\n\n useEffect(() => {\n progress.value = selected\n ? withSpring(1, { damping: 15, stiffness: 300, mass: 0.5 })\n : withTiming(0, { duration: 120 });\n }, [selected, progress]);\n\n const dotStyle = useAnimatedStyle(() => ({\n opacity: progress.value,\n transform: [{ scale: progress.value }],\n }));\n\n const slots = itemVariants({\n variant,\n size: context.size,\n selected,\n disabled: !!disabled,\n horizontal: context.orientation === 'horizontal',\n });\n\n const indicator = hideIndicator ? null : (\n \n \n \n );\n\n // Either way the label is a string in a row that is otherwise all views,\n // so children given instead of the prop are dressed the same way rather\n // than reaching the pressable bare.\n const labelled = label ? (\n {label}\n ) : (\n textChildren(children, (text) => (\n {text}\n ))\n );\n\n // The card lays its text out first so the disc sits at the trailing edge;\n // the dot row keeps the classic disc-then-label order.\n const body =\n variant === 'card' ? (\n <>\n \n {labelled}\n {description ? (\n \n {description}\n \n ) : null}\n \n {indicator}\n \n ) : (\n <>\n {indicator}\n {labelled}\n \n );\n\n return (\n \n requestRadioValueChange(context.value, value, context.onValueChange)\n }\n className={slots.row({ className })}\n >\n {body}\n \n );\n }\n);\nRadioGroupItem.displayName = 'RadioGroup.Item';\n\nexport const RadioGroup = Object.assign(RadioGroupRoot, {\n Item: RadioGroupItem,\n});\n\nexport type { RadioVariant, RadioSize };\n" + "content": "/**\n * RadioGroup — one choice from a set.\n *\n * The group owns the value and hands each item its selected state through\n * context, so an item is a thin pressable that reports its own value up and\n * reads selection back down.\n *\n * Two shapes, chosen on the group and inherited by every item: `dot` is the\n * classic label-beside-a-disc row; `card` turns the whole surface into the\n * target — for pickable options where the disc is a confirmation rather than\n * the thing you aim at. The card is the same treatment `Checkbox` offers, so a\n * form mixing single- and multi-select choices reads as one family.\n *\n * ```tsx\n * \n * \n * \n * \n * ```\n */\nimport {\n createContext,\n forwardRef,\n useContext,\n useEffect,\n useMemo,\n type ReactNode,\n} from 'react';\nimport { Pressable, View, type ViewProps } from 'react-native';\nimport Animated, {\n useAnimatedStyle,\n useSharedValue,\n withSpring,\n withTiming,\n} from 'react-native-reanimated';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport { cn } from '@/lib/cn';\nimport { Text, textChildren } from '@/components/ui/text';\nimport { requestRadioValueChange } from '@/components/ui/radio-group-value';\nimport type { ViewRef } from '@/components/ui/refs';\n\ntype RadioVariant = 'dot' | 'card';\ntype RadioOrientation = 'vertical' | 'horizontal';\n\nconst itemVariants = tv({\n slots: {\n // `self-start` matters: the group is a column, so without it Yoga stretches\n // the row to the full width and the dead space to the right of the label\n // becomes part of the target. Tapping nothing would select the option.\n row: 'min-h-12 min-w-12 flex-row items-center gap-2.5 self-start',\n indicator:\n 'h-5 w-5 items-center justify-center rounded-full border border-input bg-background',\n // Half the disc it sits in, at every size.\n dot: 'h-2.5 w-2.5 rounded-full bg-primary',\n label: 'text-sm text-foreground',\n description: 'text-xs text-muted-foreground',\n },\n variants: {\n /*\n * The disc, its dot and the text beside it move together, on the same\n * steps as Checkbox so the two line up in one form. The row stays 48\n * points tall at every size, because that is the touch target.\n *\n * Listed before `variant` so the card keeps the label weight it sets.\n */\n size: {\n xs: {\n row: 'gap-2',\n indicator: 'h-3.5 w-3.5',\n dot: 'h-1.5 w-1.5',\n label: 'text-xs',\n description: 'text-[11px]',\n },\n sm: {\n row: 'gap-2',\n indicator: 'h-4 w-4',\n dot: 'h-2 w-2',\n label: 'text-[13px]',\n },\n md: {},\n lg: {\n row: 'gap-3',\n indicator: 'h-6 w-6',\n dot: 'h-3 w-3',\n label: 'text-base',\n description: 'text-sm',\n },\n xl: {\n row: 'gap-3',\n indicator: 'h-7 w-7',\n dot: 'h-3.5 w-3.5',\n label: 'text-lg',\n description: 'text-sm',\n },\n },\n variant: {\n dot: {},\n card: {\n // The whole surface is the target; the disc moves to the trailing edge\n // as a confirmation of the row, not the affordance for it. Here the\n // full width is deliberate, so it takes `self-stretch` back.\n row: 'w-full self-stretch items-start justify-between gap-3 rounded-xl border border-border bg-card p-4',\n label: 'text-base font-medium',\n },\n },\n selected: {\n true: {},\n },\n disabled: {\n true: { row: 'opacity-50' },\n },\n /**\n * A card in a row shares the width rather than filling it, so two or three\n * options sit side by side instead of each taking the whole line.\n */\n horizontal: {\n true: {},\n },\n },\n compoundVariants: [\n { variant: 'card', selected: true, class: { row: 'border-primary bg-accent' } },\n // Share the row instead of filling it, keeping the disc on the trailing\n // edge of each narrower card.\n { variant: 'card', horizontal: true, class: { row: 'w-auto flex-1' } },\n ],\n defaultVariants: {\n variant: 'dot',\n size: 'md',\n },\n});\n\n/** How big a group's discs and labels are. */\ntype RadioSize = NonNullable['size']>;\n\ninterface RadioGroupContextValue {\n value: string | undefined;\n onValueChange: (value: string) => void;\n disabled?: boolean;\n variant: RadioVariant;\n size: RadioSize;\n orientation: RadioOrientation;\n}\n\nconst RadioGroupContext = createContext(null);\n\nexport interface RadioGroupProps extends ViewProps {\n className?: string;\n value?: string;\n onValueChange: (value: string) => void;\n disabled?: boolean;\n /**\n * `dot` is the label-beside-a-disc row. `card` makes the whole surface the\n * target and highlights the selected option — for a plan picker or a\n * settings choice where each option carries a description.\n */\n variant?: RadioVariant;\n /**\n * How big every option's disc and label are. Set on the group rather than\n * per option, because options of different sizes read as though one of them\n * matters more. The row keeps a 48-point target at every size.\n */\n size?: RadioSize;\n /**\n * `horizontal` lays the options out along a row that wraps — for two or\n * three short choices, where a stacked list wastes the width and reads as\n * longer than it is.\n */\n orientation?: RadioOrientation;\n children: ReactNode;\n}\n\nconst RadioGroupRoot = forwardRef(\n (\n {\n className,\n value,\n onValueChange,\n disabled,\n variant = 'dot',\n size = 'md',\n orientation = 'vertical',\n children,\n ...props\n },\n ref\n ) => {\n const context = useMemo(\n () => ({ value, onValueChange, disabled, variant, size, orientation }),\n [value, onValueChange, disabled, variant, size, orientation]\n );\n\n return (\n \n \n {textChildren(children)}\n \n \n );\n }\n);\nRadioGroupRoot.displayName = 'RadioGroup';\n\nexport interface RadioGroupItemProps {\n /**\n * Styles the pressable row — layout, border, fill. It does not reach the\n * label: text colour is not inherited from a view in React Native, so use\n * `labelClassName` for that.\n */\n className?: string;\n value: string;\n label?: string;\n /** Classes for the label text, merged over the defaults — e.g. `text-black`. */\n labelClassName?: string;\n /** Secondary line under the label. Most at home in the `card` variant. */\n description?: string;\n /** Classes for the description text, merged over the defaults. */\n descriptionClassName?: string;\n disabled?: boolean;\n /** Hide the disc entirely — for a card whose selected fill is enough. */\n hideIndicator?: boolean;\n children?: ReactNode;\n}\n\nconst RadioGroupItem = forwardRef(\n (\n {\n className,\n value,\n label,\n labelClassName,\n description,\n descriptionClassName,\n disabled: itemDisabled,\n hideIndicator,\n children,\n },\n ref\n ) => {\n const context = useContext(RadioGroupContext);\n if (!context) {\n throw new Error('RadioGroup.Item must be used within a ');\n }\n\n const selected = context.value === value;\n const disabled = itemDisabled || context.disabled;\n const variant = context.variant;\n const progress = useSharedValue(selected ? 1 : 0);\n\n useEffect(() => {\n progress.value = selected\n ? withSpring(1, { damping: 15, stiffness: 300, mass: 0.5 })\n : withTiming(0, { duration: 120 });\n }, [selected, progress]);\n\n const dotStyle = useAnimatedStyle(() => ({\n opacity: progress.value,\n transform: [{ scale: progress.value }],\n }));\n\n const slots = itemVariants({\n variant,\n size: context.size,\n selected,\n disabled: !!disabled,\n horizontal: context.orientation === 'horizontal',\n });\n\n const indicator = hideIndicator ? null : (\n \n \n \n );\n\n // Either way the label is a string in a row that is otherwise all views,\n // so children given instead of the prop are dressed the same way rather\n // than reaching the pressable bare.\n const labelled = label ? (\n {label}\n ) : (\n textChildren(children, (text) => (\n {text}\n ))\n );\n\n // The card lays its text out first so the disc sits at the trailing edge;\n // the dot row keeps the classic disc-then-label order.\n const body =\n variant === 'card' ? (\n <>\n \n {labelled}\n {description ? (\n \n {description}\n \n ) : null}\n \n {indicator}\n \n ) : (\n <>\n {indicator}\n {labelled}\n \n );\n\n return (\n \n requestRadioValueChange(context.value, value, context.onValueChange)\n }\n className={slots.row({ className })}\n >\n {body}\n \n );\n }\n);\nRadioGroupItem.displayName = 'RadioGroup.Item';\n\nexport const RadioGroup = Object.assign(RadioGroupRoot, {\n Item: RadioGroupItem,\n});\n\nexport type { RadioVariant, RadioSize };\n" }, { "path": "ui/radio-group-value.ts", diff --git a/apps/docs/public/r/rating.json b/apps/docs/public/r/rating.json index f39ff576c..3831b02a8 100644 --- a/apps/docs/public/r/rating.json +++ b/apps/docs/public/r/rating.json @@ -6,6 +6,7 @@ "cn", "haptics", "icons", + "refs", "text" ], "dependencies": [ @@ -19,7 +20,7 @@ { "path": "ui/rating.tsx", "type": "registry:ui", - "content": "/**\n * Rating — a row of stars you can read or set.\n *\n * A rating is two jobs wearing one shape. Read-only, it is a compact way to\n * *show* a score — four-and-a-half stars next to a product. Interactive, it is\n * an *input*: tap a star to pick a whole value, or with `precision={0.5}` press\n * the left half of a star for a half. Either way the fill is the truth, so the\n * two modes look identical and only differ in whether a finger changes them.\n *\n * Partial fills are drawn by clipping a filled star over an empty one of the\n * same geometry — the filled layer's width is a fraction of the star's, so a\n * half star is exactly half and any precision in between is honest. That fill\n * is animated on the UI thread: setting a value springs the stars to it rather\n * than snapping, and a drag follows the finger with no spring so it never lags.\n *\n * ```tsx\n * \n *\n * \n *\n * \n * ```\n *\n * Works controlled (`value` + `onValueChange`) or uncontrolled (`defaultValue`).\n */\nimport { forwardRef, useCallback, useEffect, useRef, useState } from 'react';\nimport {\n View,\n type AccessibilityActionEvent,\n type LayoutChangeEvent,\n} from 'react-native';\nimport { Gesture, GestureDetector } from 'react-native-gesture-handler';\nimport Animated, {\n runOnJS,\n useAnimatedStyle,\n useSharedValue,\n withSpring,\n type SharedValue,\n} from 'react-native-reanimated';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport { useCSSVariable } from 'uniwind';\nimport { StarIcon } from '@/components/ui/icons';\nimport { Text } from '@/components/ui/text';\nimport { cn } from '@/lib/cn';\nimport { selectionTick } from '@/lib/haptics';\nimport {\n normalizeRatingMax,\n normalizeRatingPrecision,\n normalizeRatingValue,\n} from '@/components/ui/rating-inputs';\n\n/** Springs the fill onto its resting value after a tap or the end of a drag. */\nconst SPRING = { damping: 18, stiffness: 220, mass: 0.6 } as const;\n\nconst ratingVariants = tv({\n slots: {\n root: 'gap-2',\n header: 'flex-row items-center justify-between gap-3',\n label: 'text-sm font-medium text-foreground',\n value: 'text-sm text-muted-foreground',\n row: 'flex-row items-center self-start',\n },\n variants: {\n size: {\n sm: { row: 'gap-0.5' },\n md: { row: 'gap-1' },\n lg: { row: 'gap-1.5' },\n },\n disabled: {\n true: { root: 'opacity-50' },\n },\n },\n defaultVariants: {\n size: 'md',\n },\n});\n\ntype RatingVariantProps = VariantProps;\nexport type RatingSize = NonNullable;\nexport type RatingColor =\n | 'warning'\n | 'primary'\n | 'success'\n | 'destructive'\n | 'info'\n | 'foreground';\n\n/** Star pixel size per size token — the whole control scales from this. */\nconst STAR_SIZE: Record = { sm: 18, md: 24, lg: 32 };\n\n/** The CSS token each colour paints its filled stars with. */\nconst FILL_VAR: Record = {\n warning: '--color-warning',\n primary: '--color-primary',\n success: '--color-success',\n destructive: '--color-destructive',\n info: '--color-info',\n foreground: '--color-foreground',\n};\n\nfunction clampJS(value: number, min: number, max: number) {\n return Math.min(Math.max(value, min), max);\n}\n\n/** Rounds `value` up to the nearest `precision`, so any touch fills at least it. */\nfunction snap(value: number, precision: number, max: number) {\n const stepped = Math.ceil(value / precision) * precision;\n // Guard the float drift that `ceil` on fractional steps can leave behind.\n return clampJS(Math.round(stepped / precision) * precision, 0, max);\n}\n\nexport interface RatingProps extends Omit {\n className?: string;\n /** Controlled value. Leave unset and pass `defaultValue` to run uncontrolled. */\n value?: number;\n /** Starting value when uncontrolled. */\n defaultValue?: number;\n /** How many stars. */\n max?: number;\n /**\n * Smallest step a tap can pick, as a fraction of a star. `1` is whole stars,\n * `0.5` lets the left half of a star mean a half. Reading a value renders any\n * precision — this only constrains what a finger can set.\n */\n precision?: number;\n /** Fires as the value changes, including live while dragging. */\n onValueChange?: (value: number) => void;\n /** Fires once when a tap or drag ends — the place for expensive side effects. */\n onValueCommit?: (value: number) => void;\n /** Show the stars but ignore touches — the display half of the component. */\n readOnly?: boolean;\n disabled?: boolean;\n /**\n * Let a second tap on the current value clear it back to zero, so a rating\n * given by mistake can be taken back without a separate control.\n */\n allowClear?: boolean;\n /** Which token the filled stars are painted with. */\n color?: RatingColor;\n /** Caption above the stars. Also becomes the accessibility label. */\n label?: string;\n /** Show the numeric value on the caption row, opposite the label. */\n showValue?: boolean;\n /** Format the shown value. Defaults to the number as written. */\n formatValue?: (value: number) => string;\n /**\n * A tick under the finger each time a drag crosses onto a new star. Off by\n * default — needs the optional `expo-haptics`, and is silent without it.\n */\n haptics?: boolean;\n /** Extra classes for the caption row. */\n headerClassName?: string;\n /** Extra classes for the row of stars. */\n rowClassName?: string;\n}\n\n/**\n * One star: an empty outline with a filled copy clipped over it. The clip width\n * is driven by the shared `display` value on the UI thread, so the fill of this\n * star animates and drags without a React render.\n */\nfunction Star({\n index,\n display,\n size,\n gap,\n fillColor,\n emptyColor,\n}: {\n index: number;\n display: SharedValue;\n size: number;\n gap: number;\n fillColor: string;\n emptyColor: string;\n}) {\n // Clamped inline rather than through a helper: this body runs on the UI\n // thread, which can only reach worklets, not module-scope functions.\n const clipStyle = useAnimatedStyle(() => ({\n width: Math.min(Math.max(display.value - index, 0), 1) * size,\n }));\n\n return (\n \n \n {/* The filled layer is pinned to the left and clipped to a fraction of\n the star's width, so a partial value shows a partial star. */}\n \n \n \n \n );\n}\n\nexport const Rating = forwardRef(\n (\n {\n className,\n headerClassName,\n rowClassName,\n value: valueProp,\n defaultValue = 0,\n max: maxProp = 5,\n precision: precisionProp = 1,\n onValueChange,\n onValueCommit,\n readOnly = false,\n disabled = false,\n allowClear = false,\n color = 'warning',\n size = 'md',\n label,\n showValue = false,\n formatValue,\n haptics = false,\n },\n ref\n ) => {\n const max = normalizeRatingMax(maxProp);\n const precision = normalizeRatingPrecision(precisionProp);\n const isControlled = valueProp !== undefined;\n const [internal, setInternal] = useState(() => normalizeRatingValue(defaultValue, max));\n const value = normalizeRatingValue(isControlled ? valueProp : internal, max);\n\n const interactive = !readOnly && !disabled;\n const slots = ratingVariants({ size, disabled });\n const starSize = STAR_SIZE[size ?? 'md'];\n const gap = size === 'sm' ? 2 : size === 'lg' ? 6 : 4;\n\n // SVG paints with real colour strings, not classes — resolve the tokens.\n const fillColor = useCSSVariable(FILL_VAR[color]);\n const emptyColor = useCSSVariable('--color-border');\n const fill = typeof fillColor === 'string' ? fillColor : '#f59e0b';\n const empty = typeof emptyColor === 'string' ? emptyColor : '#d4d4d4';\n\n // Drives every star's clipped fill on the UI thread.\n const display = useSharedValue(value);\n const rowWidth = useSharedValue(0);\n\n // Keep the change/commit handlers reachable from the gesture without\n // rebuilding it on every render.\n const changeRef = useRef(onValueChange);\n changeRef.current = onValueChange;\n const commitRef = useRef(onValueCommit);\n commitRef.current = onValueCommit;\n // The last whole star a drag crossed, so haptics tick once per star.\n const lastStep = useRef(Math.ceil(value));\n\n const emitChange = useCallback(\n (next: number) => {\n if (!isControlled) setInternal(next);\n changeRef.current?.(next);\n },\n [isControlled]\n );\n\n // Follow a controlled value that changes elsewhere, springing to it.\n useEffect(() => {\n display.value = withSpring(value, SPRING);\n }, [value, display]);\n\n const onRowLayout = (event: LayoutChangeEvent) => {\n rowWidth.value = event.nativeEvent.layout.width;\n };\n\n // Maps an x within the row to a snapped rating. Runs on JS off the worklet.\n const valueFromX = useCallback(\n (x: number, width: number) => {\n if (width <= 0) return value;\n const raw = clampJS(x / width, 0, 1) * max;\n return snap(raw, precision, max);\n },\n [max, precision, value]\n );\n\n const applyLive = useCallback(\n (x: number, width: number) => {\n let next = valueFromX(x, width);\n if (allowClear && next === value && Math.ceil(next) === lastStep.current) {\n next = 0;\n }\n display.value = next;\n if (haptics && Math.ceil(next) !== lastStep.current) {\n lastStep.current = Math.ceil(next);\n selectionTick();\n }\n emitChange(next);\n },\n [valueFromX, allowClear, value, display, haptics, emitChange]\n );\n\n // Resets the haptic step to where the touch started, then applies the first\n // position. Runs on JS: a ref cannot be written from a gesture worklet.\n const begin = useCallback(\n (x: number, width: number) => {\n lastStep.current = Math.ceil(value);\n applyLive(x, width);\n },\n [value, applyLive]\n );\n\n const commit = useCallback(\n (x: number, width: number) => {\n let next = valueFromX(x, width);\n if (allowClear && next === value) next = 0;\n display.value = withSpring(next, SPRING);\n lastStep.current = Math.ceil(next);\n emitChange(next);\n commitRef.current?.(next);\n },\n [valueFromX, allowClear, value, display, emitChange]\n );\n\n const pan = Gesture.Pan()\n .enabled(interactive)\n .onBegin((event) => {\n runOnJS(begin)(event.x, rowWidth.value);\n })\n .onUpdate((event) => {\n runOnJS(applyLive)(event.x, rowWidth.value);\n })\n .onFinalize((event) => {\n runOnJS(commit)(event.x, rowWidth.value);\n });\n\n const tap = Gesture.Tap()\n .enabled(interactive)\n .maxDuration(250)\n .onEnd((event) => {\n runOnJS(commit)(event.x, rowWidth.value);\n });\n\n const gesture = Gesture.Race(tap, pan);\n\n // VoiceOver / TalkBack move by a single precision step.\n const nudge = (dir: 1 | -1) => {\n const next = clampJS(\n Math.round((value + dir * precision) / precision) * precision,\n 0,\n max\n );\n if (next === value) return;\n display.value = withSpring(next, SPRING);\n lastStep.current = Math.ceil(next);\n emitChange(next);\n commitRef.current?.(next);\n };\n\n const onAccessibilityAction = (event: AccessibilityActionEvent) => {\n if (event.nativeEvent.actionName === 'increment') nudge(1);\n else if (event.nativeEvent.actionName === 'decrement') nudge(-1);\n };\n\n const shown = formatValue ? formatValue(value) : String(value);\n const a11yLabel = label ?? `Rating, ${shown} out of ${max}`;\n\n const stars = (\n \n {Array.from({ length: max }, (_, i) => (\n \n ))}\n \n );\n\n const header =\n label || showValue ? (\n \n {label ? {label} : }\n {showValue ? {shown} : null}\n \n ) : null;\n\n return (\n \n {header}\n {interactive ? (\n \n \n {stars}\n \n \n ) : (\n \n {stars}\n \n )}\n \n );\n }\n);\n\nRating.displayName = 'Rating';\n" + "content": "/**\n * Rating — a row of stars you can read or set.\n *\n * A rating is two jobs wearing one shape. Read-only, it is a compact way to\n * *show* a score — four-and-a-half stars next to a product. Interactive, it is\n * an *input*: tap a star to pick a whole value, or with `precision={0.5}` press\n * the left half of a star for a half. Either way the fill is the truth, so the\n * two modes look identical and only differ in whether a finger changes them.\n *\n * Partial fills are drawn by clipping a filled star over an empty one of the\n * same geometry — the filled layer's width is a fraction of the star's, so a\n * half star is exactly half and any precision in between is honest. That fill\n * is animated on the UI thread: setting a value springs the stars to it rather\n * than snapping, and a drag follows the finger with no spring so it never lags.\n *\n * ```tsx\n * \n *\n * \n *\n * \n * ```\n *\n * Works controlled (`value` + `onValueChange`) or uncontrolled (`defaultValue`).\n */\nimport { forwardRef, useCallback, useEffect, useRef, useState } from 'react';\nimport {\n View,\n type AccessibilityActionEvent,\n type LayoutChangeEvent,\n} from 'react-native';\nimport { Gesture, GestureDetector } from 'react-native-gesture-handler';\nimport Animated, {\n runOnJS,\n useAnimatedStyle,\n useSharedValue,\n withSpring,\n type SharedValue,\n} from 'react-native-reanimated';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport { useCSSVariable } from 'uniwind';\nimport { StarIcon } from '@/components/ui/icons';\nimport { Text } from '@/components/ui/text';\nimport { cn } from '@/lib/cn';\nimport { selectionTick } from '@/lib/haptics';\nimport {\n normalizeRatingMax,\n normalizeRatingPrecision,\n normalizeRatingValue,\n} from '@/components/ui/rating-inputs';\nimport type { ViewRef } from '@/components/ui/refs';\n\n/** Springs the fill onto its resting value after a tap or the end of a drag. */\nconst SPRING = { damping: 18, stiffness: 220, mass: 0.6 } as const;\n\nconst ratingVariants = tv({\n slots: {\n root: 'gap-2',\n header: 'flex-row items-center justify-between gap-3',\n label: 'text-sm font-medium text-foreground',\n value: 'text-sm text-muted-foreground',\n row: 'flex-row items-center self-start',\n },\n variants: {\n size: {\n sm: { row: 'gap-0.5' },\n md: { row: 'gap-1' },\n lg: { row: 'gap-1.5' },\n },\n disabled: {\n true: { root: 'opacity-50' },\n },\n },\n defaultVariants: {\n size: 'md',\n },\n});\n\ntype RatingVariantProps = VariantProps;\nexport type RatingSize = NonNullable;\nexport type RatingColor =\n | 'warning'\n | 'primary'\n | 'success'\n | 'destructive'\n | 'info'\n | 'foreground';\n\n/** Star pixel size per size token — the whole control scales from this. */\nconst STAR_SIZE: Record = { sm: 18, md: 24, lg: 32 };\n\n/** The CSS token each colour paints its filled stars with. */\nconst FILL_VAR: Record = {\n warning: '--color-warning',\n primary: '--color-primary',\n success: '--color-success',\n destructive: '--color-destructive',\n info: '--color-info',\n foreground: '--color-foreground',\n};\n\nfunction clampJS(value: number, min: number, max: number) {\n return Math.min(Math.max(value, min), max);\n}\n\n/** Rounds `value` up to the nearest `precision`, so any touch fills at least it. */\nfunction snap(value: number, precision: number, max: number) {\n const stepped = Math.ceil(value / precision) * precision;\n // Guard the float drift that `ceil` on fractional steps can leave behind.\n return clampJS(Math.round(stepped / precision) * precision, 0, max);\n}\n\nexport interface RatingProps extends Omit {\n className?: string;\n /** Controlled value. Leave unset and pass `defaultValue` to run uncontrolled. */\n value?: number;\n /** Starting value when uncontrolled. */\n defaultValue?: number;\n /** How many stars. */\n max?: number;\n /**\n * Smallest step a tap can pick, as a fraction of a star. `1` is whole stars,\n * `0.5` lets the left half of a star mean a half. Reading a value renders any\n * precision — this only constrains what a finger can set.\n */\n precision?: number;\n /** Fires as the value changes, including live while dragging. */\n onValueChange?: (value: number) => void;\n /** Fires once when a tap or drag ends — the place for expensive side effects. */\n onValueCommit?: (value: number) => void;\n /** Show the stars but ignore touches — the display half of the component. */\n readOnly?: boolean;\n disabled?: boolean;\n /**\n * Let a second tap on the current value clear it back to zero, so a rating\n * given by mistake can be taken back without a separate control.\n */\n allowClear?: boolean;\n /** Which token the filled stars are painted with. */\n color?: RatingColor;\n /** Caption above the stars. Also becomes the accessibility label. */\n label?: string;\n /** Show the numeric value on the caption row, opposite the label. */\n showValue?: boolean;\n /** Format the shown value. Defaults to the number as written. */\n formatValue?: (value: number) => string;\n /**\n * A tick under the finger each time a drag crosses onto a new star. Off by\n * default — needs the optional `expo-haptics`, and is silent without it.\n */\n haptics?: boolean;\n /** Extra classes for the caption row. */\n headerClassName?: string;\n /** Extra classes for the row of stars. */\n rowClassName?: string;\n}\n\n/**\n * One star: an empty outline with a filled copy clipped over it. The clip width\n * is driven by the shared `display` value on the UI thread, so the fill of this\n * star animates and drags without a React render.\n */\nfunction Star({\n index,\n display,\n size,\n gap,\n fillColor,\n emptyColor,\n}: {\n index: number;\n display: SharedValue;\n size: number;\n gap: number;\n fillColor: string;\n emptyColor: string;\n}) {\n // Clamped inline rather than through a helper: this body runs on the UI\n // thread, which can only reach worklets, not module-scope functions.\n const clipStyle = useAnimatedStyle(() => ({\n width: Math.min(Math.max(display.value - index, 0), 1) * size,\n }));\n\n return (\n \n \n {/* The filled layer is pinned to the left and clipped to a fraction of\n the star's width, so a partial value shows a partial star. */}\n \n \n \n \n );\n}\n\nexport const Rating = forwardRef(\n (\n {\n className,\n headerClassName,\n rowClassName,\n value: valueProp,\n defaultValue = 0,\n max: maxProp = 5,\n precision: precisionProp = 1,\n onValueChange,\n onValueCommit,\n readOnly = false,\n disabled = false,\n allowClear = false,\n color = 'warning',\n size = 'md',\n label,\n showValue = false,\n formatValue,\n haptics = false,\n },\n ref\n ) => {\n const max = normalizeRatingMax(maxProp);\n const precision = normalizeRatingPrecision(precisionProp);\n const isControlled = valueProp !== undefined;\n const [internal, setInternal] = useState(() => normalizeRatingValue(defaultValue, max));\n const value = normalizeRatingValue(isControlled ? valueProp : internal, max);\n\n const interactive = !readOnly && !disabled;\n const slots = ratingVariants({ size, disabled });\n const starSize = STAR_SIZE[size ?? 'md'];\n const gap = size === 'sm' ? 2 : size === 'lg' ? 6 : 4;\n\n // SVG paints with real colour strings, not classes — resolve the tokens.\n const fillColor = useCSSVariable(FILL_VAR[color]);\n const emptyColor = useCSSVariable('--color-border');\n const fill = typeof fillColor === 'string' ? fillColor : '#f59e0b';\n const empty = typeof emptyColor === 'string' ? emptyColor : '#d4d4d4';\n\n // Drives every star's clipped fill on the UI thread.\n const display = useSharedValue(value);\n const rowWidth = useSharedValue(0);\n\n // Keep the change/commit handlers reachable from the gesture without\n // rebuilding it on every render.\n const changeRef = useRef(onValueChange);\n changeRef.current = onValueChange;\n const commitRef = useRef(onValueCommit);\n commitRef.current = onValueCommit;\n // The last whole star a drag crossed, so haptics tick once per star.\n const lastStep = useRef(Math.ceil(value));\n\n const emitChange = useCallback(\n (next: number) => {\n if (!isControlled) setInternal(next);\n changeRef.current?.(next);\n },\n [isControlled]\n );\n\n // Follow a controlled value that changes elsewhere, springing to it.\n useEffect(() => {\n display.value = withSpring(value, SPRING);\n }, [value, display]);\n\n const onRowLayout = (event: LayoutChangeEvent) => {\n rowWidth.value = event.nativeEvent.layout.width;\n };\n\n // Maps an x within the row to a snapped rating. Runs on JS off the worklet.\n const valueFromX = useCallback(\n (x: number, width: number) => {\n if (width <= 0) return value;\n const raw = clampJS(x / width, 0, 1) * max;\n return snap(raw, precision, max);\n },\n [max, precision, value]\n );\n\n const applyLive = useCallback(\n (x: number, width: number) => {\n let next = valueFromX(x, width);\n if (allowClear && next === value && Math.ceil(next) === lastStep.current) {\n next = 0;\n }\n display.value = next;\n if (haptics && Math.ceil(next) !== lastStep.current) {\n lastStep.current = Math.ceil(next);\n selectionTick();\n }\n emitChange(next);\n },\n [valueFromX, allowClear, value, display, haptics, emitChange]\n );\n\n // Resets the haptic step to where the touch started, then applies the first\n // position. Runs on JS: a ref cannot be written from a gesture worklet.\n const begin = useCallback(\n (x: number, width: number) => {\n lastStep.current = Math.ceil(value);\n applyLive(x, width);\n },\n [value, applyLive]\n );\n\n const commit = useCallback(\n (x: number, width: number) => {\n let next = valueFromX(x, width);\n if (allowClear && next === value) next = 0;\n display.value = withSpring(next, SPRING);\n lastStep.current = Math.ceil(next);\n emitChange(next);\n commitRef.current?.(next);\n },\n [valueFromX, allowClear, value, display, emitChange]\n );\n\n const pan = Gesture.Pan()\n .enabled(interactive)\n .onBegin((event) => {\n runOnJS(begin)(event.x, rowWidth.value);\n })\n .onUpdate((event) => {\n runOnJS(applyLive)(event.x, rowWidth.value);\n })\n .onFinalize((event) => {\n runOnJS(commit)(event.x, rowWidth.value);\n });\n\n const tap = Gesture.Tap()\n .enabled(interactive)\n .maxDuration(250)\n .onEnd((event) => {\n runOnJS(commit)(event.x, rowWidth.value);\n });\n\n const gesture = Gesture.Race(tap, pan);\n\n // VoiceOver / TalkBack move by a single precision step.\n const nudge = (dir: 1 | -1) => {\n const next = clampJS(\n Math.round((value + dir * precision) / precision) * precision,\n 0,\n max\n );\n if (next === value) return;\n display.value = withSpring(next, SPRING);\n lastStep.current = Math.ceil(next);\n emitChange(next);\n commitRef.current?.(next);\n };\n\n const onAccessibilityAction = (event: AccessibilityActionEvent) => {\n if (event.nativeEvent.actionName === 'increment') nudge(1);\n else if (event.nativeEvent.actionName === 'decrement') nudge(-1);\n };\n\n const shown = formatValue ? formatValue(value) : String(value);\n const a11yLabel = label ?? `Rating, ${shown} out of ${max}`;\n\n const stars = (\n \n {Array.from({ length: max }, (_, i) => (\n \n ))}\n \n );\n\n const header =\n label || showValue ? (\n \n {label ? {label} : }\n {showValue ? {shown} : null}\n \n ) : null;\n\n return (\n \n {header}\n {interactive ? (\n \n \n {stars}\n \n \n ) : (\n \n {stars}\n \n )}\n \n );\n }\n);\n\nRating.displayName = 'Rating';\n" }, { "path": "ui/rating-inputs.ts", diff --git a/apps/docs/public/r/refs.json b/apps/docs/public/r/refs.json new file mode 100644 index 000000000..25849df5f --- /dev/null +++ b/apps/docs/public/r/refs.json @@ -0,0 +1,15 @@ +{ + "name": "refs", + "type": "registry:ui", + "description": "The types a forwarded ref points at, the same on every React Native version.", + "registryDependencies": [], + "dependencies": [], + "optionalDependencies": [], + "files": [ + { + "path": "ui/refs.ts", + "type": "registry:ui", + "content": "import type { ComponentRef } from 'react';\nimport type { Text, TextInput, View } from 'react-native';\n\n/*\n * The types a forwarded ref points at.\n *\n * React Native's `View`, `Text` and `TextInput` were declared as classes up to\n * 0.86 and as function components from 0.88, so naming one as a ref's type —\n * `forwardRef` — compiles on one and fails on the other.\n * `ComponentRef` reads the instance type off whichever declaration is\n * installed, so these compile on both and keep the instance methods (`focus`,\n * `blur`, `measure`, …) on the ref.\n */\n\n/** What a ref on a `View`-rooted component points at. */\nexport type ViewRef = ComponentRef;\n\n/** What a ref on a `Text`-rooted component points at. */\nexport type TextRef = ComponentRef;\n\n/** What a ref on a `TextInput`-rooted component points at: `focus()`, `clear()`, `isFocused()`. */\nexport type TextInputRef = ComponentRef;\n" + } + ] +} diff --git a/apps/docs/public/r/scroll-header.json b/apps/docs/public/r/scroll-header.json index 56b636113..9a32c65e3 100644 --- a/apps/docs/public/r/scroll-header.json +++ b/apps/docs/public/r/scroll-header.json @@ -4,6 +4,7 @@ "description": "A screen title that hands over to a compact bar as the page scrolls.", "registryDependencies": [ "cn", + "refs", "scrim", "text", "use-theme" @@ -22,7 +23,7 @@ { "path": "ui/scroll-header.tsx", "type": "registry:ui", - "content": "/**\n * ScrollHeader — a screen header that hands over to a compact bar as the page\n * scrolls.\n *\n * A screen with a title has two states and every app draws both: the title\n * large, at rest, with room around it, and the title small, pinned, once you\n * are reading. Hand-rolling the change between them is where screens stop\n * matching each other — one snaps, one crossfades at a different point, one\n * forgets the bar is over a safe area — so the two states and the transit\n * between them are one component here.\n *\n * ```tsx\n * \n * \n * Library\n * \n * \n * \n * \n * \n * Library\n * 128 components\n * \n * {rows}\n * \n * ```\n *\n * ## The band collapses; nothing inside it is animated up\n *\n * The header is one absolutely positioned band over the scroller, and the only\n * thing driven by the scroll is its height: `bar + max(0, large - offset)`.\n * The bar is anchored to its top and the large block to its bottom, so the\n * band shrinking is what carries the large block up and behind the bar, and\n * `overflow-hidden` is what cuts it off there.\n *\n * Driving the height rather than a `translateY` is what makes the rest fall\n * out for free. Over-scrolling makes `large - offset` larger than `large`, so\n * the band grows and a cover filling it stretches by being laid out bigger —\n * no scale transform, so a photograph stretches without going soft. And the\n * band's height is the inset the content needs, so there is one number rather\n * than two that have to agree.\n *\n * ## The two titles cross-fade; neither one morphs\n *\n * The large title and the bar title are separate elements that fade past each\n * other. A single title scaled and translated between the two positions tracks\n * beautifully until the text is long enough to truncate, at which point it is\n * animating between two different strings.\n *\n * Both are therefore in the tree at once, which is a problem for a screen\n * reader — one of them is invisible and would still be read. So the crossing\n * point is also published to React as `collapsed`, and whichever title is not\n * being shown is hidden from accessibility. That is one re-render per crossing\n * and no more: everything that runs per frame stays in shared values.\n *\n * ## The bar's surface is a fill, or a frost\n *\n * `surface` says what the bar is drawn on once it has taken over: a token\n * fill, nothing at all over a cover, or `blur` — a real material, so the rows\n * passing under the bar stay legible as shape and colour while losing the\n * detail that would compete with the title on top.\n *\n * The frost needs a native view, and there are two ways it cannot be drawn:\n * `expo-blur` is optional and may not be installed, and Reduce Transparency is\n * a preference that outranks the design. Both fall back to the plain\n * background token rather than to nothing, because a bar you cannot read is a\n * worse answer than a bar that is not frosted.\n *\n * ## What it needs\n *\n * A height to fill, and exactly one scrollable child. The child is cloned with\n * the scroll handler and the content inset composed onto it, the same way\n * `ScrollFade` wraps one, so a `ScrollView`, a `FlatList` or a `SectionList`\n * all work unchanged.\n */\nimport {\n Children,\n createContext,\n forwardRef,\n isValidElement,\n useCallback,\n useContext,\n useMemo,\n useState,\n type ComponentType,\n type ReactNode,\n type Ref,\n} from 'react';\nimport {\n Image,\n Platform,\n StyleSheet,\n View,\n type ImageSourcePropType,\n type LayoutChangeEvent,\n type NativeScrollEvent,\n type NativeSyntheticEvent,\n type StyleProp,\n type Text as RNText,\n type ViewProps,\n type ViewStyle,\n} from 'react-native';\nimport { LinearGradient } from 'expo-linear-gradient';\nimport Animated, {\n interpolate,\n runOnJS,\n scrollTo,\n useAnimatedReaction,\n useAnimatedRef,\n useAnimatedScrollHandler,\n useAnimatedStyle,\n useComposedEventHandler,\n useDerivedValue,\n useReducedMotion,\n useSharedValue,\n withTiming,\n type AnimatedScrollViewProps,\n type SharedValue,\n} from 'react-native-reanimated';\nimport { useSafeAreaInsets } from 'react-native-safe-area-context';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport { useCSSVariable } from 'uniwind';\nimport { hasBlur, useReduceTransparency } from '@/components/ui/scrim';\nimport { Text, textChildren, type TextProps } from '@/components/ui/text';\nimport { useThemeMode } from '@/hooks/use-theme';\nimport { cn } from '@/lib/cn';\nimport {\n BAR_TITLE_ARRIVE,\n HANDOVER_DURATION,\n LARGE_EXIT,\n SURFACE_ARRIVE,\n bandHeight,\n collapseProgress,\n contentInset,\n hasSpan,\n isCrossing,\n snapTarget,\n} from '@/components/ui/scroll-header-math';\n\n/**\n * Height of the pinned bar, in points, before the device's top inset is added.\n * The platform navigation bars are 44 and 56; 48 is the target-size floor the\n * rest of the library holds compact controls to, and it sits between them.\n */\nconst BAR_HEIGHT = 48;\n\n/**\n * The scrollables Reanimated already animates. Its animated components are\n * ordinary function components carrying the *inner* component's name, so there\n * is nothing on one to test — but these two are module-level constants, and\n * identity is exact.\n */\n/** Which way the frost tints. `default` follows the app's theme. */\nexport type ScrollHeaderMaterial = 'light' | 'dark' | 'default';\n\n/**\n * `expo-blur`'s BlurView, or null when it is not installed. Resolved once at\n * module load — the require is cheap and caching it avoids a try/catch on\n * every render.\n */\nconst BlurView: ComponentType<{\n intensity?: number;\n tint?: ScrollHeaderMaterial;\n style?: unknown;\n}> | null = (() => {\n try {\n // eslint-disable-next-line @typescript-eslint/no-require-imports\n const mod = require('expo-blur');\n return (mod?.BlurView as ComponentType<{ intensity?: number }>) ?? null;\n } catch {\n return null;\n }\n})();\n\n/**\n * Depth of the frost. Heavier than a scrim's, because a scrim covers a whole\n * screen and this is a thin band read against content that is moving under it.\n */\nconst DEFAULT_BLUR_INTENSITY = 40;\n\nconst ANIMATED_SCROLLABLES = new Set([Animated.ScrollView, Animated.FlatList]);\n\nconst scrollHeaderVariants = tv({\n slots: {\n root: 'flex-1',\n band: 'absolute inset-x-0 top-0 overflow-hidden',\n cover: 'absolute inset-0',\n bar: 'absolute inset-x-0 top-0 flex-row items-center gap-3 px-4',\n barSurface: 'absolute inset-0',\n large: 'absolute inset-x-0 bottom-0 gap-1 px-4 pb-3',\n actions: 'ml-auto flex-row items-center gap-1',\n },\n variants: {\n /** What the bar is drawn on once it has taken over. */\n surface: {\n plain: { barSurface: 'bg-background' },\n muted: { barSurface: 'bg-card' },\n none: { barSurface: 'bg-transparent' },\n // No fill of its own: an opaque colour behind the material is part of\n // what the material samples, so a frost over one is a flat bar that has\n // paid for a native view.\n blur: { barSurface: 'bg-transparent' },\n },\n divider: {\n true: { barSurface: 'border-b border-border' },\n false: {},\n },\n },\n defaultVariants: {\n surface: 'plain',\n divider: true,\n },\n});\n\ntype ScrollHeaderVariantProps = VariantProps;\n\n/** What the bar is drawn on once the large block has gone. */\nexport type ScrollHeaderSurface = NonNullable;\n\n/** Which half of the header a part is standing in. */\nexport type ScrollHeaderSlot = 'bar' | 'large';\n\ninterface ScrollHeaderContextValue {\n /** 0 while the large block is whole, 1 once the bar has taken over. */\n progress: SharedValue;\n /** Distance scrolled, in points. Negative while the finger pulls down. */\n offset: SharedValue;\n /** Measured height of the large block. */\n largeHeight: SharedValue;\n /** The crossing point, published to React for accessibility. */\n collapsed: boolean;\n /** Whether there is a large block at all. A bar-only header has no crossing. */\n hasLarge: boolean;\n measureLarge: (event: LayoutChangeEvent) => void;\n}\n\nconst ScrollHeaderContext = createContext(undefined);\n\nfunction useScrollHeader(component: string): ScrollHeaderContextValue {\n const context = useContext(ScrollHeaderContext);\n if (!context) throw new Error(`${component} must be used within a `);\n return context;\n}\n\n/**\n * Which half a part is in. `Title` reads it to know which size to be and which\n * way to fade, so the same element can be written in both places.\n */\nconst ScrollHeaderSlotContext = createContext('large');\n\ninterface ScrollableProps {\n onScroll?: AnimatedScrollViewProps['onScroll'];\n contentContainerStyle?: StyleProp;\n scrollIndicatorInsets?: { top?: number; bottom?: number; left?: number; right?: number };\n scrollEventThrottle?: number;\n contentInsetAdjustmentBehavior?: AnimatedScrollViewProps['contentInsetAdjustmentBehavior'];\n ref?: Ref;\n}\n\nexport interface ScrollHeaderProps extends ViewProps {\n className?: string;\n /**\n * Height of the pinned bar in points, before the device's top inset. The\n * inset is added on top of this rather than taken out of it, so the bar's\n * contents keep this much room on every device.\n */\n barHeight?: number;\n /**\n * How much of the large block has to leave before the bar has fully taken\n * over, as a fraction of its height. Below 1 the crossing happens early,\n * which suits a tall block whose last few points are not worth waiting for.\n */\n threshold?: number;\n /**\n * Settle a part-scrolled band open or closed when the finger lifts, rather\n * than leaving the header half-collapsed.\n */\n snap?: boolean;\n /**\n * Let the band grow past its resting height when the scroller is pulled\n * down. A cover fills the band, so this is what stretches it. Off under\n * Reduce Motion.\n */\n stretch?: boolean;\n /** Add the device's top inset above the bar. Off inside a screen that already has one. */\n inset?: boolean;\n /**\n * Called as the bar takes over, and again when the large block comes back.\n * Fires on the crossing, not on every frame.\n */\n onCollapsedChange?: (collapsed: boolean) => void;\n /**\n * A shared value to mirror the collapse into, 0 to 1, for animating\n * something outside the header against the same transition.\n */\n progress?: SharedValue;\n /** The parts, and exactly one scrollable. */\n children?: ReactNode;\n}\n\n/**\n * The band, and the scroller it sits over. Everything that is not a part is\n * treated as the scrollable child.\n */\nconst ScrollHeaderRoot = forwardRef(\n (\n {\n className,\n barHeight = BAR_HEIGHT,\n threshold = 1,\n snap = true,\n stretch = true,\n inset = true,\n onCollapsedChange,\n progress: externalProgress,\n children,\n ...props\n },\n ref\n ) => {\n const insets = useSafeAreaInsets();\n const reducedMotion = useReducedMotion();\n const insetTop = inset ? insets.top : 0;\n const barBand = insetTop + barHeight;\n\n const offset = useSharedValue(0);\n const largeHeight = useSharedValue(0);\n const [largeSize, setLargeSize] = useState(0);\n const [collapsed, setCollapsed] = useState(false);\n\n const scrollRef = useAnimatedRef();\n\n // Measured into both a shared value, for the band's height, and React\n // state, for the content inset — the inset is a resting number that only\n // changes when the block is re-laid-out, so paying a render for it costs\n // nothing per frame and keeps the scroller's props stable.\n const measureLarge = useCallback((event: LayoutChangeEvent) => {\n const { height } = event.nativeEvent.layout;\n largeHeight.value = height;\n setLargeSize((previous) => (Math.abs(previous - height) < 0.5 ? previous : height));\n }, [largeHeight]);\n\n const progress = useDerivedValue(() => {\n const next = collapseProgress(offset.value, largeHeight.value, threshold);\n // A header with a block hands over across that block's height, and the\n // finger sets the pace. One without has no distance to interpolate over,\n // so the step is timed rather than instant — the alternative is a bar\n // whose surface appears between one frame and the next.\n const value = hasSpan(largeHeight.value)\n ? next\n : withTiming(next, { duration: HANDOVER_DURATION });\n if (externalProgress) externalProgress.value = value;\n return value;\n }, [threshold, externalProgress]);\n\n // The one thing the transition tells React about. `collapsed` gates the\n // accessibility of the two titles and is what `onCollapsedChange` reports;\n // the reaction fires on the crossing, not on the frames either side of it.\n const cross = useCallback(\n (next: boolean) => {\n setCollapsed(next);\n onCollapsedChange?.(next);\n },\n [onCollapsedChange]\n );\n\n useAnimatedReaction(\n () => progress.value >= 1,\n (isCollapsed, was) => {\n if (!isCrossing(isCollapsed, was)) return;\n runOnJS(cross)(isCollapsed);\n },\n [cross]\n );\n\n const settle = useCallback(\n (velocity: number) => {\n 'worklet';\n if (!snap) return;\n const target = snapTarget(offset.value, largeHeight.value, velocity);\n if (target === null) return;\n scrollTo(scrollRef, 0, target, true);\n },\n [snap, largeHeight, offset, scrollRef]\n );\n\n const { parts, scrollable } = useMemo(() => splitChildren(children), [children]);\n\n const childType = isValidElement(scrollable)\n ? (scrollable.type as ComponentType)\n : null;\n // Keyed on the element type rather than the element: rebuilding the\n // wrapper would remount the list and lose its scroll position. A child\n // that is animated already is used as it stands — wrapping one twice is\n // unsupported, and there is no marker on them to test for, so the two\n // that exist are recognised by identity.\n const AnimatedScrollable = useMemo(() => {\n if (!childType) return null;\n if (ANIMATED_SCROLLABLES.has(childType)) return childType;\n return Animated.createAnimatedComponent(childType);\n }, [childType]);\n\n const childProps = (isValidElement(scrollable) ? scrollable.props : {}) as ScrollableProps;\n const childOnScroll = childProps.onScroll;\n\n /*\n * A consumer's own `onScroll` is kept, whichever of the two kinds it is,\n * because silently dropping one looks like a bug in the scrolling rather\n * than in the call site.\n *\n * `useEvent` returns an object wearing a function's type, so the two are\n * told apart by what they actually are: an object is a Reanimated handler\n * and composes onto ours, staying on the UI thread. A plain function\n * cannot — `useComposedEventHandler` keeps only worklet handlers and drops\n * anything else without a word — so it is called across the bridge\n * instead, once per scroll event the child delivers. How often that is, is\n * `scrollEventThrottle`'s answer rather than this component's: every frame\n * at the default of 16, and as rare as the caller asks for above it.\n */\n const workletOnScroll = typeof childOnScroll === 'function' ? null : childOnScroll ?? null;\n const plainOnScroll = typeof childOnScroll === 'function' ? childOnScroll : null;\n\n // Rebuilt only when the callback itself changes, so the worklet below is\n // not rebuilt on every render of the child.\n const forwardScroll = useCallback(\n (native: NativeScrollEvent) => {\n // The `nativeEvent` crosses whole; the synthetic wrapper around it does\n // not, because the rest of one is a live object with methods on it.\n plainOnScroll?.({ nativeEvent: native } as NativeSyntheticEvent);\n },\n [plainOnScroll]\n );\n const forwards = plainOnScroll !== null;\n\n const scrollHandler = useAnimatedScrollHandler(\n {\n onScroll: (event) => {\n offset.value = event.contentOffset.y;\n if (forwards) {\n // Every field React Native puts on a scroll event, so a callback\n // reading `velocity` for a direction, or `targetContentOffset` for\n // where a fling is going, finds what it would have without the\n // header. Listed rather than spread: the event carries Reanimated's\n // own `eventName` too, and that is not part of the contract.\n runOnJS(forwardScroll)({\n contentInset: event.contentInset,\n contentOffset: event.contentOffset,\n contentSize: event.contentSize,\n layoutMeasurement: event.layoutMeasurement,\n velocity: event.velocity,\n zoomScale: event.zoomScale,\n targetContentOffset: event.targetContentOffset,\n } as NativeScrollEvent);\n }\n },\n onEndDrag: (event) => {\n settle(event.velocity?.y ?? 0);\n },\n onMomentumEnd: () => {\n settle(0);\n },\n },\n [settle, forwards, forwardScroll]\n );\n\n const onScroll = useComposedEventHandler([\n scrollHandler,\n (workletOnScroll as typeof scrollHandler | null) ?? null,\n ]);\n\n /*\n * The band's resting height, plus whatever top padding the child asked\n * for. A style array overrides rather than adds, so composing ours after\n * theirs would drop their padding and composing theirs after ours would\n * drop the inset — and they cannot write the sum themselves, because the\n * band's height is measured. Reading it and adding is the only\n * arrangement where both survive.\n */\n const childContentStyle = StyleSheet.flatten(childProps.contentContainerStyle) ?? {};\n const headerHeight = contentInset(\n barBand + largeSize,\n childContentStyle.paddingTop ??\n childContentStyle.paddingVertical ??\n childContentStyle.padding\n );\n\n const bandStyle = useAnimatedStyle(\n () => ({\n height: bandHeight(barBand, largeHeight.value, offset.value, stretch && !reducedMotion),\n }),\n [barBand, stretch, reducedMotion]\n );\n\n const metrics = useMemo(\n () => ({ barBand, insetTop }),\n [barBand, insetTop]\n );\n\n const hasLarge = largeSize >= 1;\n\n const context = useMemo(\n () => ({ progress, offset, largeHeight, collapsed, hasLarge, measureLarge }),\n [progress, offset, largeHeight, collapsed, hasLarge, measureLarge]\n );\n\n const { root, band } = scrollHeaderVariants();\n\n return (\n \n \n {AnimatedScrollable && isValidElement(scrollable) ? (\n \n ) : (\n scrollable\n )}\n\n \n \n {/* Draw order, not writing order: the cover is behind the block,\n and the bar is over both so it covers the block on its way\n past rather than being crossed by it. */}\n {parts.cover}\n {parts.large}\n {parts.bar}\n \n \n \n \n );\n }\n);\nScrollHeaderRoot.displayName = 'ScrollHeader';\n\ninterface BandMetrics {\n /** Height of the pinned band, inset included. */\n barBand: number;\n /** How much of it is safe area. */\n insetTop: number;\n}\n\nconst BandMetricsContext = createContext({ barBand: BAR_HEIGHT, insetTop: 0 });\n\nexport interface ScrollHeaderBarProps extends ViewProps {\n className?: string;\n /**\n * What the bar is drawn on once it has taken over. `none` leaves it clear,\n * for a bar over a cover that should stay visible. `blur` frosts it, so the\n * content passing under the bar stays legible as shape and colour.\n *\n * `blur` needs `expo-blur`, which is optional, and it is replaced by an\n * opaque bar under Reduce Transparency. Both fall back to `plain` — a bar\n * whose title cannot be read is a worse answer than one that is not frosted.\n */\n surface?: ScrollHeaderSurface;\n /** A hairline under the bar, drawn with its surface. */\n divider?: boolean;\n /**\n * Depth of the frost, on `expo-blur`'s 0–100 scale. Defaults to 40 — heavier\n * than a scrim's, because a scrim covers a whole screen and this is a thin\n * band read against content moving under it. Ignored unless `surface` is\n * `blur`.\n */\n intensity?: number;\n /**\n * Which way the frost tints. Defaults to the app's theme rather than the\n * device's, so an app running light inside a dark OS frosts light. Ignored\n * unless `surface` is `blur`.\n */\n material?: ScrollHeaderMaterial;\n children?: ReactNode;\n}\n\n/**\n * The pinned bar. Its contents never move; its surface fades in as the large\n * block leaves, which is what makes the change read as one crossfade rather\n * than two things happening at once.\n */\nconst ScrollHeaderBar = forwardRef(\n (\n {\n className,\n surface = 'plain',\n divider = true,\n intensity = DEFAULT_BLUR_INTENSITY,\n material = 'default',\n children,\n ...props\n },\n ref\n ) => {\n const { progress } = useScrollHeader('ScrollHeader.Bar');\n const { barBand, insetTop } = useContext(BandMetricsContext);\n const { mode } = useThemeMode();\n const reduceTransparency = useReduceTransparency();\n\n // Three gates, and a preference that has not been answered yet counts as\n // switched on: the case worth being careful about is the one where it is.\n const blurring =\n surface === 'blur' && hasBlur && BlurView !== null && reduceTransparency === false;\n const fill = surface === 'blur' && !blurring ? 'plain' : surface;\n const { bar, barSurface } = scrollHeaderVariants({ surface: fill, divider });\n\n // Closed by the time the block has gone, so the block is never seen through\n // it — and never on top of whatever else the bar is carrying.\n const surfaceStyle = useAnimatedStyle(() => ({\n opacity: interpolate(progress.value, SURFACE_ARRIVE, [0, 1], 'clamp'),\n }));\n\n return (\n \n {/*\n The material is a child of the surface rather than a sibling of it,\n so there is one opacity driving both and the frost cannot arrive on a\n different curve from the fill it replaces. It sits inside the\n divider's border box, so the hairline stays on top of it.\n */}\n \n {blurring && BlurView ? (\n \n ) : null}\n \n \n {textChildren(children)}\n \n \n );\n }\n);\nScrollHeaderBar.displayName = 'ScrollHeader.Bar';\n\nexport interface ScrollHeaderLargeProps extends ViewProps {\n className?: string;\n children?: ReactNode;\n}\n\n/**\n * The expanded block. Its measured height is the distance the header\n * collapses over, so whatever is put in it — a title, a search field, a row of\n * chips — sets the scroll distance rather than a prop having to agree with it.\n */\nconst ScrollHeaderLarge = forwardRef(\n ({ className, onLayout, children, ...props }, ref) => {\n const { progress, measureLarge, collapsed } = useScrollHeader('ScrollHeader.Large');\n const { large } = scrollHeaderVariants();\n\n const style = useAnimatedStyle(() => ({\n opacity: interpolate(progress.value, LARGE_EXIT, [1, 0], 'clamp'),\n }));\n\n // The measurement is this block's whole job, so it is taken first and a\n // consumer's own `onLayout` runs after it rather than instead of it.\n const measure = useCallback(\n (event: LayoutChangeEvent) => {\n measureLarge(event);\n onLayout?.(event);\n },\n [measureLarge, onLayout]\n );\n\n return (\n \n \n {textChildren(children)}\n \n \n );\n }\n);\nScrollHeaderLarge.displayName = 'ScrollHeader.Large';\n\n/**\n * The screen's title. Written in both halves and styled from whichever it is\n * in: large and at rest in the block, compact and fading in on the bar.\n */\nconst ScrollHeaderTitle = forwardRef(({ className, ...props }, ref) => {\n const { progress, largeHeight, collapsed, hasLarge } = useScrollHeader('ScrollHeader.Title');\n const slot = useContext(ScrollHeaderSlotContext);\n\n // A bar with no block above it is not handing over from anything: it is the\n // only title the screen has, and it is wanted from the first frame. Fading it\n // in with the collapse would leave that screen untitled until somebody\n // scrolled it.\n const style = useAnimatedStyle(() => ({\n opacity: hasSpan(largeHeight.value)\n ? interpolate(progress.value, BAR_TITLE_ARRIVE, [0, 1], 'clamp')\n : 1,\n }));\n\n if (slot === 'large') {\n return (\n \n );\n }\n\n return (\n \n \n \n );\n});\nScrollHeaderTitle.displayName = 'ScrollHeader.Title';\n\n/** The quiet line under the title — a count, a byline, a date. */\nconst ScrollHeaderDescription = forwardRef(({ className, ...props }, ref) => (\n \n));\nScrollHeaderDescription.displayName = 'ScrollHeader.Description';\n\nexport interface ScrollHeaderActionsProps extends ViewProps {\n className?: string;\n children?: ReactNode;\n}\n\n/**\n * The controls at the trailing end of the bar. They stay put and stay\n * reachable — only the bar's surface and title are part of the transition.\n */\nconst ScrollHeaderActions = forwardRef(\n ({ className, children, ...props }, ref) => {\n const { actions } = scrollHeaderVariants();\n return (\n \n {textChildren(children)}\n \n );\n }\n);\nScrollHeaderActions.displayName = 'ScrollHeader.Actions';\n\nexport interface ScrollHeaderCoverProps extends ViewProps {\n className?: string;\n /** A picture behind the header. Laid out to fill the band, so it stretches with it. */\n source?: ImageSourcePropType;\n /**\n * The gradient drawn when there is no picture, or under one that has not\n * loaded. Defaults to two of the theme's series tokens, so an app that puts\n * its charts on brand puts this on brand with them.\n */\n colors?: [string, string, ...string[]];\n /** A wash over the cover, so a title stays legible on a bright picture. */\n scrim?: boolean;\n children?: ReactNode;\n}\n\n/**\n * A picture or a gradient filling the band. It has no height of its own — the\n * band's height is its height, which is why it stretches on an over-scroll by\n * being laid out larger rather than by being scaled up and going soft.\n */\nconst ScrollHeaderCover = forwardRef(\n (\n {\n className,\n source,\n colors,\n scrim = true,\n children,\n ...props\n },\n ref\n ) => {\n const { cover } = scrollHeaderVariants();\n const seriesOne = useCSSVariable('--color-chart-1');\n const seriesTwo = useCSSVariable('--color-chart-2');\n\n const ramp: [string, string, ...string[]] = colors ?? [\n typeof seriesOne === 'string' ? seriesOne : '#6366f1',\n typeof seriesTwo === 'string' ? seriesTwo : '#8b5cf6',\n ];\n\n return (\n \n \n {source ? (\n \n ) : null}\n {scrim ? : null}\n {children}\n \n );\n }\n);\nScrollHeaderCover.displayName = 'ScrollHeader.Cover';\n\n/**\n * Sort the children into the three band slots and the one scrollable. Sorting\n * by component rather than by order is what lets the example above read\n * top-down — bar, block, list — while the band still draws the cover first.\n */\nfunction splitChildren(children: ReactNode): {\n parts: { cover: ReactNode; large: ReactNode; bar: ReactNode };\n scrollable: ReactNode;\n} {\n let cover: ReactNode = null;\n let large: ReactNode = null;\n let bar: ReactNode = null;\n let scrollable: ReactNode = null;\n\n Children.forEach(children, (child) => {\n if (!isValidElement(child)) return;\n if (child.type === ScrollHeaderCover) cover = child;\n else if (child.type === ScrollHeaderLarge) large = child;\n else if (child.type === ScrollHeaderBar) bar = child;\n else if (!scrollable) scrollable = child;\n });\n\n return { parts: { cover, large, bar }, scrollable };\n}\n\n/** Point several refs at one node, skipping the ones that were not given. */\nfunction composeRefs(...refs: (Ref | undefined)[]) {\n return (node: unknown) => {\n for (const ref of refs) {\n if (typeof ref === 'function') ref(node);\n else if (ref && typeof ref === 'object') {\n (ref as { current: unknown }).current = node;\n }\n }\n };\n}\n\nconst styles = StyleSheet.create({\n // Android draws by elevation before z-order, so a band with none of its own\n // ends up under the scroller's own background.\n lift: { elevation: 4 },\n});\n\nexport const ScrollHeader = Object.assign(ScrollHeaderRoot, {\n Bar: ScrollHeaderBar,\n Large: ScrollHeaderLarge,\n Title: ScrollHeaderTitle,\n Description: ScrollHeaderDescription,\n Actions: ScrollHeaderActions,\n Cover: ScrollHeaderCover,\n});\n" + "content": "/**\n * ScrollHeader — a screen header that hands over to a compact bar as the page\n * scrolls.\n *\n * A screen with a title has two states and every app draws both: the title\n * large, at rest, with room around it, and the title small, pinned, once you\n * are reading. Hand-rolling the change between them is where screens stop\n * matching each other — one snaps, one crossfades at a different point, one\n * forgets the bar is over a safe area — so the two states and the transit\n * between them are one component here.\n *\n * ```tsx\n * \n * \n * Library\n * \n * \n * \n * \n * \n * Library\n * 128 components\n * \n * {rows}\n * \n * ```\n *\n * ## The band collapses; nothing inside it is animated up\n *\n * The header is one absolutely positioned band over the scroller, and the only\n * thing driven by the scroll is its height: `bar + max(0, large - offset)`.\n * The bar is anchored to its top and the large block to its bottom, so the\n * band shrinking is what carries the large block up and behind the bar, and\n * `overflow-hidden` is what cuts it off there.\n *\n * Driving the height rather than a `translateY` is what makes the rest fall\n * out for free. Over-scrolling makes `large - offset` larger than `large`, so\n * the band grows and a cover filling it stretches by being laid out bigger —\n * no scale transform, so a photograph stretches without going soft. And the\n * band's height is the inset the content needs, so there is one number rather\n * than two that have to agree.\n *\n * ## The two titles cross-fade; neither one morphs\n *\n * The large title and the bar title are separate elements that fade past each\n * other. A single title scaled and translated between the two positions tracks\n * beautifully until the text is long enough to truncate, at which point it is\n * animating between two different strings.\n *\n * Both are therefore in the tree at once, which is a problem for a screen\n * reader — one of them is invisible and would still be read. So the crossing\n * point is also published to React as `collapsed`, and whichever title is not\n * being shown is hidden from accessibility. That is one re-render per crossing\n * and no more: everything that runs per frame stays in shared values.\n *\n * ## The bar's surface is a fill, or a frost\n *\n * `surface` says what the bar is drawn on once it has taken over: a token\n * fill, nothing at all over a cover, or `blur` — a real material, so the rows\n * passing under the bar stay legible as shape and colour while losing the\n * detail that would compete with the title on top.\n *\n * The frost needs a native view, and there are two ways it cannot be drawn:\n * `expo-blur` is optional and may not be installed, and Reduce Transparency is\n * a preference that outranks the design. Both fall back to the plain\n * background token rather than to nothing, because a bar you cannot read is a\n * worse answer than a bar that is not frosted.\n *\n * ## What it needs\n *\n * A height to fill, and exactly one scrollable child. The child is cloned with\n * the scroll handler and the content inset composed onto it, the same way\n * `ScrollFade` wraps one, so a `ScrollView`, a `FlatList` or a `SectionList`\n * all work unchanged.\n */\nimport {\n Children,\n createContext,\n forwardRef,\n isValidElement,\n useCallback,\n useContext,\n useMemo,\n useState,\n type ComponentType,\n type ReactNode,\n type Ref,\n} from 'react';\nimport {\n Image,\n Platform,\n StyleSheet,\n View,\n type ImageSourcePropType,\n type LayoutChangeEvent,\n type NativeScrollEvent,\n type NativeSyntheticEvent,\n type StyleProp,\n type Text as RNText,\n type ViewProps,\n type ViewStyle,\n} from 'react-native';\nimport { LinearGradient } from 'expo-linear-gradient';\nimport Animated, {\n interpolate,\n runOnJS,\n scrollTo,\n useAnimatedReaction,\n useAnimatedRef,\n useAnimatedScrollHandler,\n useAnimatedStyle,\n useComposedEventHandler,\n useDerivedValue,\n useReducedMotion,\n useSharedValue,\n withTiming,\n type AnimatedScrollViewProps,\n type SharedValue,\n} from 'react-native-reanimated';\nimport { useSafeAreaInsets } from 'react-native-safe-area-context';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport { useCSSVariable } from 'uniwind';\nimport { hasBlur, useReduceTransparency } from '@/components/ui/scrim';\nimport { Text, textChildren, type TextProps } from '@/components/ui/text';\nimport { useThemeMode } from '@/hooks/use-theme';\nimport { cn } from '@/lib/cn';\nimport {\n BAR_TITLE_ARRIVE,\n HANDOVER_DURATION,\n LARGE_EXIT,\n SURFACE_ARRIVE,\n bandHeight,\n collapseProgress,\n contentInset,\n hasSpan,\n isCrossing,\n snapTarget,\n} from '@/components/ui/scroll-header-math';\nimport type { TextRef, ViewRef } from '@/components/ui/refs';\n\n/**\n * Height of the pinned bar, in points, before the device's top inset is added.\n * The platform navigation bars are 44 and 56; 48 is the target-size floor the\n * rest of the library holds compact controls to, and it sits between them.\n */\nconst BAR_HEIGHT = 48;\n\n/**\n * The scrollables Reanimated already animates. Its animated components are\n * ordinary function components carrying the *inner* component's name, so there\n * is nothing on one to test — but these two are module-level constants, and\n * identity is exact.\n */\n/** Which way the frost tints. `default` follows the app's theme. */\nexport type ScrollHeaderMaterial = 'light' | 'dark' | 'default';\n\n/**\n * `expo-blur`'s BlurView, or null when it is not installed. Resolved once at\n * module load — the require is cheap and caching it avoids a try/catch on\n * every render.\n */\nconst BlurView: ComponentType<{\n intensity?: number;\n tint?: ScrollHeaderMaterial;\n style?: unknown;\n}> | null = (() => {\n try {\n // eslint-disable-next-line @typescript-eslint/no-require-imports\n const mod = require('expo-blur');\n return (mod?.BlurView as ComponentType<{ intensity?: number }>) ?? null;\n } catch {\n return null;\n }\n})();\n\n/**\n * Depth of the frost. Heavier than a scrim's, because a scrim covers a whole\n * screen and this is a thin band read against content that is moving under it.\n */\nconst DEFAULT_BLUR_INTENSITY = 40;\n\nconst ANIMATED_SCROLLABLES = new Set([Animated.ScrollView, Animated.FlatList]);\n\nconst scrollHeaderVariants = tv({\n slots: {\n root: 'flex-1',\n band: 'absolute inset-x-0 top-0 overflow-hidden',\n cover: 'absolute inset-0',\n bar: 'absolute inset-x-0 top-0 flex-row items-center gap-3 px-4',\n barSurface: 'absolute inset-0',\n large: 'absolute inset-x-0 bottom-0 gap-1 px-4 pb-3',\n actions: 'ml-auto flex-row items-center gap-1',\n },\n variants: {\n /** What the bar is drawn on once it has taken over. */\n surface: {\n plain: { barSurface: 'bg-background' },\n muted: { barSurface: 'bg-card' },\n none: { barSurface: 'bg-transparent' },\n // No fill of its own: an opaque colour behind the material is part of\n // what the material samples, so a frost over one is a flat bar that has\n // paid for a native view.\n blur: { barSurface: 'bg-transparent' },\n },\n divider: {\n true: { barSurface: 'border-b border-border' },\n false: {},\n },\n },\n defaultVariants: {\n surface: 'plain',\n divider: true,\n },\n});\n\ntype ScrollHeaderVariantProps = VariantProps;\n\n/** What the bar is drawn on once the large block has gone. */\nexport type ScrollHeaderSurface = NonNullable;\n\n/** Which half of the header a part is standing in. */\nexport type ScrollHeaderSlot = 'bar' | 'large';\n\ninterface ScrollHeaderContextValue {\n /** 0 while the large block is whole, 1 once the bar has taken over. */\n progress: SharedValue;\n /** Distance scrolled, in points. Negative while the finger pulls down. */\n offset: SharedValue;\n /** Measured height of the large block. */\n largeHeight: SharedValue;\n /** The crossing point, published to React for accessibility. */\n collapsed: boolean;\n /** Whether there is a large block at all. A bar-only header has no crossing. */\n hasLarge: boolean;\n measureLarge: (event: LayoutChangeEvent) => void;\n}\n\nconst ScrollHeaderContext = createContext(undefined);\n\nfunction useScrollHeader(component: string): ScrollHeaderContextValue {\n const context = useContext(ScrollHeaderContext);\n if (!context) throw new Error(`${component} must be used within a `);\n return context;\n}\n\n/**\n * Which half a part is in. `Title` reads it to know which size to be and which\n * way to fade, so the same element can be written in both places.\n */\nconst ScrollHeaderSlotContext = createContext('large');\n\ninterface ScrollableProps {\n onScroll?: AnimatedScrollViewProps['onScroll'];\n contentContainerStyle?: StyleProp;\n scrollIndicatorInsets?: { top?: number; bottom?: number; left?: number; right?: number };\n scrollEventThrottle?: number;\n contentInsetAdjustmentBehavior?: AnimatedScrollViewProps['contentInsetAdjustmentBehavior'];\n ref?: Ref;\n}\n\nexport interface ScrollHeaderProps extends ViewProps {\n className?: string;\n /**\n * Height of the pinned bar in points, before the device's top inset. The\n * inset is added on top of this rather than taken out of it, so the bar's\n * contents keep this much room on every device.\n */\n barHeight?: number;\n /**\n * How much of the large block has to leave before the bar has fully taken\n * over, as a fraction of its height. Below 1 the crossing happens early,\n * which suits a tall block whose last few points are not worth waiting for.\n */\n threshold?: number;\n /**\n * Settle a part-scrolled band open or closed when the finger lifts, rather\n * than leaving the header half-collapsed.\n */\n snap?: boolean;\n /**\n * Let the band grow past its resting height when the scroller is pulled\n * down. A cover fills the band, so this is what stretches it. Off under\n * Reduce Motion.\n */\n stretch?: boolean;\n /** Add the device's top inset above the bar. Off inside a screen that already has one. */\n inset?: boolean;\n /**\n * Called as the bar takes over, and again when the large block comes back.\n * Fires on the crossing, not on every frame.\n */\n onCollapsedChange?: (collapsed: boolean) => void;\n /**\n * A shared value to mirror the collapse into, 0 to 1, for animating\n * something outside the header against the same transition.\n */\n progress?: SharedValue;\n /** The parts, and exactly one scrollable. */\n children?: ReactNode;\n}\n\n/**\n * The band, and the scroller it sits over. Everything that is not a part is\n * treated as the scrollable child.\n */\nconst ScrollHeaderRoot = forwardRef(\n (\n {\n className,\n barHeight = BAR_HEIGHT,\n threshold = 1,\n snap = true,\n stretch = true,\n inset = true,\n onCollapsedChange,\n progress: externalProgress,\n children,\n ...props\n },\n ref\n ) => {\n const insets = useSafeAreaInsets();\n const reducedMotion = useReducedMotion();\n const insetTop = inset ? insets.top : 0;\n const barBand = insetTop + barHeight;\n\n const offset = useSharedValue(0);\n const largeHeight = useSharedValue(0);\n const [largeSize, setLargeSize] = useState(0);\n const [collapsed, setCollapsed] = useState(false);\n\n const scrollRef = useAnimatedRef();\n\n // Measured into both a shared value, for the band's height, and React\n // state, for the content inset — the inset is a resting number that only\n // changes when the block is re-laid-out, so paying a render for it costs\n // nothing per frame and keeps the scroller's props stable.\n const measureLarge = useCallback((event: LayoutChangeEvent) => {\n const { height } = event.nativeEvent.layout;\n largeHeight.value = height;\n setLargeSize((previous) => (Math.abs(previous - height) < 0.5 ? previous : height));\n }, [largeHeight]);\n\n const progress = useDerivedValue(() => {\n const next = collapseProgress(offset.value, largeHeight.value, threshold);\n // A header with a block hands over across that block's height, and the\n // finger sets the pace. One without has no distance to interpolate over,\n // so the step is timed rather than instant — the alternative is a bar\n // whose surface appears between one frame and the next.\n const value = hasSpan(largeHeight.value)\n ? next\n : withTiming(next, { duration: HANDOVER_DURATION });\n if (externalProgress) externalProgress.value = value;\n return value;\n }, [threshold, externalProgress]);\n\n // The one thing the transition tells React about. `collapsed` gates the\n // accessibility of the two titles and is what `onCollapsedChange` reports;\n // the reaction fires on the crossing, not on the frames either side of it.\n const cross = useCallback(\n (next: boolean) => {\n setCollapsed(next);\n onCollapsedChange?.(next);\n },\n [onCollapsedChange]\n );\n\n useAnimatedReaction(\n () => progress.value >= 1,\n (isCollapsed, was) => {\n if (!isCrossing(isCollapsed, was)) return;\n runOnJS(cross)(isCollapsed);\n },\n [cross]\n );\n\n const settle = useCallback(\n (velocity: number) => {\n 'worklet';\n if (!snap) return;\n const target = snapTarget(offset.value, largeHeight.value, velocity);\n if (target === null) return;\n scrollTo(scrollRef, 0, target, true);\n },\n [snap, largeHeight, offset, scrollRef]\n );\n\n const { parts, scrollable } = useMemo(() => splitChildren(children), [children]);\n\n const childType = isValidElement(scrollable)\n ? (scrollable.type as ComponentType)\n : null;\n // Keyed on the element type rather than the element: rebuilding the\n // wrapper would remount the list and lose its scroll position. A child\n // that is animated already is used as it stands — wrapping one twice is\n // unsupported, and there is no marker on them to test for, so the two\n // that exist are recognised by identity.\n const AnimatedScrollable = useMemo(() => {\n if (!childType) return null;\n if (ANIMATED_SCROLLABLES.has(childType)) return childType;\n return Animated.createAnimatedComponent(childType);\n }, [childType]);\n\n const childProps = (isValidElement(scrollable) ? scrollable.props : {}) as ScrollableProps;\n const childOnScroll = childProps.onScroll;\n\n /*\n * A consumer's own `onScroll` is kept, whichever of the two kinds it is,\n * because silently dropping one looks like a bug in the scrolling rather\n * than in the call site.\n *\n * `useEvent` returns an object wearing a function's type, so the two are\n * told apart by what they actually are: an object is a Reanimated handler\n * and composes onto ours, staying on the UI thread. A plain function\n * cannot — `useComposedEventHandler` keeps only worklet handlers and drops\n * anything else without a word — so it is called across the bridge\n * instead, once per scroll event the child delivers. How often that is, is\n * `scrollEventThrottle`'s answer rather than this component's: every frame\n * at the default of 16, and as rare as the caller asks for above it.\n */\n const workletOnScroll = typeof childOnScroll === 'function' ? null : childOnScroll ?? null;\n const plainOnScroll = typeof childOnScroll === 'function' ? childOnScroll : null;\n\n // Rebuilt only when the callback itself changes, so the worklet below is\n // not rebuilt on every render of the child.\n const forwardScroll = useCallback(\n (native: NativeScrollEvent) => {\n // The `nativeEvent` crosses whole; the synthetic wrapper around it does\n // not, because the rest of one is a live object with methods on it.\n plainOnScroll?.({ nativeEvent: native } as NativeSyntheticEvent);\n },\n [plainOnScroll]\n );\n const forwards = plainOnScroll !== null;\n\n const scrollHandler = useAnimatedScrollHandler(\n {\n onScroll: (event) => {\n offset.value = event.contentOffset.y;\n if (forwards) {\n // Every field React Native puts on a scroll event, so a callback\n // reading `velocity` for a direction, or `targetContentOffset` for\n // where a fling is going, finds what it would have without the\n // header. Listed rather than spread: the event carries Reanimated's\n // own `eventName` too, and that is not part of the contract.\n runOnJS(forwardScroll)({\n contentInset: event.contentInset,\n contentOffset: event.contentOffset,\n contentSize: event.contentSize,\n layoutMeasurement: event.layoutMeasurement,\n velocity: event.velocity,\n zoomScale: event.zoomScale,\n targetContentOffset: event.targetContentOffset,\n } as NativeScrollEvent);\n }\n },\n onEndDrag: (event) => {\n settle(event.velocity?.y ?? 0);\n },\n onMomentumEnd: () => {\n settle(0);\n },\n },\n [settle, forwards, forwardScroll]\n );\n\n const onScroll = useComposedEventHandler([\n scrollHandler,\n (workletOnScroll as typeof scrollHandler | null) ?? null,\n ]);\n\n /*\n * The band's resting height, plus whatever top padding the child asked\n * for. A style array overrides rather than adds, so composing ours after\n * theirs would drop their padding and composing theirs after ours would\n * drop the inset — and they cannot write the sum themselves, because the\n * band's height is measured. Reading it and adding is the only\n * arrangement where both survive.\n */\n const childContentStyle = StyleSheet.flatten(childProps.contentContainerStyle) ?? {};\n const headerHeight = contentInset(\n barBand + largeSize,\n childContentStyle.paddingTop ??\n childContentStyle.paddingVertical ??\n childContentStyle.padding\n );\n\n const bandStyle = useAnimatedStyle(\n () => ({\n height: bandHeight(barBand, largeHeight.value, offset.value, stretch && !reducedMotion),\n }),\n [barBand, stretch, reducedMotion]\n );\n\n const metrics = useMemo(\n () => ({ barBand, insetTop }),\n [barBand, insetTop]\n );\n\n const hasLarge = largeSize >= 1;\n\n const context = useMemo(\n () => ({ progress, offset, largeHeight, collapsed, hasLarge, measureLarge }),\n [progress, offset, largeHeight, collapsed, hasLarge, measureLarge]\n );\n\n const { root, band } = scrollHeaderVariants();\n\n return (\n \n \n {AnimatedScrollable && isValidElement(scrollable) ? (\n \n ) : (\n scrollable\n )}\n\n \n \n {/* Draw order, not writing order: the cover is behind the block,\n and the bar is over both so it covers the block on its way\n past rather than being crossed by it. */}\n {parts.cover}\n {parts.large}\n {parts.bar}\n \n \n \n \n );\n }\n);\nScrollHeaderRoot.displayName = 'ScrollHeader';\n\ninterface BandMetrics {\n /** Height of the pinned band, inset included. */\n barBand: number;\n /** How much of it is safe area. */\n insetTop: number;\n}\n\nconst BandMetricsContext = createContext({ barBand: BAR_HEIGHT, insetTop: 0 });\n\nexport interface ScrollHeaderBarProps extends ViewProps {\n className?: string;\n /**\n * What the bar is drawn on once it has taken over. `none` leaves it clear,\n * for a bar over a cover that should stay visible. `blur` frosts it, so the\n * content passing under the bar stays legible as shape and colour.\n *\n * `blur` needs `expo-blur`, which is optional, and it is replaced by an\n * opaque bar under Reduce Transparency. Both fall back to `plain` — a bar\n * whose title cannot be read is a worse answer than one that is not frosted.\n */\n surface?: ScrollHeaderSurface;\n /** A hairline under the bar, drawn with its surface. */\n divider?: boolean;\n /**\n * Depth of the frost, on `expo-blur`'s 0–100 scale. Defaults to 40 — heavier\n * than a scrim's, because a scrim covers a whole screen and this is a thin\n * band read against content moving under it. Ignored unless `surface` is\n * `blur`.\n */\n intensity?: number;\n /**\n * Which way the frost tints. Defaults to the app's theme rather than the\n * device's, so an app running light inside a dark OS frosts light. Ignored\n * unless `surface` is `blur`.\n */\n material?: ScrollHeaderMaterial;\n children?: ReactNode;\n}\n\n/**\n * The pinned bar. Its contents never move; its surface fades in as the large\n * block leaves, which is what makes the change read as one crossfade rather\n * than two things happening at once.\n */\nconst ScrollHeaderBar = forwardRef(\n (\n {\n className,\n surface = 'plain',\n divider = true,\n intensity = DEFAULT_BLUR_INTENSITY,\n material = 'default',\n children,\n ...props\n },\n ref\n ) => {\n const { progress } = useScrollHeader('ScrollHeader.Bar');\n const { barBand, insetTop } = useContext(BandMetricsContext);\n const { mode } = useThemeMode();\n const reduceTransparency = useReduceTransparency();\n\n // Three gates, and a preference that has not been answered yet counts as\n // switched on: the case worth being careful about is the one where it is.\n const blurring =\n surface === 'blur' && hasBlur && BlurView !== null && reduceTransparency === false;\n const fill = surface === 'blur' && !blurring ? 'plain' : surface;\n const { bar, barSurface } = scrollHeaderVariants({ surface: fill, divider });\n\n // Closed by the time the block has gone, so the block is never seen through\n // it — and never on top of whatever else the bar is carrying.\n const surfaceStyle = useAnimatedStyle(() => ({\n opacity: interpolate(progress.value, SURFACE_ARRIVE, [0, 1], 'clamp'),\n }));\n\n return (\n \n {/*\n The material is a child of the surface rather than a sibling of it,\n so there is one opacity driving both and the frost cannot arrive on a\n different curve from the fill it replaces. It sits inside the\n divider's border box, so the hairline stays on top of it.\n */}\n \n {blurring && BlurView ? (\n \n ) : null}\n \n \n {textChildren(children)}\n \n \n );\n }\n);\nScrollHeaderBar.displayName = 'ScrollHeader.Bar';\n\nexport interface ScrollHeaderLargeProps extends ViewProps {\n className?: string;\n children?: ReactNode;\n}\n\n/**\n * The expanded block. Its measured height is the distance the header\n * collapses over, so whatever is put in it — a title, a search field, a row of\n * chips — sets the scroll distance rather than a prop having to agree with it.\n */\nconst ScrollHeaderLarge = forwardRef(\n ({ className, onLayout, children, ...props }, ref) => {\n const { progress, measureLarge, collapsed } = useScrollHeader('ScrollHeader.Large');\n const { large } = scrollHeaderVariants();\n\n const style = useAnimatedStyle(() => ({\n opacity: interpolate(progress.value, LARGE_EXIT, [1, 0], 'clamp'),\n }));\n\n // The measurement is this block's whole job, so it is taken first and a\n // consumer's own `onLayout` runs after it rather than instead of it.\n const measure = useCallback(\n (event: LayoutChangeEvent) => {\n measureLarge(event);\n onLayout?.(event);\n },\n [measureLarge, onLayout]\n );\n\n return (\n \n \n {textChildren(children)}\n \n \n );\n }\n);\nScrollHeaderLarge.displayName = 'ScrollHeader.Large';\n\n/**\n * The screen's title. Written in both halves and styled from whichever it is\n * in: large and at rest in the block, compact and fading in on the bar.\n */\nconst ScrollHeaderTitle = forwardRef(({ className, ...props }, ref) => {\n const { progress, largeHeight, collapsed, hasLarge } = useScrollHeader('ScrollHeader.Title');\n const slot = useContext(ScrollHeaderSlotContext);\n\n // A bar with no block above it is not handing over from anything: it is the\n // only title the screen has, and it is wanted from the first frame. Fading it\n // in with the collapse would leave that screen untitled until somebody\n // scrolled it.\n const style = useAnimatedStyle(() => ({\n opacity: hasSpan(largeHeight.value)\n ? interpolate(progress.value, BAR_TITLE_ARRIVE, [0, 1], 'clamp')\n : 1,\n }));\n\n if (slot === 'large') {\n return (\n \n );\n }\n\n return (\n \n \n \n );\n});\nScrollHeaderTitle.displayName = 'ScrollHeader.Title';\n\n/** The quiet line under the title — a count, a byline, a date. */\nconst ScrollHeaderDescription = forwardRef(({ className, ...props }, ref) => (\n \n));\nScrollHeaderDescription.displayName = 'ScrollHeader.Description';\n\nexport interface ScrollHeaderActionsProps extends ViewProps {\n className?: string;\n children?: ReactNode;\n}\n\n/**\n * The controls at the trailing end of the bar. They stay put and stay\n * reachable — only the bar's surface and title are part of the transition.\n */\nconst ScrollHeaderActions = forwardRef(\n ({ className, children, ...props }, ref) => {\n const { actions } = scrollHeaderVariants();\n return (\n \n {textChildren(children)}\n \n );\n }\n);\nScrollHeaderActions.displayName = 'ScrollHeader.Actions';\n\nexport interface ScrollHeaderCoverProps extends ViewProps {\n className?: string;\n /** A picture behind the header. Laid out to fill the band, so it stretches with it. */\n source?: ImageSourcePropType;\n /**\n * The gradient drawn when there is no picture, or under one that has not\n * loaded. Defaults to two of the theme's series tokens, so an app that puts\n * its charts on brand puts this on brand with them.\n */\n colors?: [string, string, ...string[]];\n /** A wash over the cover, so a title stays legible on a bright picture. */\n scrim?: boolean;\n children?: ReactNode;\n}\n\n/**\n * A picture or a gradient filling the band. It has no height of its own — the\n * band's height is its height, which is why it stretches on an over-scroll by\n * being laid out larger rather than by being scaled up and going soft.\n */\nconst ScrollHeaderCover = forwardRef(\n (\n {\n className,\n source,\n colors,\n scrim = true,\n children,\n ...props\n },\n ref\n ) => {\n const { cover } = scrollHeaderVariants();\n const seriesOne = useCSSVariable('--color-chart-1');\n const seriesTwo = useCSSVariable('--color-chart-2');\n\n const ramp: [string, string, ...string[]] = colors ?? [\n typeof seriesOne === 'string' ? seriesOne : '#6366f1',\n typeof seriesTwo === 'string' ? seriesTwo : '#8b5cf6',\n ];\n\n return (\n \n \n {source ? (\n \n ) : null}\n {scrim ? : null}\n {children}\n \n );\n }\n);\nScrollHeaderCover.displayName = 'ScrollHeader.Cover';\n\n/**\n * Sort the children into the three band slots and the one scrollable. Sorting\n * by component rather than by order is what lets the example above read\n * top-down — bar, block, list — while the band still draws the cover first.\n */\nfunction splitChildren(children: ReactNode): {\n parts: { cover: ReactNode; large: ReactNode; bar: ReactNode };\n scrollable: ReactNode;\n} {\n let cover: ReactNode = null;\n let large: ReactNode = null;\n let bar: ReactNode = null;\n let scrollable: ReactNode = null;\n\n Children.forEach(children, (child) => {\n if (!isValidElement(child)) return;\n if (child.type === ScrollHeaderCover) cover = child;\n else if (child.type === ScrollHeaderLarge) large = child;\n else if (child.type === ScrollHeaderBar) bar = child;\n else if (!scrollable) scrollable = child;\n });\n\n return { parts: { cover, large, bar }, scrollable };\n}\n\n/** Point several refs at one node, skipping the ones that were not given. */\nfunction composeRefs(...refs: (Ref | undefined)[]) {\n return (node: unknown) => {\n for (const ref of refs) {\n if (typeof ref === 'function') ref(node);\n else if (ref && typeof ref === 'object') {\n (ref as { current: unknown }).current = node;\n }\n }\n };\n}\n\nconst styles = StyleSheet.create({\n // Android draws by elevation before z-order, so a band with none of its own\n // ends up under the scroller's own background.\n lift: { elevation: 4 },\n});\n\nexport const ScrollHeader = Object.assign(ScrollHeaderRoot, {\n Bar: ScrollHeaderBar,\n Large: ScrollHeaderLarge,\n Title: ScrollHeaderTitle,\n Description: ScrollHeaderDescription,\n Actions: ScrollHeaderActions,\n Cover: ScrollHeaderCover,\n});\n" }, { "path": "ui/scroll-header-math.ts", diff --git a/apps/docs/public/r/search-bar.json b/apps/docs/public/r/search-bar.json index e35a9bd9d..69ebf75ca 100644 --- a/apps/docs/public/r/search-bar.json +++ b/apps/docs/public/r/search-bar.json @@ -7,6 +7,7 @@ "icons", "input", "keyboard-avoider", + "refs", "spinner", "text", "use-keyboard" @@ -20,7 +21,7 @@ { "path": "ui/search-bar.tsx", "type": "registry:ui", - "content": "/**\n * SearchBar — a text field for querying a list, with the two controls a search\n * needs and an ordinary field does not, and a panel of results that opens out\n * of the field itself.\n *\n * ```tsx\n * \n * \n * \n * \n * } onPress={add}>Claude\n * \n * \n * ```\n *\n * ## The results are above the field, and the field is above the keyboard\n *\n * A search that is being typed into has a keyboard under it, and a list drawn\n * below the field is a list drawn behind the keyboard. So `avoidKeyboard`\n * lifts the field until it sits `keyboardOffset` points clear of the keyboard's\n * top edge, and the panel opens *upward* out of it into the space that is\n * actually free.\n *\n * That puts the first result nearest the field and the last one furthest away,\n * which is the order a reader walking away from the caret expects. Pass\n * `panelPlacement=\"bottom\"` for a search bar in a header, where the space is\n * the other way round.\n *\n * The panel is positioned absolutely rather than laid out in the flow, so\n * opening it never moves the page underneath — a list that pushes the field it\n * belongs to is a field that walks away from the finger typing into it.\n *\n * ## Touches inside the panel must not close the keyboard\n *\n * The panel scrolls with `keyboardShouldPersistTaps=\"always\"`, and every press\n * inside it holds the field's focus open for a moment afterwards. Both are\n * needed, because a search closes the instant the field blurs and there are\n * two separate ways for a touch in the panel to blur it.\n *\n * `\"handled\"` only spares presses a child takes responsibility for, which\n * leaves the panel's own padding, the gaps between rows, a section heading and\n * the whole of `SearchBar.Status` as live dismiss surfaces — tapping the word\n * \"Searching …\" would end the search. `\"always\"` gives the panel back.\n *\n * The focus guard covers the other way: a control inside a row — an add\n * button, a remove ✕ — takes focus with the press on Android, and returning it\n * a frame later is not enough on its own, because the blur has already closed\n * the panel the control was drawn in. So a press in the panel marks the field\n * as still being used, and a blur arriving under that mark is answered by\n * asking for focus back rather than by ending the search.\n *\n * That guard only knows about presses that go through this component's own\n * parts, and a caller's `Pressable` in a row's `trailing` slot takes the touch\n * itself. So the panel also waits before believing any blur, and asks the\n * keyboard: it is still up, because nothing in the panel dismisses it, and a\n * search whose keyboard is still up has not ended. Focus goes back instead.\n *\n * ## The space kept for the field is not a target\n *\n * The card is one box around the results *and* the field, so it carries a\n * spacer where the field sits. That spacer is a plain view drawn over a\n * focused field, and a touch on a plain view is the platform's cue to dismiss\n * the keyboard — so winning one blurred the field and closed the panel drawn\n * out of that focus. Tapping the search box shut the results, which is exactly\n * backwards. The card and its spacer take no touches at all now.\n *\n * ## What has already been picked goes in the field\n *\n * `tokens` puts the choices made so far inside the field, before the caret, so\n * the query and what it has produced are one control rather than a control and\n * a list somewhere above it. `SearchBar.Token` is the chip; backspace on an\n * empty field fires `onRemoveLastToken`, which is what a token field does\n * everywhere else.\n *\n * They scroll rather than wrap: the field is one line tall, and a row of chips\n * that grew it would move the caret every time something was picked.\n *\n * ## The clear button, and why it is not the platform's\n *\n * A ✕ appears inside the field as soon as there is something to clear, and\n * takes it back to empty without dismissing the keyboard — clearing a query is\n * the start of the next one, not the end of the search. It is drawn here\n * rather than left to `clearButtonMode`, which exists on iOS only, cannot be\n * labelled for a screen reader and cannot be swapped for a spinner while\n * results are in flight.\n *\n * The glyph is 24 points and its touch box is 48, made up with slop rather\n * than with size. A 48-point circle inside a 40-point field either overflows\n * it or forces every search bar in an app to be as tall as the largest one.\n *\n * ## Cancel is a row, not a decoration\n *\n * `cancel=\"focus\"` puts a Cancel button beside the field and slides it in\n * while the field is being edited, which is the platform's own answer to\n * \"how do I get out of this search\". It is a sibling of the field rather than\n * something inside it, because it acts on the search as a whole: it empties\n * the query, drops focus and calls `onCancel`, and a control that ends the\n * thing it sits inside reads as part of the query it is about to discard.\n *\n * Its width is measured once and animated on the UI thread. The button is\n * always mounted when `cancel` is not `never`, so the measurement is already\n * there the first time the field is touched and the first slide is as smooth\n * as the tenth.\n *\n * ## Debouncing belongs to the caller's search, not to the field\n *\n * `onChangeText` always fires on every keystroke — a controlled field that\n * lags its own input is unusable. `debounce` is about the *query*: it holds\n * `onDebouncedChange` until typing pauses, so a network search runs once per\n * pause instead of once per letter. Submitting flushes it immediately, since\n * a return key is somebody saying they are done waiting.\n */\nimport {\n Children,\n createContext,\n forwardRef,\n isValidElement,\n useContext,\n useCallback,\n useEffect,\n useImperativeHandle,\n useMemo,\n useRef,\n useState,\n type ReactNode,\n} from 'react';\nimport {\n ScrollView,\n StyleSheet,\n TextInput,\n View,\n useWindowDimensions,\n type LayoutChangeEvent,\n type NativeSyntheticEvent,\n type TextInputKeyPressEventData,\n type TextInputSubmitEditingEventData,\n type ViewProps,\n type ViewStyle,\n} from 'react-native';\nimport Animated, {\n FadeIn,\n FadeOut,\n useAnimatedStyle,\n useSharedValue,\n withTiming,\n} from 'react-native-reanimated';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport { useKeyboard } from '@/hooks/use-keyboard';\nimport { SearchIcon, XIcon } from '@/components/ui/icons';\nimport {\n AnimatedPressable,\n type AnimatedPressableProps,\n} from '@/components/ui/animated-pressable';\nimport { KeyboardAvoider } from '@/components/ui/keyboard-avoider';\nimport { Text } from '@/components/ui/text';\nimport { Input, type InputProps } from '@/components/ui/input';\nimport { Spinner } from '@/components/ui/spinner';\nimport {\n cancelSearchBarDebounce,\n flushSearchBarDebounce,\n scheduleSearchBarDebounce,\n type SearchBarDebounceTimer,\n} from '@/components/ui/search-bar-debounce';\n\n/** Matches the field's own focus crossfade, so the row settles as one thing. */\nconst CANCEL_DURATION = 180;\n\n/** Space kept between the field and the Cancel button while it is out. */\nconst CANCEL_GAP = 8;\n\n/**\n * Slop around the 24-point clear glyph, taking its touch box to 48 without\n * changing the height of the field it sits in.\n */\nconst CLEAR_HIT_SLOP = 12;\n\n/** The panel's crossfade, matching every other anchored list in the library. */\nconst PANEL_IN = 140;\nconst PANEL_OUT = 120;\n\n/** Gap left between the panel's far edge and the edge of the screen. */\nconst PANEL_EDGE_GAP = 24;\n\n/** Floor for the derived height — below this a list is not worth opening. */\nconst PANEL_MIN_HEIGHT = 160;\n\n/**\n * Ceiling for the derived height, in points. About six rows and a heading.\n *\n * The room above a lifted field is most of the screen, and a panel that takes\n * all of it is a full-screen list that happens to have a search box under it —\n * the page it was opened over stops being visible, so the search stops reading\n * as something laid over the app. Past this the results scroll, which is what\n * a long list should do anyway.\n *\n * `panelMaxHeight` overrides it for a screen that really is nothing but the\n * search.\n */\nconst PANEL_MAX_HEIGHT = 320;\n\n/**\n * The field's height per size, matching `Input`'s own `h-10 / h-12 / h-14`.\n *\n * It is the fallback for the slot the panel keeps for the field, which is\n * otherwise the measured height and therefore zero on the frame the panel\n * first opens. A zero slot puts the card's bottom edge at the field's, so the\n * last row is drawn underneath the field — which is painted after the card and\n * takes the touch. The press then reads as a tap on the input.\n */\nconst FIELD_HEIGHT = { sm: 40, md: 48, lg: 56 } as const;\n\n/**\n * How long a press inside the panel keeps the field's focus. Long enough to\n * cover the blur Android sends with the press and the re-render that follows\n * it, short enough that a real dismissal is never held open.\n */\nconst FOCUS_GUARD = 400;\n\n/**\n * How long the panel waits after a blur before believing the search is over.\n *\n * The guard above only covers presses that go through this component's own\n * parts. A caller's own `Pressable` in a row's `trailing` slot takes the touch\n * itself and this component never hears about it — so the catch-all is the\n * keyboard: it is still up, because nothing in the panel dismisses it, and a\n * search whose keyboard is still up has not ended. The pause is long enough\n * for focus to come back on its own and short enough that a real dismissal\n * does not leave the panel hanging.\n */\nconst BLUR_GRACE = 220;\n\n/** Share of the field a row of tokens may take before it starts scrolling. */\nconst TOKEN_MAX_SHARE = 0.6;\n\n/*\n * The card is pinned to the edge of the field's own slot and grows away from\n * it, so the field's box is the one thing that never moves.\n */\nconst CARD_ABOVE: ViewStyle = { position: 'absolute', bottom: 0, left: 0, right: 0 };\nconst CARD_BELOW: ViewStyle = { position: 'absolute', top: 0, left: 0, right: 0 };\n\n/*\n * `zIndex` *and* `elevation`, on the field's own box rather than on the card:\n * Android draws siblings in tree order and takes its stacking from elevation,\n * so a card overlapping the content above the field would otherwise be painted\n * under it — and putting it here keeps the field painting over the card, which\n * is what lets the two be one surface.\n *\n * Applied always, and never toggled. React Native implements `zIndex` on iOS\n * by reordering the parent's subviews, which takes the view out of the\n * hierarchy and puts it back — and a `UITextField` removed from the window\n * resigns first responder. Setting this at the moment the panel opens\n * therefore blurred the field that had just been focused, which closed the\n * panel again: the keyboard came up and went straight back down, and the only\n * way to get a search open was to be quicker than the render.\n *\n * A constant stacking order costs nothing while the panel is shut — there is\n * nothing to stack against — and the elevation draws no shadow, because the\n * box has no background for Android to cast one from.\n */\nconst RAISED: ViewStyle = { zIndex: 20, elevation: 20 };\n\nconst searchBarVariants = tv({\n slots: {\n row: 'w-full flex-row items-center',\n /*\n * The box the panel is positioned against — the field alone, so the panel\n * is the field's width rather than the row's and does not run out under a\n * Cancel button that is only sometimes there.\n */\n anchor: 'relative',\n field: '',\n /*\n * One card around the results *and* the field, with the field drawn over\n * the space kept for it at the bottom.\n *\n * It has to be one box because the outline is one outline. Drawn as two —\n * a bordered panel above a bordered field — the field's edge is the focus\n * ring, since a field with a panel open is a field being typed into, and\n * the card ends up with a brighter box welded to a dimmer one.\n *\n * `bg-popover`, not `bg-card`: this floats over the page rather than\n * sitting in it, and a card is one step from the background — close enough\n * that in dark mode the whole thing dissolves into the screen behind it.\n */\n panel: 'overflow-hidden rounded-2xl border border-border bg-popover shadow-lg',\n panelList: 'p-1.5',\n /** The hairline between the results and the field. */\n panelDivider: 'w-full bg-border',\n /*\n * A gap between the rows, because a selected one is drawn as a filled\n * shape. Stacked flush, its fill runs into the row underneath and the two\n * read as one block with a coloured top half — a row's rounded corners are\n * only visible when there is background either side of them to round\n * against. Two points separates the fills and still reads as a list rather\n * than a set of cards.\n */\n section: 'gap-0.5',\n sectionLabel: 'px-3 pb-1 pt-2 text-sm text-muted-foreground',\n item: 'flex-row items-center gap-3 rounded-lg px-3 py-2.5',\n itemLabel: 'flex-1 text-base text-foreground',\n status: 'flex-row items-center justify-center gap-2 px-3 py-8',\n // Clipped, because this is what the Cancel button is revealed out of: the\n // button keeps its measured width and the container's grows past it.\n //\n // `self-stretch` gives it the row's height, which it has no other way of\n // getting — its only child is positioned absolutely, so it has no content\n // to be as tall as.\n cancelClip: 'self-stretch overflow-hidden',\n cancelButton: 'absolute bottom-0 end-0 top-0 items-center justify-center ps-2',\n cancelLabel: 'font-medium text-primary',\n clear: 'items-center justify-center rounded-full',\n /*\n * The chips sit in the field's start content, which `Input` measures and\n * turns into padding on the text — so the caret starts after them however\n * many there are, and nothing typed ever runs underneath them.\n */\n tokenRow: 'flex-row items-center gap-1.5',\n token: 'flex-row items-center gap-1 rounded-full bg-accent ps-2 pe-1',\n tokenLabel: 'text-accent-foreground',\n tokenRemove: 'items-center justify-center rounded-full',\n },\n variants: {\n size: {\n sm: {\n cancelLabel: 'text-[14px]',\n clear: 'h-6 w-6',\n token: 'h-6',\n tokenLabel: 'text-[13px]',\n tokenRemove: 'h-4 w-4',\n },\n md: {\n cancelLabel: 'text-[16px]',\n clear: 'h-6 w-6',\n token: 'h-7',\n tokenLabel: 'text-[14px]',\n tokenRemove: 'h-5 w-5',\n },\n lg: {\n cancelLabel: 'text-[16px]',\n clear: 'h-7 w-7',\n token: 'h-8',\n tokenLabel: 'text-[15px]',\n tokenRemove: 'h-5 w-5',\n },\n },\n /**\n * The field's corner. `pill` is the shape a search field takes when it is\n * chrome — sitting above a list, in a header — and `rounded` the one it\n * takes inside a form beside other fields.\n */\n shape: {\n rounded: { field: '' },\n pill: { field: 'rounded-full' },\n },\n /**\n * Which edge of the field the card grows out of. The field's corners on\n * that edge go square and its border comes off entirely — the card around\n * both of them is what draws the edge.\n */\n attached: {\n none: {},\n top: { field: 'rounded-t-none rounded-b-2xl border-0' },\n bottom: { field: 'rounded-b-none rounded-t-2xl border-0' },\n },\n selected: {\n true: { item: 'bg-accent' },\n },\n },\n defaultVariants: {\n size: 'md',\n shape: 'rounded',\n attached: 'none',\n },\n});\n\ntype SearchBarVariantProps = VariantProps;\n\n/** Glyph sizes per field size — the icon tracks the text, not the box. */\nconst ICON_SIZE = { sm: 16, md: 18, lg: 20 } as const;\n\ninterface SearchBarContextValue {\n /** The field's size, so a chip drawn in it matches the text beside it. */\n size: NonNullable;\n /**\n * Marks the field as still in use and asks for focus back. Called by\n * anything pressable the panel or the field contains, before the press has\n * had a chance to blur the field and close the search around it.\n */\n retainFocus: () => void;\n}\n\nconst SearchBarContext = createContext(null);\n\n/** Where the results open. */\nexport type SearchBarPanelPlacement = 'top' | 'bottom';\n\n/** When the results are shown. */\nexport type SearchBarPanelMode = 'never' | 'focus' | 'always';\n\n/**\n * What SearchBar takes from Input, minus everything it owns itself. The form\n * furniture is dropped along with it: a label and an error line stack above\n * and below the field, and Cancel sits beside the whole stack rather than\n * beside the field it belongs to. Use `Field` for a search that is one answer\n * in a form.\n *\n * The keyboard props go too. Input's would move the field and leave the Cancel\n * button and the panel where they were; SearchBar lifts all three together.\n */\ntype InheritedInputProps = Omit<\n InputProps,\n | 'avoidKeyboard'\n | 'defaultValue'\n | 'description'\n | 'endContent'\n | 'errorMessage'\n | 'interactiveContent'\n | 'isRequired'\n | 'keyboardBottomInset'\n | 'keyboardMode'\n | 'keyboardOffset'\n | 'label'\n | 'multiline'\n | 'onChangeText'\n | 'size'\n | 'startContent'\n | 'value'\n>;\n\nexport interface SearchBarProps\n extends InheritedInputProps,\n Omit {\n /**\n * The field's background, from `Input`. `outline` draws its own edge, for a\n * search bar sitting on the page; `filled` drops it, for one inside a card\n * or a header where a second border reads as a seam. Defaults to `outline`.\n */\n variant?: InputProps['variant'];\n /** The query, when the caller holds it. Leave unset to let the field keep it. */\n value?: string;\n /** Starting query for an uncontrolled field. Ignored once `value` is passed. */\n defaultValue?: string;\n /** Fires on every keystroke. For a search that costs something, see `debounce`. */\n onChangeText?: (value: string) => void;\n /** The return key, which is labelled Search. Flushes `onDebouncedChange` first. */\n onSubmit?: (value: string) => void;\n /**\n * How long typing has to pause before `onDebouncedChange` runs, in\n * milliseconds. `0` runs it on every keystroke, which is only right for a\n * filter over a list already in memory.\n */\n debounce?: number;\n /** The query, once typing has paused for `debounce` milliseconds. */\n onDebouncedChange?: (value: string) => void;\n /** Fires after the ✕ empties the field. The field keeps focus. */\n onClear?: () => void;\n /** Fires after Cancel empties the field and drops focus. */\n onCancel?: () => void;\n /** Whether the ✕ appears once there is a query. */\n isClearable?: boolean;\n /**\n * When the Cancel button is beside the field. `focus` slides it in while the\n * field is being edited and away again when it is not, which is what a\n * search bar above a list wants. `always` keeps it out, for a screen that is\n * nothing but the search.\n */\n cancel?: 'never' | 'focus' | 'always';\n /** The Cancel button's word. */\n cancelLabel?: string;\n /** How the ✕ announces itself. */\n clearLabel?: string;\n /**\n * Results are on their way. A spinner takes the ✕'s place, because the two\n * would otherwise sit on top of one another at exactly the moment a query is\n * both non-empty and running.\n */\n loading?: boolean;\n /** The leading glyph, for a search over something with a symbol of its own. */\n icon?: ReactNode;\n /**\n * Lift the whole search — field, Cancel button and panel — until it sits\n * clear of the software keyboard, and put it back on blur. Without it the\n * field stays where the page left it, which on most screens is behind the\n * keyboard it just opened.\n *\n * Install `react-native-keyboard-controller` for this to behave on Android.\n *\n * Do not toggle it at runtime: it changes which component wraps the row, so\n * the field would remount and lose focus.\n */\n avoidKeyboard?: boolean;\n /** Gap kept between the field's bottom edge and the keyboard. */\n keyboardOffset?: number;\n /**\n * When the results panel is shown. `focus` opens it while the field is being\n * typed into, `always` keeps it out for a screen that is nothing but the\n * search, `never` ignores the children entirely.\n */\n panel?: SearchBarPanelMode;\n /**\n * Which side of the field the panel opens out of. `top` is the default,\n * because the space under a focused field belongs to the keyboard.\n */\n panelPlacement?: SearchBarPanelPlacement;\n /**\n * Cap on the panel's height, in points.\n *\n * The panel takes the smaller of this and the room between the field and the\n * edge of the screen, so it never runs off the top of the display. Unset, it\n * is capped at about six rows: the space above a lifted field is most of the\n * screen, and a panel that takes all of it stops reading as something laid\n * over the app. Longer lists scroll.\n */\n panelMaxHeight?: number;\n /**\n * What has been picked so far, drawn inside the field before the caret.\n * `SearchBar.Token` is the chip; anything else that fits on one line works\n * too. Tokens scroll rather than wrap, so the field stays one line tall.\n */\n tokens?: ReactNode;\n /**\n * Fires when backspace is pressed in an empty field. Remove the last token\n * here — it is the gesture every token field answers, and without it the\n * only way back out of a choice is its own ✕.\n */\n onRemoveLastToken?: () => void;\n /** The panel's contents — `SearchBar.Section`, `.Item` and `.Status`. */\n children?: ReactNode;\n}\n\nconst SearchBarRoot = forwardRef(\n (\n {\n value: valueProp,\n defaultValue,\n onChangeText,\n onSubmit,\n debounce = 0,\n onDebouncedChange,\n onClear,\n onCancel,\n isClearable = true,\n cancel = 'never',\n cancelLabel = 'Cancel',\n clearLabel = 'Clear search',\n loading = false,\n icon,\n shape,\n size = 'md',\n className,\n containerClassName,\n disabled,\n avoidKeyboard = false,\n keyboardOffset = 12,\n panel = 'focus',\n panelPlacement = 'top',\n panelMaxHeight,\n tokens,\n onRemoveLastToken,\n children,\n onFocus,\n onBlur,\n onKeyPress,\n onSubmitEditing,\n ...props\n },\n ref\n ) => {\n const controlled = valueProp !== undefined;\n const [internal, setInternal] = useState(defaultValue ?? '');\n const text = controlled ? valueProp : internal;\n\n const [focused, setFocused] = useState(false);\n const inputRef = useRef(null);\n useImperativeHandle(ref, () => inputRef.current as TextInput, []);\n\n /*\n * Counted rather than tested for truthiness: `{results.map(…)}` over an\n * empty array is a child, and a panel that opens on nothing is a card of\n * padding.\n */\n const hasPanel = useMemo(() => {\n let found = false;\n Children.forEach(children, (child) => {\n if (isValidElement(child)) found = true;\n });\n return found;\n }, [children]);\n\n const panelOpen =\n !disabled &&\n panel !== 'never' &&\n hasPanel &&\n (panel === 'always' || focused);\n\n const attached = panelOpen ? panelPlacement : 'none';\n const slots = searchBarVariants({ size, shape, attached });\n\n const setText = useCallback(\n (next: string) => {\n if (!controlled) setInternal(next);\n onChangeText?.(next);\n },\n [controlled, onChangeText]\n );\n\n /*\n * The debounced callback is read through a ref so an inline arrow function\n * — which is what a caller writes — does not restart the timer on every\n * render and push the pause out forever.\n */\n const debouncedRef = useRef(onDebouncedChange);\n const debounceTimerRef = useRef(null);\n useEffect(() => {\n debouncedRef.current = onDebouncedChange;\n });\n\n // Skipped on mount: nothing was typed, so there is no pause to be at the\n // end of, and firing here would run a search for the initial value.\n const settled = useRef(false);\n useEffect(() => {\n if (!settled.current) {\n settled.current = true;\n return;\n }\n scheduleSearchBarDebounce(debounceTimerRef, debouncedRef.current, text, debounce);\n return () => {\n cancelSearchBarDebounce(debounceTimerRef);\n };\n }, [text, debounce]);\n\n /*\n * Set while a press inside the panel or the field is being served. A blur\n * arriving under it is the press taking focus rather than the search\n * ending, so it is answered by asking for focus back — the panel is drawn\n * out of `focused`, and letting it through would close the panel the\n * pressed control is standing in.\n */\n const guarded = useRef(false);\n const guardTimer = useRef | null>(null);\n\n useEffect(\n () => () => {\n if (guardTimer.current) clearTimeout(guardTimer.current);\n },\n []\n );\n\n const retainFocus = useCallback(() => {\n if (disabled) return;\n guarded.current = true;\n if (guardTimer.current) clearTimeout(guardTimer.current);\n guardTimer.current = setTimeout(() => {\n guarded.current = false;\n }, FOCUS_GUARD);\n inputRef.current?.focus();\n }, [disabled]);\n\n /*\n * Set while the search is being ended deliberately — Cancel, or a caller\n * calling `blur()`. Without it the recovery below would fight the very\n * thing that is trying to close the search.\n */\n const ending = useRef(false);\n const closeTimer = useRef | null>(null);\n const clearCloseTimer = useCallback(() => {\n if (closeTimer.current) clearTimeout(closeTimer.current);\n closeTimer.current = null;\n }, []);\n\n useEffect(() => clearCloseTimer, [clearCloseTimer]);\n\n // Read inside a timer, so it has to be a ref rather than the render value.\n const keyboardUp = useRef(false);\n\n const handleFocus = useCallback>(\n (event) => {\n clearCloseTimer();\n ending.current = false;\n setFocused(true);\n onFocus?.(event);\n },\n [clearCloseTimer, onFocus]\n );\n\n const handleBlur = useCallback>(\n (event) => {\n // The caller asked for the event, so it goes out now rather than at\n // the end of the pause — this is about what the *panel* believes.\n onBlur?.(event);\n\n if (guarded.current) {\n inputRef.current?.focus();\n return;\n }\n\n clearCloseTimer();\n closeTimer.current = setTimeout(() => {\n closeTimer.current = null;\n /*\n * A keyboard that never went down is not on its own proof that the\n * focus is still inside this search.\n *\n * Put two search bars on one screen and they hand the keyboard\n * between them: the first blurs, the keyboard stays up because the\n * second now has it, and the first takes it straight back — then the\n * second does the same, and both draw themselves as the field being\n * typed into. A screen showing sizes or variants is four of them in a\n * column, so this is the arrangement, not a corner case.\n *\n * So ask who actually holds it. Another field means somebody moved\n * on deliberately; a focus that has landed nowhere is one of this\n * search's own buttons taking it, which is what the recovery is for.\n */\n const holder = TextInput.State.currentlyFocusedInput();\n const elsewhere = holder != null && holder !== inputRef.current;\n if (!ending.current && keyboardUp.current && !elsewhere) {\n inputRef.current?.focus();\n return;\n }\n setFocused(false);\n }, BLUR_GRACE);\n },\n [clearCloseTimer, onBlur]\n );\n\n const handleKeyPress = useCallback(\n (event: NativeSyntheticEvent) => {\n // Only on an empty field: while there is a query, backspace is editing\n // it, and eating a token instead would delete something nobody aimed at.\n if (event.nativeEvent.key === 'Backspace' && text.length === 0) {\n onRemoveLastToken?.();\n }\n onKeyPress?.(event);\n },\n [onKeyPress, onRemoveLastToken, text.length]\n );\n\n const handleSubmit = useCallback(\n (event: NativeSyntheticEvent) => {\n if (disabled) return;\n // A return key is somebody saying they are done waiting, so the\n // pending pause is spent rather than waited out.\n flushSearchBarDebounce(debounceTimerRef, debouncedRef.current, text);\n onSubmit?.(text);\n onSubmitEditing?.(event);\n },\n [disabled, onSubmit, onSubmitEditing, text]\n );\n\n const handleClear = useCallback(() => {\n if (disabled) return;\n setText('');\n onClear?.();\n // The keyboard stays: clearing a query is the start of the next one.\n // Android takes focus away with the press, so it is asked back.\n inputRef.current?.focus();\n }, [disabled, onClear, setText]);\n\n const handleCancel = useCallback(() => {\n if (disabled) return;\n // Deliberate: the recovery above must not answer this blur by handing\n // the focus straight back.\n ending.current = true;\n guarded.current = false;\n setText('');\n setFocused(false);\n inputRef.current?.blur();\n onCancel?.();\n }, [disabled, onCancel, setText]);\n\n /*\n * Cancel's width is measured once and driven from a shared value, so the\n * slide costs no re-render. `cancelWidth` is a shared value rather than\n * state for the same reason — a measurement that arrives as state would\n * re-render the field it is beside.\n */\n const cancelWidth = useSharedValue(0);\n const cancelOut = cancel === 'always' || (cancel === 'focus' && focused);\n const cancelProgress = useSharedValue(cancel === 'always' ? 1 : 0);\n\n useEffect(() => {\n if (cancel === 'never') return;\n cancelProgress.value = withTiming(cancelOut ? 1 : 0, {\n duration: CANCEL_DURATION,\n });\n }, [cancel, cancelOut, cancelProgress]);\n\n const cancelStyle = useAnimatedStyle(() => ({\n width: (cancelWidth.value + CANCEL_GAP) * cancelProgress.value,\n opacity: cancelProgress.value,\n }));\n\n const handleCancelLayout = useCallback(\n (event: LayoutChangeEvent) => {\n cancelWidth.value = event.nativeEvent.layout.width;\n },\n [cancelWidth]\n );\n\n /*\n * How much room the panel has, in the direction it opens. Measured on the\n * JS side because it decides a layout constraint rather than a frame of an\n * animation: a `maxHeight` that changed every frame would re-lay out the\n * list under the finger scrolling it.\n */\n const { height: windowHeight } = useWindowDimensions();\n const { height: keyboardHeight } = useKeyboard();\n useEffect(() => {\n keyboardUp.current = keyboardHeight > 0;\n }, [keyboardHeight]);\n const anchorRef = useRef(null);\n const [anchorBox, setAnchorBox] = useState<{\n top: number;\n height: number;\n width: number;\n } | null>(null);\n\n const measureAnchor = useCallback(() => {\n anchorRef.current?.measureInWindow((_x, y, width, height) => {\n setAnchorBox((current) =>\n current &&\n current.top === y &&\n current.height === height &&\n current.width === width\n ? current\n : { top: y, height, width }\n );\n });\n }, []);\n\n useEffect(() => {\n if (!panelOpen) return;\n measureAnchor();\n }, [panelOpen, keyboardHeight, measureAnchor]);\n\n const resolvedMaxHeight = useMemo(() => {\n const fieldHeight = anchorBox?.height ?? 0;\n /*\n * While the field is riding the keyboard, where it has come to rest is\n * computed rather than measured: the lift is a transform applied on the\n * UI thread, so a measurement taken from JavaScript is a frame behind it\n * for the whole of the animation.\n */\n const fieldTop =\n avoidKeyboard && keyboardHeight > 0\n ? windowHeight - keyboardHeight - keyboardOffset - fieldHeight\n : (anchorBox?.top ?? 0);\n const room =\n panelPlacement === 'top'\n ? fieldTop - PANEL_EDGE_GAP\n : windowHeight - keyboardHeight - fieldTop - fieldHeight - PANEL_EDGE_GAP;\n /*\n * The smaller of what it is allowed and what it actually has, floored so\n * a cramped screen still opens something worth reading. A caller's cap\n * is clamped to the room too — a cap taller than the space available is\n * a panel running off the top of the display.\n */\n const cap = panelMaxHeight ?? PANEL_MAX_HEIGHT;\n return Math.max(Math.min(cap, room), PANEL_MIN_HEIGHT);\n }, [\n anchorBox,\n avoidKeyboard,\n keyboardHeight,\n keyboardOffset,\n panelMaxHeight,\n panelPlacement,\n windowHeight,\n ]);\n\n const glyph = (\n /*\n * Decorative, and said so here rather than through Input's\n * `interactiveContent` — that flag covers both ends of the field, and\n * turning it off to quieten the magnifier would take the clear button's\n * touches with it.\n */\n \n {icon ?? }\n \n );\n\n /*\n * Capped and scrolling rather than wrapping. The field is one line tall, so\n * a row of chips allowed to grow would move the caret every time something\n * was picked; and left uncapped it would take the whole field and leave\n * nowhere to type the next query.\n */\n const tokenRow = tokens ? (\n \n {tokens}\n \n ) : null;\n\n const startContent = tokenRow ? (\n <>\n {glyph}\n {tokenRow}\n \n ) : (\n glyph\n );\n\n const endContent = loading ? (\n \n ) : isClearable && text.length > 0 ? (\n \n \n \n ) : null;\n\n const field = (\n \n );\n\n const context = useMemo(\n () => ({ size, retainFocus }),\n [retainFocus, size]\n );\n\n // Nothing beside it and nothing under it: the field is the whole component,\n // and a wrapper around it would only be a box for the caller's layout to\n // fight.\n if (cancel === 'never' && !avoidKeyboard && !hasPanel) {\n return (\n \n \n {field}\n \n \n );\n }\n\n /*\n * The results, the hairline and the room the field occupies, in the order\n * they are stacked. The field itself is drawn over that last piece rather\n * than inside the card: it has to keep its own place in the layout, and a\n * text field that moved into an absolutely positioned box on focus would\n * remount and lose the keyboard it just opened.\n */\n const list = (\n \n {children}\n \n );\n const divider = (\n \n );\n /*\n * The room the card keeps for the field, and the reason it is inert.\n *\n * It is a spacer drawn at exactly the field's position, inside the card and\n * therefore outside the list's `keyboardShouldPersistTaps`. Any touch it\n * wins is a touch on a plain view while a field is focused, which is the\n * platform's cue to dismiss the keyboard — so the field blurs, and the\n * panel drawn out of that focus closes. From the outside that is \"tapping\n * the search box closes the results\", which is exactly backwards.\n *\n * It has nothing to be pressed for. `none` puts the touch through to the\n * field underneath it, where it was aimed.\n */\n const fieldSlot = (\n \n );\n\n const anchor = (\n \n {panelOpen ? (\n \n {panelPlacement === 'top'\n ? [list, divider, fieldSlot]\n : [fieldSlot, divider, list]}\n \n ) : null}\n {field}\n \n );\n\n const body =\n cancel === 'never' ? (\n anchor\n ) : (\n <>\n {anchor}\n \n {/*\n * Absolute, and pinned to the end edge: it keeps its natural width\n * inside a container whose width is animating, so the clip reveals\n * it from the edge instead of squeezing the word as it arrives. It\n * is also what makes the measurement possible at all — a child laid\n * out against a container of width 0 would otherwise report 0.\n */}\n \n {cancelLabel}\n \n \n \n );\n\n /*\n * The keyboard hook is behind a component boundary rather than a flag.\n * Calling it at all has global consequences — without the keyboard\n * controller installed it falls back to Reanimated's useAnimatedKeyboard,\n * which switches Android out of adjustResize for the whole app. A search\n * bar that never asked to avoid the keyboard must not do that to every\n * other screen.\n */\n if (avoidKeyboard) {\n return (\n \n \n {body}\n \n \n );\n }\n\n return (\n \n {body}\n \n );\n }\n);\n\nSearchBarRoot.displayName = 'SearchBar';\n\nexport interface SearchBarSectionProps extends ViewProps {\n className?: string;\n /**\n * The heading over the run of rows — \"Suggested\", \"Results\". Announced as a\n * header, so a screen reader reaching the group is told what it is before\n * walking into it.\n */\n label?: string;\n children?: ReactNode;\n}\n\n/** A labelled run of rows inside the panel. */\nfunction SearchBarSection({ className, label, children, ...props }: SearchBarSectionProps) {\n const { section, sectionLabel } = searchBarVariants();\n return (\n \n {label ? (\n \n {label}\n \n ) : null}\n {children}\n \n );\n}\n\nSearchBarSection.displayName = 'SearchBar.Section';\n\nexport interface SearchBarItemProps extends Omit {\n className?: string;\n /** Anything before the label — an avatar, a logo, a status dot. */\n leading?: ReactNode;\n /**\n * Anything after it. A slot rather than a built-in button, because what a\n * result row offers differs per search: an add, a pin, a count, nothing.\n */\n trailing?: ReactNode;\n /** A second line under the label, for what the label alone cannot say. */\n description?: string;\n /** Draws the row as the one the search has settled on. */\n selected?: boolean;\n /** The row's label. */\n children?: ReactNode;\n}\n\n/** One result. */\nfunction SearchBarItem({\n className,\n leading,\n trailing,\n description,\n selected,\n children,\n onPressIn,\n ...props\n}: SearchBarItemProps) {\n const { item, itemLabel } = searchBarVariants({ selected: !!selected });\n const search = useContext(SearchBarContext);\n return (\n {\n search?.retainFocus();\n onPressIn?.(event);\n }}\n // A row is a wide target, and a target that shrinks when pressed reads as\n // a card rather than a line in a list. The dim is the whole feedback.\n pressScale={1}\n pressOpacity={0.6}\n // The two points of gap the section leaves between rows belong to\n // neither of them otherwise, and a dead line across a list of targets is\n // a tap that does nothing.\n hitSlop={{ top: 2, bottom: 2 }}\n {...props}\n className={item({ className })}\n >\n {leading}\n \n \n {children}\n \n {description ? (\n \n {description}\n \n ) : null}\n \n {trailing}\n \n );\n}\n\nSearchBarItem.displayName = 'SearchBar.Item';\n\nexport interface SearchBarStatusProps extends ViewProps {\n className?: string;\n /** A spinner beside the line, for a search that is still running. */\n loading?: boolean;\n children?: ReactNode;\n}\n\n/**\n * The one line a panel shows instead of rows — nothing typed yet, a search in\n * flight, or a query that matched nothing. It is a sentence rather than an\n * empty box because those three states look identical when they are blank, and\n * which one it is decides what the person does next.\n */\nfunction SearchBarStatus({\n className,\n loading = false,\n children,\n ...props\n}: SearchBarStatusProps) {\n const { status } = searchBarVariants();\n return (\n \n {loading ? : null}\n \n {children}\n \n \n );\n}\n\nSearchBarStatus.displayName = 'SearchBar.Status';\n\nexport interface SearchBarActionProps extends AnimatedPressableProps {\n className?: string;\n children?: ReactNode;\n}\n\n/**\n * A button inside a row — an add, a pin, a remove — for the `trailing` slot.\n *\n * It exists rather than being left to a plain `Pressable` because a control\n * nested inside a row takes the touch itself, so the row above it never sees\n * the press and cannot hold the field's focus on its behalf. Pressed, this one\n * ends up blurring the field, and a blurred field closes the panel the button\n * was standing in — the press lands and the search disappears under it.\n */\nfunction SearchBarAction({ className, children, onPressIn, ...props }: SearchBarActionProps) {\n const search = useContext(SearchBarContext);\n return (\n {\n search?.retainFocus();\n onPressIn?.(event);\n }}\n {...props}\n className={className}\n >\n {children}\n \n );\n}\n\nSearchBarAction.displayName = 'SearchBar.Action';\n\nexport interface SearchBarTokenProps extends ViewProps {\n className?: string;\n /** Anything before the label — an avatar, a logo, a status dot. */\n leading?: ReactNode;\n /** Fires when the chip's ✕ is pressed. Without it no ✕ is drawn. */\n onRemove?: () => void;\n /** How the ✕ announces itself. Defaults to `Remove