@delacour/react-native-ui 0.1.0-alpha.20261007125731 → 0.1.0-alpha.20261007130133

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@delacour/react-native-ui",
3
- "version": "0.1.0-alpha.20261007125731",
3
+ "version": "0.1.0-alpha.20261007130133",
4
4
  "description": "React Native UI components — Uniwind, Reanimated, Gesture Handler",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -48,6 +48,7 @@
48
48
  "./dialog": "./src/components/dialog/index.ts",
49
49
  "./drawer": "./src/components/drawer/index.ts",
50
50
  "./empty-state": "./src/components/empty-state/index.ts",
51
+ "./feedback": "./src/components/feedback/index.ts",
51
52
  "./field": "./src/components/field/index.ts",
52
53
  "./icon": "./src/components/icon/index.ts",
53
54
  "./input": "./src/components/input/index.ts",
@@ -108,8 +109,8 @@
108
109
  },
109
110
  "peerDependencies": {
110
111
  "@central-icons-react-native/round-outlined-radius-1-stroke-1.5": "^1.1",
111
- "@delacour/react-native-bottom-sheet": "0.1.0-alpha.20261007125731",
112
- "@delacour/react-native-charts": "0.1.0-alpha.20261007125731",
112
+ "@delacour/react-native-bottom-sheet": "0.1.0-alpha.20261007130133",
113
+ "@delacour/react-native-charts": "0.1.0-alpha.20261007130133",
113
114
  "@legendapp/list": ">=3.3",
114
115
  "expo-linear-gradient": ">=15",
115
116
  "expo-router": ">=57",
@@ -0,0 +1,95 @@
1
+ # Feedback
2
+
3
+ A dialog for writing: "What should we fix first?", a bug report, a one-question survey. It is a
4
+ [`Dialog`](../dialog/AGENTS.md) whose text field sits in a **recessed well** that says "type here",
5
+ with the actions on the band around it. Compound root plus `Feedback.Trigger`, `Feedback.Content`,
6
+ `Feedback.Panel`, `Feedback.Title`, `Feedback.Close`, `Feedback.Field`, `Feedback.Footer`,
7
+ `Feedback.Action`, `Feedback.Cancel` and `Feedback.Submit`, and `useFeedback()`.
8
+
9
+ `import { Feedback } from "@delacour/react-native-ui/feedback";`
10
+
11
+ Not for confirmations or warnings — those are a `Dialog`. Like every overlay it draws through
12
+ `Overlay.Portal`, so the app mounts `OverlayProvider` once at its root.
13
+
14
+ ## Files
15
+
16
+ | File | What it holds |
17
+ | --- | --- |
18
+ | `index.ts` | → `@delacour/react-native-ui/feedback` |
19
+ | `feedback.tsx` | Root — the open state, the draft, the send state — wrapping a `Dialog`, and the `Object.assign` surface |
20
+ | `feedback.context.tsx` | **Leaf.** `FeedbackProvider`, `useFeedback()`, `useFeedbackContext()`, `useFeedbackPart()` |
21
+ | `feedback-trigger.tsx` | `Feedback.Trigger` — `Dialog.Trigger` |
22
+ | `feedback-content.tsx` | `Feedback.Content` — `Dialog.Content` on the muted band, tighter padding |
23
+ | `feedback-panel.tsx` | `Feedback.Panel` — the well, in a clip that eases to its measured height |
24
+ | `feedback-title.tsx` | `Feedback.Title` — `Dialog.Title`; publishes a string title as the field's label |
25
+ | `feedback-close.tsx` | `Feedback.Close` — `Dialog.Close`, in the well's corner |
26
+ | `feedback-field.tsx` | `Feedback.Field` — a bare multiline `TextInput` bound to the draft |
27
+ | `feedback-footer.tsx` | `Feedback.Footer` |
28
+ | `feedback-action.tsx` | `Feedback.Action` — a `secondary` `Button` for custom actions |
29
+ | `feedback-cancel.tsx` | `Feedback.Cancel` — closes, keeps the draft |
30
+ | `feedback-submit.tsx` | `Feedback.Submit` — the gate, the trimmed value, the wait on a promise |
31
+ | `feedback.variants.ts` | The slotted `tv()`, the row and motion constants, `canSubmitFeedback` and the field-height resolvers — no RN imports |
32
+ | `feedback.variants.test.ts` | Slots, tokens in both themes, the submit gate, the field heights |
33
+
34
+ ## Design
35
+
36
+ - **Everything a dialog does is Dialog's.** `Feedback` renders a `Dialog` and its parts *are*
37
+ Dialog's parts with classes merged over them — open state, portal, scrim, motion, the `modal`
38
+ band, the back button, the escape gesture, focus on open and return, and the keyboard lift that
39
+ keeps the footer above the keyboard. Nothing is restated, so a fix to Dialog is a fix here. The
40
+ import is `../dialog`: Dialog never imports Feedback, so there is no cycle to route around.
41
+ - **The shell is the band; the panel is the well.** `Feedback.Content` merges `bg-muted` and `p-2`
42
+ over Dialog's `bg-popover` and `p-5` through the same `tv` merge, so the corner, hairline and
43
+ width cap stay Dialog's. `Feedback.Panel` is `bg-background` with `rounded-md` — one step down
44
+ the corner ramp from the card it sits in, so the two read as nested — and a hairline. The well's
45
+ page colour against the band's muted one is what says "type here" without a field border.
46
+ - **The field has no box.** `Feedback.Field` is a raw `TextInput`, not an `Input`: the well is
47
+ its box, and an `Input` would draw a second border inside the first. It restates `font-sans` for
48
+ `Input`'s reason (a `TextInput` inherits nothing), sets `text-input-md` with paragraph leading
49
+ (`leading-6`, `FEEDBACK_FIELD_LINE_HEIGHT`, pinned by a test), and takes `Input`'s `accent-*`
50
+ placeholder, caret and selection classes from `input.variants` — a leaf, so no cycle.
51
+ - **The field is floored at `minRows` and capped at `maxRows`.** Six rows and twelve by default,
52
+ as a style (`resolveFeedbackFieldHeightStyle`) because a runtime number cannot be a class.
53
+ Between the two React Native's multiline `TextInput` sizes itself. `maxRows` is not in the
54
+ original spec: without a cap a long message grows the card until Dialog's lift, which keeps the
55
+ title on screen first, has to give up the footer to the keyboard.
56
+ - **The draft lives on the root, above `Dialog.Content`.** Content unmounts when the exit animation
57
+ ends; the root does not, so an uncontrolled draft survives a close and a reopen. Cancel, Close,
58
+ the scrim and back all keep it — a long message put down for a moment must not be thrown away.
59
+ `useFeedback().clear()` is the one way to empty it.
60
+ - **Submit never closes.** `canSubmitFeedback` gates it (empty and whitespace are empty;
61
+ `canSubmitEmpty` lets an empty one through for chips or a rating; disabled outranks both), and
62
+ `onSubmit` gets the trimmed text. A returned promise turns on the button's loading state and
63
+ makes the field read-only until it settles — and the dialog is still open, so a failure can be
64
+ shown in the well with the draft intact. The caller closes and clears on success. A rejection is
65
+ not swallowed: handle failures inside `onSubmit`, or the app's unhandled-rejection reporting sees
66
+ it, which is better than a send that failed silently.
67
+ - **The well eases between heights; the shell never jumps.** A multi-step flow (rating → text →
68
+ thanks) is the caller swapping the panel's and footer's children. `Feedback.Panel` measures the
69
+ well with `onLayout` and animates a clip around it to that height over `FEEDBACK_PANEL_RESIZE_MS`
70
+ (200ms). The spec named a Reanimated `LinearTransition` on the panel; that animates the panel's
71
+ frame inside a shell that has already snapped to its final size and re-centred, which is exactly
72
+ the jump to avoid. A real height changing on every frame moves the shell through ordinary layout
73
+ and keeps it centred — Accordion's reasoning for its own panel. The first layout lands without
74
+ animating, and under reduce motion every change does.
75
+ - **The field's label is the title.** `Feedback.Title` publishes its children to the root when
76
+ they are a plain string, and `Feedback.Field` uses that as its default `accessibilityLabel`, so
77
+ a screen reader announces the question rather than only the placeholder. A non-string title
78
+ publishes nothing; pass `accessibilityLabel` then.
79
+ - **Close sits in the well, not the shell.** `Feedback.Close` is `Dialog.Close` with its slot
80
+ re-pinned to the well's corner (`top-3 right-3`, against the panel's `p-4`); the title keeps
81
+ Dialog's `pr-8` clearance.
82
+ - **No blur backdrop.** As for every overlay: frosted scrims need `expo-blur`, not a peer.
83
+
84
+ ## Testing
85
+
86
+ `bun test` reaches `feedbackVariants`, the tokens in both themes, `canSubmitFeedback` and the
87
+ field-height resolvers. Opening, the submit gate, the async send, the draft across close and
88
+ reopen, the keyboard, the multi-step resize and every dismissal path are verified on a simulator
89
+ through `/feedback` in the playground.
90
+
91
+ Preview media is held back with the rest of the overlays (see `docs/plans/overlays/README.md`).
92
+ When a capture tool is back, re-add `capture` with these settings and run
93
+ `bun run previews -- -- --only feedback`: `basic` `{ flow: "feedback/basic", frame: "device", hero: true }`,
94
+ and `sending`, `multi-step` `{ flow: "feedback/<id>", frame: "device" }`. The flows are in
95
+ `.argent/flows/previews/feedback/`.
@@ -0,0 +1,16 @@
1
+ import type { ReactElement } from "react";
2
+ import { Button, type ButtonProps } from "../button";
3
+
4
+ export type FeedbackActionProps = ButtonProps;
5
+
6
+ /**
7
+ * A custom footer action — "Skip", "Back", "Attach screenshot" — as a
8
+ * `secondary` `Button`. Pass `variant` to change it.
9
+ *
10
+ * @example
11
+ * <Feedback.Action onPress={back}>Back</Feedback.Action>
12
+ */
13
+ export function FeedbackAction({ variant = "secondary", ...props }: FeedbackActionProps): ReactElement {
14
+ return <Button variant={variant} {...props} />;
15
+ }
16
+ FeedbackAction.displayName = "DelacourUI.Feedback.Action";
@@ -0,0 +1,34 @@
1
+ import { type ReactElement, useCallback } from "react";
2
+ import { Button, type ButtonProps } from "../button";
3
+ import { useFeedbackPart } from "./feedback.context";
4
+
5
+ export type FeedbackCancelProps = ButtonProps;
6
+
7
+ /**
8
+ * Closes the dialog and keeps the draft — a `secondary` `Button` labelled
9
+ * "Cancel" unless given children. Clearing on cancel would throw away a long
10
+ * message the user only meant to put down for a moment.
11
+ *
12
+ * @example
13
+ * <Feedback.Cancel />
14
+ */
15
+ export function FeedbackCancel({
16
+ variant = "secondary",
17
+ children = "Cancel",
18
+ onPress,
19
+ ...props
20
+ }: FeedbackCancelProps): ReactElement {
21
+ const { close } = useFeedbackPart("Feedback.Cancel");
22
+
23
+ const handlePress = useCallback(() => {
24
+ close();
25
+ onPress?.();
26
+ }, [close, onPress]);
27
+
28
+ return (
29
+ <Button onPress={handlePress} variant={variant} {...props}>
30
+ {children}
31
+ </Button>
32
+ );
33
+ }
34
+ FeedbackCancel.displayName = "DelacourUI.Feedback.Cancel";
@@ -0,0 +1,21 @@
1
+ import type { ReactElement } from "react";
2
+ import { Dialog, type DialogCloseProps } from "../dialog";
3
+ import { feedbackVariants } from "./feedback.variants";
4
+
5
+ export type FeedbackCloseProps = DialogCloseProps;
6
+
7
+ /**
8
+ * The ✕ in the well's top-right corner — `Dialog.Close`, positioned against
9
+ * the panel rather than the shell. `Feedback.Title`'s `pr-8` reserves its
10
+ * clearance. Labelled `"Close"`; the draft is kept.
11
+ *
12
+ * With `asChild` it donates the close to its child, as `Dialog.Close` does.
13
+ *
14
+ * @example
15
+ * <Feedback.Close />
16
+ */
17
+ export function FeedbackClose(props: FeedbackCloseProps): ReactElement {
18
+ if (props.asChild) return <Dialog.Close {...props} />;
19
+ return <Dialog.Close {...props} className={feedbackVariants().close({ className: props.className })} />;
20
+ }
21
+ FeedbackClose.displayName = "DelacourUI.Feedback.Close";
@@ -0,0 +1,24 @@
1
+ import type { ReactElement } from "react";
2
+ import { Dialog, type DialogContentProps } from "../dialog";
3
+ import { feedbackVariants } from "./feedback.variants";
4
+
5
+ export type FeedbackContentProps = DialogContentProps;
6
+
7
+ /**
8
+ * The shell — a `Dialog.Content` on the muted band.
9
+ *
10
+ * Portal, scrim, motion, keyboard lift, back button, escape and focus are all
11
+ * Dialog's. This only swaps the card's fill for `bg-muted` and tightens its
12
+ * padding, so the `Feedback.Panel` inside reads as a well sunk into it and the
13
+ * footer's actions sit on the band around it.
14
+ *
15
+ * @example
16
+ * <Feedback.Content>
17
+ * <Feedback.Panel>…</Feedback.Panel>
18
+ * <Feedback.Footer>…</Feedback.Footer>
19
+ * </Feedback.Content>
20
+ */
21
+ export function FeedbackContent({ className, ...props }: FeedbackContentProps): ReactElement {
22
+ return <Dialog.Content className={feedbackVariants().content({ className })} {...props} />;
23
+ }
24
+ FeedbackContent.displayName = "DelacourUI.Feedback.Content";
@@ -0,0 +1,70 @@
1
+ import { type ReactElement, useCallback } from "react";
2
+ import { TextInput, type TextInputProps } from "react-native";
3
+ import { resolvePlaceholderAccentClass, resolveSelectionAccentClass } from "../input/input.variants";
4
+ import { useFeedbackPart } from "./feedback.context";
5
+ import { feedbackVariants, resolveFeedbackFieldHeightStyle } from "./feedback.variants";
6
+
7
+ export type FeedbackFieldProps = Omit<TextInputProps, "value" | "onChangeText" | "multiline"> & {
8
+ className?: string;
9
+ /** Rows before it grows. Default 6. */
10
+ minRows?: number;
11
+ /** Rows it grows to before it scrolls. Default 12. */
12
+ maxRows?: number;
13
+ /** Read-only — while sending, say. A send in flight makes it read-only on its own. */
14
+ isDisabled?: boolean;
15
+ };
16
+
17
+ /**
18
+ * Where the message is written: a bare multiline `TextInput` with no box of
19
+ * its own, because the well around it is the box.
20
+ *
21
+ * The draft is the root's — `value` and `onChangeText` are withheld — so it
22
+ * survives the dialog closing and reopening, and `useFeedback().clear()` is
23
+ * the one way to empty it. It is floored at `minRows` and grows to `maxRows`
24
+ * before it scrolls (`resolveFeedbackFieldHeightStyle`), as a style, since a
25
+ * runtime number cannot be a Tailwind class.
26
+ *
27
+ * While a promise from `Feedback.Submit` is in flight it is read-only, so what
28
+ * is being sent is what stays on screen. Its label defaults to the title's
29
+ * text when the title is a plain string.
30
+ *
31
+ * Placeholder, caret and selection take `Input`'s `accent-*` defaults.
32
+ *
33
+ * @example
34
+ * <Feedback.Field placeholder="Tell us what got in your way" />
35
+ */
36
+ export function FeedbackField({
37
+ className,
38
+ minRows,
39
+ maxRows,
40
+ isDisabled = false,
41
+ accessibilityLabel,
42
+ editable,
43
+ style,
44
+ ...props
45
+ }: FeedbackFieldProps): ReactElement {
46
+ const { value, setValue, isSending, titleText } = useFeedbackPart("Feedback.Field");
47
+ const isReadOnly = isDisabled || isSending;
48
+ const selectionAccent = resolveSelectionAccentClass({});
49
+
50
+ const handleChangeText = useCallback((text: string) => setValue(text), [setValue]);
51
+
52
+ return (
53
+ <TextInput
54
+ accessibilityLabel={accessibilityLabel ?? titleText}
55
+ accessibilityState={{ disabled: isReadOnly }}
56
+ className={feedbackVariants().field({ className })}
57
+ cursorColorClassName={selectionAccent}
58
+ placeholderTextColorClassName={resolvePlaceholderAccentClass()}
59
+ selectionColorClassName={selectionAccent}
60
+ style={[resolveFeedbackFieldHeightStyle({ maxRows, minRows }), style]}
61
+ textAlignVertical="top"
62
+ {...props}
63
+ editable={isReadOnly ? false : editable}
64
+ multiline
65
+ onChangeText={handleChangeText}
66
+ value={value}
67
+ />
68
+ );
69
+ }
70
+ FeedbackField.displayName = "DelacourUI.Feedback.Field";
@@ -0,0 +1,21 @@
1
+ import type { ReactElement } from "react";
2
+ import { View, type ViewProps } from "react-native";
3
+ import { feedbackVariants } from "./feedback.variants";
4
+
5
+ export type FeedbackFooterProps = ViewProps & { className?: string };
6
+
7
+ /**
8
+ * The actions, on the band under the well — an end-aligned row, inset less
9
+ * than the well so the buttons line up with the shell's edge rather than the
10
+ * well's text.
11
+ *
12
+ * @example
13
+ * <Feedback.Footer>
14
+ * <Feedback.Cancel />
15
+ * <Feedback.Submit onSubmit={send} />
16
+ * </Feedback.Footer>
17
+ */
18
+ export function FeedbackFooter({ className, ...props }: FeedbackFooterProps): ReactElement {
19
+ return <View className={feedbackVariants().footer({ className })} {...props} />;
20
+ }
21
+ FeedbackFooter.displayName = "DelacourUI.Feedback.Footer";
@@ -0,0 +1,54 @@
1
+ import { type ReactElement, useCallback } from "react";
2
+ import { type LayoutChangeEvent, View, type ViewProps } from "react-native";
3
+ import Animated, { useAnimatedStyle, useReducedMotion, useSharedValue, withTiming } from "react-native-reanimated";
4
+ import { FEEDBACK_PANEL_RESIZE_MS, feedbackVariants } from "./feedback.variants";
5
+
6
+ export type FeedbackPanelProps = ViewProps & { className?: string };
7
+
8
+ /** No height measured yet: the clip wraps its content until the first layout. */
9
+ const UNMEASURED = -1;
10
+
11
+ /**
12
+ * The recessed well the title, the close and the field sit in.
13
+ *
14
+ * **Its height is measured and animated, never laid out.** The well is drawn
15
+ * inside a clip whose height follows the well's own `onLayout` on a short
16
+ * timing, so when a multi-step flow swaps the panel's children — a rating, then
17
+ * a message, then thanks — the clip eases to the new height. The shell grows
18
+ * with it through ordinary layout and stays centred on every frame, so it never
19
+ * jumps; a layout transition on the panel alone would animate the panel inside
20
+ * a shell that had already snapped to its final size. The first layout lands
21
+ * without animating, and under reduce motion every change does.
22
+ *
23
+ * @example
24
+ * <Feedback.Panel>
25
+ * <Feedback.Title>What should we fix first?</Feedback.Title>
26
+ * <Feedback.Close />
27
+ * <Feedback.Field placeholder="Tell us what got in your way" />
28
+ * </Feedback.Panel>
29
+ */
30
+ export function FeedbackPanel({ className, onLayout, ...props }: FeedbackPanelProps): ReactElement {
31
+ const isReduced = useReducedMotion();
32
+ const height = useSharedValue(UNMEASURED);
33
+
34
+ const handleLayout = useCallback(
35
+ (event: LayoutChangeEvent) => {
36
+ const next = event.nativeEvent.layout.height;
37
+ height.value =
38
+ height.value === UNMEASURED || isReduced ? next : withTiming(next, { duration: FEEDBACK_PANEL_RESIZE_MS });
39
+ onLayout?.(event);
40
+ },
41
+ [height, isReduced, onLayout]
42
+ );
43
+
44
+ const clipStyle = useAnimatedStyle(() => (height.value === UNMEASURED ? {} : { height: height.value }));
45
+
46
+ const slots = feedbackVariants();
47
+
48
+ return (
49
+ <Animated.View className={slots.clip()} style={clipStyle}>
50
+ <View className={slots.panel({ className })} onLayout={handleLayout} {...props} />
51
+ </Animated.View>
52
+ );
53
+ }
54
+ FeedbackPanel.displayName = "DelacourUI.Feedback.Panel";
@@ -0,0 +1,74 @@
1
+ import { type ReactElement, useCallback } from "react";
2
+ import { Button, type ButtonProps } from "../button";
3
+ import { useFeedbackPart } from "./feedback.context";
4
+ import { canSubmitFeedback } from "./feedback.variants";
5
+
6
+ export type FeedbackSubmitProps = Omit<ButtonProps, "onPress"> & {
7
+ /** Receives the trimmed message. Does NOT close — sending has to finish first. */
8
+ onSubmit: (value: string) => void | Promise<void>;
9
+ /** Allow submitting an empty message (e.g. when chips were picked). Default false. */
10
+ canSubmitEmpty?: boolean;
11
+ };
12
+
13
+ /** Whether a value is a promise, without trusting `instanceof` across realms. */
14
+ function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
15
+ return typeof value === "object" && value !== null && typeof (value as PromiseLike<unknown>).then === "function";
16
+ }
17
+
18
+ /**
19
+ * Sends the message — a primary `Button` labelled "Send" unless given children.
20
+ *
21
+ * Disabled while `canSubmitFeedback` says no: an empty or whitespace draft,
22
+ * unless `canSubmitEmpty`. `onSubmit` receives the trimmed text.
23
+ *
24
+ * **It never closes the dialog.** When `onSubmit` returns a promise the button
25
+ * shows its loading state and the field turns read-only until the promise
26
+ * settles, and then the dialog is still open — so a failure can be shown in
27
+ * the well with the draft intact. Close and clear from `onSubmit` on success,
28
+ * through `useFeedback()` or controlled state. Handle a failure inside
29
+ * `onSubmit`: a rejection is not caught here, so it still reaches the app's
30
+ * unhandled-rejection reporting rather than vanishing.
31
+ *
32
+ * @example
33
+ * <Feedback.Submit
34
+ * onSubmit={async (message) => {
35
+ * await send(message);
36
+ * clear();
37
+ * close();
38
+ * }}
39
+ * />
40
+ */
41
+ export function FeedbackSubmit({
42
+ onSubmit,
43
+ canSubmitEmpty = false,
44
+ isDisabled = false,
45
+ isLoading = false,
46
+ children = "Send",
47
+ ...props
48
+ }: FeedbackSubmitProps): ReactElement {
49
+ const { value, isSending, setSending } = useFeedbackPart("Feedback.Submit");
50
+ const canSubmit = canSubmitFeedback({ canSubmitEmpty, isDisabled, value });
51
+
52
+ const handlePress = useCallback(() => {
53
+ if (!canSubmit || isSending) return;
54
+ const result = onSubmit(value.trim());
55
+ if (!isPromiseLike(result)) return;
56
+
57
+ setSending(true);
58
+ const settle = () => setSending(false);
59
+ void Promise.resolve(result).finally(settle);
60
+ }, [canSubmit, isSending, onSubmit, setSending, value]);
61
+
62
+ return (
63
+ <Button
64
+ isDisabled={!canSubmit}
65
+ isLoading={isLoading || isSending}
66
+ onPress={handlePress}
67
+ variant="primary"
68
+ {...props}
69
+ >
70
+ {children}
71
+ </Button>
72
+ );
73
+ }
74
+ FeedbackSubmit.displayName = "DelacourUI.Feedback.Submit";
@@ -0,0 +1,34 @@
1
+ import { type ReactElement, useEffect } from "react";
2
+ import { Dialog, type DialogTitleProps } from "../dialog";
3
+ import { useFeedbackPart } from "./feedback.context";
4
+ import { feedbackVariants } from "./feedback.variants";
5
+
6
+ export type FeedbackTitleProps = DialogTitleProps;
7
+
8
+ /**
9
+ * The question the dialog asks — a `Dialog.Title`, inside the well.
10
+ *
11
+ * It is what the dialog is labelled by and where accessibility focus lands on
12
+ * open, exactly as in a `Dialog`. When its children are a plain string, that
13
+ * string also becomes `Feedback.Field`'s default `accessibilityLabel`, so the
14
+ * field announces the question rather than only its placeholder.
15
+ *
16
+ * @example
17
+ * <Feedback.Title>What should we fix first?</Feedback.Title>
18
+ */
19
+ export function FeedbackTitle({ className, children, ...props }: FeedbackTitleProps): ReactElement {
20
+ const { setTitleText } = useFeedbackPart("Feedback.Title");
21
+ const text = typeof children === "string" ? children : undefined;
22
+
23
+ useEffect(() => {
24
+ setTitleText(text);
25
+ return () => setTitleText(undefined);
26
+ }, [setTitleText, text]);
27
+
28
+ return (
29
+ <Dialog.Title className={feedbackVariants().title({ className })} {...props}>
30
+ {children}
31
+ </Dialog.Title>
32
+ );
33
+ }
34
+ FeedbackTitle.displayName = "DelacourUI.Feedback.Title";
@@ -0,0 +1,20 @@
1
+ import type { ReactElement } from "react";
2
+ import { Dialog, type DialogTriggerProps } from "../dialog";
3
+
4
+ export type FeedbackTriggerProps = DialogTriggerProps;
5
+
6
+ /**
7
+ * The control that opens the feedback dialog — `Dialog.Trigger`, unchanged.
8
+ *
9
+ * `asChild` donates the open to a `Button` rather than wrapping it, for the
10
+ * reason `Dialog.Trigger` gives: two tap gestures nested are not simultaneous.
11
+ *
12
+ * @example
13
+ * <Feedback.Trigger asChild>
14
+ * <Button variant="secondary">Give feedback</Button>
15
+ * </Feedback.Trigger>
16
+ */
17
+ export function FeedbackTrigger(props: FeedbackTriggerProps): ReactElement {
18
+ return <Dialog.Trigger {...props} />;
19
+ }
20
+ FeedbackTrigger.displayName = "DelacourUI.Feedback.Trigger";
@@ -0,0 +1,73 @@
1
+ import { createContext, type ReactElement, type ReactNode, use } from "react";
2
+
3
+ export type FeedbackContextValue = {
4
+ /** The draft as typed — untrimmed. */
5
+ value: string;
6
+ /** Replaces the draft. Reports through `onValueChange`. */
7
+ setValue: (value: string) => void;
8
+ /** Whether the dialog is open. */
9
+ isOpen: boolean;
10
+ /** Closes the dialog. The draft is kept. */
11
+ close: () => void;
12
+ /** Empties the draft — after a successful send, say. */
13
+ clear: () => void;
14
+ };
15
+
16
+ /** What the parts share that a caller has no use for. */
17
+ export type FeedbackInternalValue = FeedbackContextValue & {
18
+ /** Whether a promise `onSubmit` returned is still in flight. */
19
+ isSending: boolean;
20
+ setSending: (isSending: boolean) => void;
21
+ /** The title's text when it is a plain string — the field's default label. */
22
+ titleText: string | undefined;
23
+ setTitleText: (text: string | undefined) => void;
24
+ };
25
+
26
+ const FeedbackContext = createContext<FeedbackInternalValue | null>(null);
27
+
28
+ /**
29
+ * Supplies the feedback dialog's draft and send state to its parts.
30
+ *
31
+ * Its own module, importing nothing but React, so a part can read it without
32
+ * importing `./feedback` — that import would close a cycle, and Metro serves a
33
+ * partially initialised module for one.
34
+ */
35
+ export function FeedbackProvider({
36
+ value,
37
+ children,
38
+ }: {
39
+ value: FeedbackInternalValue;
40
+ children: ReactNode;
41
+ }): ReactElement {
42
+ return <FeedbackContext value={value}>{children}</FeedbackContext>;
43
+ }
44
+ FeedbackProvider.displayName = "DelacourUI.Feedback.Provider";
45
+
46
+ /** The enclosing feedback dialog's context, or null outside a `<Feedback>`. */
47
+ export function useFeedbackContext(): FeedbackContextValue | null {
48
+ return use(FeedbackContext);
49
+ }
50
+
51
+ /**
52
+ * Reads the enclosing feedback dialog's draft, its setters and its open state.
53
+ *
54
+ * Lets a custom child — a row of chips, a rating step — read or replace the
55
+ * draft, and lets an `onSubmit` close and clear once the send has gone through.
56
+ * Throws outside a `<Feedback>`.
57
+ */
58
+ export function useFeedback(): FeedbackContextValue {
59
+ const context = use(FeedbackContext);
60
+ if (!context) {
61
+ throw new Error("useFeedback must be called inside a <Feedback>.");
62
+ }
63
+ return context;
64
+ }
65
+
66
+ /** The full context, for a part that cannot work without one. Internal. */
67
+ export function useFeedbackPart(component: string): FeedbackInternalValue {
68
+ const context = use(FeedbackContext);
69
+ if (!context) {
70
+ throw new Error(`${component} must be rendered inside a <Feedback>.`);
71
+ }
72
+ return context;
73
+ }
@@ -0,0 +1,135 @@
1
+ import { type ReactElement, type ReactNode, useCallback, useMemo, useState } from "react";
2
+ import { useControllableState } from "../../hooks/use-controllable-state";
3
+ import { Dialog } from "../dialog";
4
+ import { type FeedbackInternalValue, FeedbackProvider } from "./feedback.context";
5
+ import { FeedbackAction } from "./feedback-action";
6
+ import { FeedbackCancel } from "./feedback-cancel";
7
+ import { FeedbackClose } from "./feedback-close";
8
+ import { FeedbackContent } from "./feedback-content";
9
+ import { FeedbackField } from "./feedback-field";
10
+ import { FeedbackFooter } from "./feedback-footer";
11
+ import { FeedbackPanel } from "./feedback-panel";
12
+ import { FeedbackSubmit } from "./feedback-submit";
13
+ import { FeedbackTitle } from "./feedback-title";
14
+ import { FeedbackTrigger } from "./feedback-trigger";
15
+
16
+ export type FeedbackProps = {
17
+ /** Whether the dialog is open. Pass it to control the dialog; omit it and the dialog holds its own state. */
18
+ isOpen?: boolean;
19
+ /** Whether an uncontrolled dialog starts open. Default false. */
20
+ defaultOpen?: boolean;
21
+ /** Called with the new value whenever the dialog opens or closes, from any path. */
22
+ onOpenChange?: (isOpen: boolean) => void;
23
+ /** The draft. Pass it to control the text; omit it and the dialog keeps its own draft. */
24
+ value?: string;
25
+ /** The draft an uncontrolled dialog starts with. Default "". */
26
+ defaultValue?: string;
27
+ /** Called with the draft on every keystroke, and on `clear()`. */
28
+ onValueChange?: (value: string) => void;
29
+ /** Whether a scrim tap, Android back and the iOS escape gesture close it. Default true. */
30
+ isDismissible?: boolean;
31
+ children: ReactNode;
32
+ };
33
+
34
+ function FeedbackRoot({
35
+ isOpen,
36
+ defaultOpen = false,
37
+ onOpenChange,
38
+ value,
39
+ defaultValue = "",
40
+ onValueChange,
41
+ isDismissible = true,
42
+ children,
43
+ }: FeedbackProps): ReactElement {
44
+ const [open, setOpen] = useControllableState({ defaultValue: defaultOpen, onChange: onOpenChange, value: isOpen });
45
+ const [draft, setDraft] = useControllableState({ defaultValue, onChange: onValueChange, value });
46
+ const [isSending, setSending] = useState(false);
47
+ const [titleText, setTitleText] = useState<string | undefined>(undefined);
48
+
49
+ const close = useCallback(() => {
50
+ if (open) setOpen(false);
51
+ }, [open, setOpen]);
52
+ const clear = useCallback(() => setDraft(""), [setDraft]);
53
+
54
+ const context = useMemo<FeedbackInternalValue>(
55
+ () => ({
56
+ clear,
57
+ close,
58
+ isOpen: open,
59
+ isSending,
60
+ setSending,
61
+ setTitleText,
62
+ setValue: setDraft,
63
+ titleText,
64
+ value: draft,
65
+ }),
66
+ [clear, close, draft, isSending, open, setDraft, titleText]
67
+ );
68
+
69
+ // The draft lives here, above `Dialog.Content`, which unmounts once the exit
70
+ // animation ends — so an uncontrolled draft survives a close and a reopen.
71
+ return (
72
+ <Dialog isDismissible={isDismissible} isOpen={open} onOpenChange={setOpen}>
73
+ <FeedbackProvider value={context}>{children}</FeedbackProvider>
74
+ </Dialog>
75
+ );
76
+ }
77
+
78
+ /**
79
+ * A dialog for writing — "What should we fix first?", a bug report, a
80
+ * one-question survey.
81
+ *
82
+ * It is a `Dialog` whose text field sits in a recessed well, with the actions
83
+ * on the band around it. Open state, portal, scrim, back, escape, focus and the
84
+ * keyboard lift are all Dialog's; this adds the draft, a submit that waits for
85
+ * the send, and a well that eases between heights when a multi-step flow swaps
86
+ * its content. Not for confirmations or warnings — use a `Dialog`.
87
+ *
88
+ * The draft is kept across close and reopen while uncontrolled;
89
+ * `useFeedback().clear()` empties it. `Feedback.Submit` never closes the
90
+ * dialog: close from `onSubmit` once the send has gone through.
91
+ *
92
+ * Draws through `Overlay.Portal`, so the app needs `OverlayProvider` mounted
93
+ * once at its root.
94
+ *
95
+ * @example
96
+ * <Feedback>
97
+ * <Feedback.Trigger asChild>
98
+ * <Button variant="secondary">Give feedback</Button>
99
+ * </Feedback.Trigger>
100
+ * <Feedback.Content>
101
+ * <Feedback.Panel>
102
+ * <Feedback.Title>What should we fix first?</Feedback.Title>
103
+ * <Feedback.Close />
104
+ * <Feedback.Field placeholder="Tell us what got in your way" />
105
+ * </Feedback.Panel>
106
+ * <Feedback.Footer>
107
+ * <Feedback.Cancel />
108
+ * <Feedback.Submit onSubmit={send} />
109
+ * </Feedback.Footer>
110
+ * </Feedback.Content>
111
+ * </Feedback>
112
+ */
113
+ export const Feedback = Object.assign(FeedbackRoot, {
114
+ /** The control that opens it. `asChild` donates the press to a `Button`. */
115
+ Trigger: FeedbackTrigger,
116
+ /** The shell — a `Dialog.Content` on the muted band. Renders nothing while closed. */
117
+ Content: FeedbackContent,
118
+ /** The recessed well holding the title, the close and the field. Eases between heights. */
119
+ Panel: FeedbackPanel,
120
+ /** The question — a `Dialog.Title`, and the field's default label. */
121
+ Title: FeedbackTitle,
122
+ /** The ✕ in the well's corner. Keeps the draft. */
123
+ Close: FeedbackClose,
124
+ /** The multiline text field, bound to the draft. */
125
+ Field: FeedbackField,
126
+ /** The actions, end-aligned on the band under the well. */
127
+ Footer: FeedbackFooter,
128
+ /** A custom footer action — a `secondary` `Button`. */
129
+ Action: FeedbackAction,
130
+ /** Closes and keeps the draft. */
131
+ Cancel: FeedbackCancel,
132
+ /** Sends the trimmed draft; waits on a promise; never closes. */
133
+ Submit: FeedbackSubmit,
134
+ displayName: "DelacourUI.Feedback",
135
+ });
@@ -0,0 +1,185 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { declaredTokens } from "../../styles/theme-tokens.test";
3
+ import {
4
+ canSubmitFeedback,
5
+ FEEDBACK_DEFAULT_MAX_ROWS,
6
+ FEEDBACK_DEFAULT_MIN_ROWS,
7
+ FEEDBACK_FIELD_LINE_HEIGHT,
8
+ FEEDBACK_PANEL_RESIZE_MS,
9
+ feedbackVariants,
10
+ resolveFeedbackFieldHeightStyle,
11
+ resolveFeedbackFieldMinHeight,
12
+ } from "./feedback.variants";
13
+
14
+ const LIGHT = declaredTokens("light");
15
+ const DARK = declaredTokens("dark");
16
+
17
+ /** `border-t` and friends set a width, not a colour, and name no token. */
18
+ const STRUCTURAL_BORDER_SUFFIXES = new Set(["t", "b", "l", "r", "x", "y", "s", "e"]);
19
+
20
+ /** Every theme token a class string paints with, with any `/alpha` suffix dropped. */
21
+ function colorTokens(cls: string): string[] {
22
+ const tokens: string[] = [];
23
+
24
+ for (const [, utility, token] of cls.matchAll(/\b(bg|border|text)-([a-z][\w-]*)(?:\/\d+)?\b/g)) {
25
+ if (utility === "border" && STRUCTURAL_BORDER_SUFFIXES.has(token)) continue;
26
+ // `text-input-md` is a size token, not a colour.
27
+ if (utility === "text" && token.startsWith("input-")) continue;
28
+ tokens.push(token);
29
+ }
30
+
31
+ return tokens;
32
+ }
33
+
34
+ /** Pinned rather than derived — `tv` adds a `base` slot of its own. */
35
+ const SLOT_NAMES = ["content", "clip", "panel", "title", "close", "field", "footer"] as const;
36
+
37
+ describe("feedbackVariants", () => {
38
+ test("the shell paints the muted band and is tighter than a dialog's p-5", () => {
39
+ const content = feedbackVariants().content();
40
+ expect(content).toContain("bg-muted");
41
+ expect(content).toMatch(/\bp-[1-3]\b/);
42
+ expect(content).not.toContain("p-5");
43
+ });
44
+
45
+ test("the panel is a recessed well on the page colour, inset with the md corner and a hairline", () => {
46
+ const panel = feedbackVariants().panel();
47
+ expect(panel).toContain("bg-background");
48
+ expect(panel).toContain("rounded-md");
49
+ expect(panel).toContain("border");
50
+ expect(panel).toContain("border-border");
51
+ expect(panel).toMatch(/\bp-\d/);
52
+ });
53
+
54
+ test("the clip hides the well's overflow mid-resize and shares its corner", () => {
55
+ const clip = feedbackVariants().clip();
56
+ expect(clip).toContain("overflow-hidden");
57
+ expect(clip).toContain("rounded-md");
58
+ });
59
+
60
+ test("the title reserves clearance for the close glyph and carries no type of its own", () => {
61
+ const title = feedbackVariants().title();
62
+ expect(title).toBe("pr-8");
63
+ });
64
+
65
+ test("the close glyph sits in the panel's top-right corner", () => {
66
+ const close = feedbackVariants().close();
67
+ expect(close).toContain("absolute");
68
+ expect(close).toMatch(/\btop-\d/);
69
+ expect(close).toMatch(/\bright-\d/);
70
+ });
71
+
72
+ test("the field has no box of its own and sets the input type on the house face", () => {
73
+ const field = feedbackVariants().field();
74
+ expect(field).toContain("font-sans");
75
+ expect(field).toContain("text-input-md");
76
+ expect(field).toContain("text-foreground");
77
+ expect(field).toContain("leading-6");
78
+ expect(field).not.toMatch(/\bborder\b/);
79
+ expect(field).not.toMatch(/\bbg-/);
80
+ });
81
+
82
+ test("the footer is an end-aligned row with a narrower inset than the well's", () => {
83
+ const footer = feedbackVariants().footer();
84
+ expect(footer).toContain("flex-row");
85
+ expect(footer).toContain("items-center");
86
+ expect(footer).toContain("justify-end");
87
+ expect(footer).toContain("gap-2");
88
+ expect(footer).not.toMatch(/\bp-[4-9]\b/);
89
+ });
90
+
91
+ test("a caller's className reaches each slot and wins a conflict", () => {
92
+ const panel = feedbackVariants().panel({ className: "rounded-xl" });
93
+ expect(panel).toContain("rounded-xl");
94
+ expect(panel).not.toContain("rounded-md");
95
+ });
96
+ });
97
+
98
+ describe("every token the slots name", () => {
99
+ test("is declared in both variants of theme.css", () => {
100
+ const slots = feedbackVariants();
101
+ for (const name of SLOT_NAMES) {
102
+ for (const token of colorTokens(slots[name]())) {
103
+ expect({ inLight: LIGHT.has(token), slot: name, token }).toEqual({ inLight: true, slot: name, token });
104
+ expect({ inDark: DARK.has(token), slot: name, token }).toEqual({ inDark: true, slot: name, token });
105
+ }
106
+ }
107
+ });
108
+
109
+ test("the reader found tokens at all", () => {
110
+ expect(colorTokens(feedbackVariants().content())).toContain("muted");
111
+ expect(colorTokens(feedbackVariants().panel())).toEqual(expect.arrayContaining(["background", "border"]));
112
+ expect(colorTokens(feedbackVariants().field())).toEqual(["foreground"]);
113
+ });
114
+ });
115
+
116
+ describe("canSubmitFeedback", () => {
117
+ test("an empty message cannot be sent", () => {
118
+ expect(canSubmitFeedback({ value: "" })).toBe(false);
119
+ });
120
+
121
+ test("whitespace is empty", () => {
122
+ expect(canSubmitFeedback({ value: " \n\t " })).toBe(false);
123
+ });
124
+
125
+ test("any text can be sent", () => {
126
+ expect(canSubmitFeedback({ value: "The export button is hidden" })).toBe(true);
127
+ expect(canSubmitFeedback({ value: " x " })).toBe(true);
128
+ });
129
+
130
+ test("canSubmitEmpty lets an empty message through", () => {
131
+ expect(canSubmitFeedback({ canSubmitEmpty: true, value: "" })).toBe(true);
132
+ expect(canSubmitFeedback({ canSubmitEmpty: true, value: " " })).toBe(true);
133
+ });
134
+
135
+ test("disabled outranks everything", () => {
136
+ expect(canSubmitFeedback({ isDisabled: true, value: "text" })).toBe(false);
137
+ expect(canSubmitFeedback({ canSubmitEmpty: true, isDisabled: true, value: "" })).toBe(false);
138
+ });
139
+ });
140
+
141
+ describe("resolveFeedbackFieldMinHeight", () => {
142
+ test("the line height is the field's leading-6", () => {
143
+ expect(FEEDBACK_FIELD_LINE_HEIGHT).toBe(24);
144
+ });
145
+
146
+ test("defaults to six rows", () => {
147
+ expect(FEEDBACK_DEFAULT_MIN_ROWS).toBe(6);
148
+ expect(resolveFeedbackFieldMinHeight(undefined, FEEDBACK_FIELD_LINE_HEIGHT)).toBe(144);
149
+ });
150
+
151
+ test("is rows times the line height", () => {
152
+ expect(resolveFeedbackFieldMinHeight(3, 24)).toBe(72);
153
+ expect(resolveFeedbackFieldMinHeight(1, 20)).toBe(20);
154
+ });
155
+
156
+ test("floors to whole rows and clamps to at least one", () => {
157
+ expect(resolveFeedbackFieldMinHeight(2.7, 24)).toBe(48);
158
+ expect(resolveFeedbackFieldMinHeight(0, 24)).toBe(24);
159
+ expect(resolveFeedbackFieldMinHeight(-3, 24)).toBe(24);
160
+ });
161
+
162
+ test("falls back to the default on a number that is not one", () => {
163
+ expect(resolveFeedbackFieldMinHeight(Number.NaN, 24)).toBe(144);
164
+ expect(resolveFeedbackFieldMinHeight(Number.POSITIVE_INFINITY, 24)).toBe(144);
165
+ });
166
+ });
167
+
168
+ describe("resolveFeedbackFieldHeightStyle", () => {
169
+ test("floors at minRows and caps at maxRows", () => {
170
+ expect(FEEDBACK_DEFAULT_MAX_ROWS).toBe(12);
171
+ expect(resolveFeedbackFieldHeightStyle({})).toEqual({ maxHeight: 288, minHeight: 144 });
172
+ expect(resolveFeedbackFieldHeightStyle({ maxRows: 4, minRows: 2 })).toEqual({ maxHeight: 96, minHeight: 48 });
173
+ });
174
+
175
+ test("lifts a ceiling below the floor up to it, so the field never shrinks as it grows", () => {
176
+ expect(resolveFeedbackFieldHeightStyle({ maxRows: 2, minRows: 6 })).toEqual({ maxHeight: 144, minHeight: 144 });
177
+ });
178
+ });
179
+
180
+ describe("FEEDBACK_PANEL_RESIZE_MS", () => {
181
+ test("is short and finite, so a step change settles before the next tap", () => {
182
+ expect(FEEDBACK_PANEL_RESIZE_MS).toBeGreaterThan(0);
183
+ expect(FEEDBACK_PANEL_RESIZE_MS).toBeLessThanOrEqual(250);
184
+ });
185
+ });
@@ -0,0 +1,128 @@
1
+ import type { VariantProps } from "tailwind-variants";
2
+ import { tv } from "../../lib/tv";
3
+
4
+ /** How many lines tall the field is before it grows, when `minRows` is not named. */
5
+ export const FEEDBACK_DEFAULT_MIN_ROWS = 6;
6
+
7
+ /** The tallest the field grows before it scrolls, when `maxRows` is not named. */
8
+ export const FEEDBACK_DEFAULT_MAX_ROWS = 12;
9
+
10
+ /**
11
+ * How long the well takes to follow a change in its content's height — a
12
+ * multi-step swap, or a line added while typing. Short, so a new line is never
13
+ * clipped for long, and finite, so an E2E runner's settle wait ends.
14
+ */
15
+ export const FEEDBACK_PANEL_RESIZE_MS = 200;
16
+
17
+ /**
18
+ * Points per line of the field — the `leading-6` its slot sets. The test reads
19
+ * the class back, so the two cannot drift apart.
20
+ */
21
+ export const FEEDBACK_FIELD_LINE_HEIGHT = 24;
22
+
23
+ /**
24
+ * Styling for every part of a feedback dialog.
25
+ *
26
+ * The shell is `Dialog.Content` with these classes merged over the dialog's
27
+ * own, so `content` only names what differs from a confirm dialog: the muted
28
+ * band and a tighter padding, which is what makes the well read as inset.
29
+ * Everything else — the corner, the hairline, the width cap — stays Dialog's.
30
+ *
31
+ * Free of React Native imports so it stays unit-testable. See AGENTS.md.
32
+ */
33
+ export const feedbackVariants = tv({
34
+ slots: {
35
+ /** The shell: the dialog's card, on the band colour, with a tight inset around the well. */
36
+ content: "gap-2 bg-muted p-2",
37
+ /**
38
+ * The animated clip around the well. Its height follows the well's on a
39
+ * timing; its corner matches the well's so nothing pokes out mid-resize.
40
+ */
41
+ clip: "overflow-hidden rounded-md",
42
+ /**
43
+ * The recessed well: the page colour, one step down the corner ramp from
44
+ * the card it sits in so the two read as nested, and a hairline. Relative
45
+ * so the corner ✕ positions against it rather than the shell.
46
+ */
47
+ panel: "relative gap-2 overflow-hidden rounded-md border border-border bg-background p-4",
48
+ /** Clearance for `Feedback.Close`, and nothing else — the type is `Text.Header`'s. */
49
+ title: "pr-8",
50
+ close: "absolute top-3 right-3 z-10",
51
+ /**
52
+ * A raw `TextInput` with no box: the well is the box. It restates
53
+ * `font-sans` for the reason `Input`'s field does — a `TextInput`
54
+ * inherits nothing — and sets paragraph leading, which its min height
55
+ * is counted in.
56
+ */
57
+ field: "p-0 font-sans text-foreground text-input-md leading-6",
58
+ /** Narrower than the well's inset, so the actions sit on the band rather than in it. */
59
+ footer: "flex-row items-center justify-end gap-2 px-1 pb-1",
60
+ },
61
+ });
62
+
63
+ export type FeedbackVariantProps = VariantProps<typeof feedbackVariants>;
64
+
65
+ export type CanSubmitFeedbackInput = {
66
+ /** The draft as typed. */
67
+ value: string;
68
+ /** Allow an empty message — when chips or a rating carry the answer. Default false. */
69
+ canSubmitEmpty?: boolean;
70
+ /** The submit is off — disabled by the caller, or a send already in flight. Default false. */
71
+ isDisabled?: boolean;
72
+ };
73
+
74
+ /**
75
+ * Whether `Feedback.Submit` can be pressed.
76
+ *
77
+ * Whitespace is empty: a message of three spaces sends nothing, and `onSubmit`
78
+ * receives the trimmed text anyway. Disabled outranks `canSubmitEmpty`.
79
+ *
80
+ * Pure, so it is reachable from `bun test`. See AGENTS.md.
81
+ */
82
+ export function canSubmitFeedback({
83
+ value,
84
+ canSubmitEmpty = false,
85
+ isDisabled = false,
86
+ }: CanSubmitFeedbackInput): boolean {
87
+ if (isDisabled) return false;
88
+ if (canSubmitEmpty) return true;
89
+ return value.trim().length > 0;
90
+ }
91
+
92
+ /**
93
+ * The field's minimum height, in points — `minRows` lines of `lineHeight`.
94
+ *
95
+ * A style, never a class: a runtime `` `min-h-[${n}px]` `` is never compiled by
96
+ * Tailwind's static scanner. A bad row count degrades the way `Textarea`'s
97
+ * does — floored to whole lines, clamped to one, the default on `NaN`.
98
+ *
99
+ * Pure, so it is reachable from `bun test`. See AGENTS.md.
100
+ */
101
+ export function resolveFeedbackFieldMinHeight(minRows: number | undefined, lineHeight: number): number {
102
+ const rows =
103
+ minRows === undefined || !Number.isFinite(minRows) ? FEEDBACK_DEFAULT_MIN_ROWS : Math.max(1, Math.floor(minRows));
104
+ return rows * lineHeight;
105
+ }
106
+
107
+ /**
108
+ * The field's height bounds, as a style: floored at `minRows`, capped at
109
+ * `maxRows`, and between the two React Native's multiline `TextInput` sizes
110
+ * itself to its text. Past the cap it scrolls — a dialog that grew without
111
+ * one would push its footer under the keyboard.
112
+ *
113
+ * A ceiling below the floor is lifted up to it, so the field never shrinks as
114
+ * it grows. Pure, so it is reachable from `bun test`. See AGENTS.md.
115
+ */
116
+ export function resolveFeedbackFieldHeightStyle({
117
+ minRows,
118
+ maxRows,
119
+ lineHeight = FEEDBACK_FIELD_LINE_HEIGHT,
120
+ }: {
121
+ minRows?: number;
122
+ maxRows?: number;
123
+ lineHeight?: number;
124
+ }): { minHeight: number; maxHeight: number } {
125
+ const minHeight = resolveFeedbackFieldMinHeight(minRows, lineHeight);
126
+ const ceiling = resolveFeedbackFieldMinHeight(maxRows ?? FEEDBACK_DEFAULT_MAX_ROWS, lineHeight);
127
+ return { maxHeight: Math.max(minHeight, ceiling), minHeight };
128
+ }
@@ -0,0 +1,24 @@
1
+ export { Feedback, type FeedbackProps } from "./feedback";
2
+ export { type FeedbackContextValue, useFeedback, useFeedbackContext } from "./feedback.context";
3
+ export {
4
+ type CanSubmitFeedbackInput,
5
+ canSubmitFeedback,
6
+ FEEDBACK_DEFAULT_MAX_ROWS,
7
+ FEEDBACK_DEFAULT_MIN_ROWS,
8
+ FEEDBACK_FIELD_LINE_HEIGHT,
9
+ FEEDBACK_PANEL_RESIZE_MS,
10
+ type FeedbackVariantProps,
11
+ feedbackVariants,
12
+ resolveFeedbackFieldHeightStyle,
13
+ resolveFeedbackFieldMinHeight,
14
+ } from "./feedback.variants";
15
+ export type { FeedbackActionProps } from "./feedback-action";
16
+ export type { FeedbackCancelProps } from "./feedback-cancel";
17
+ export type { FeedbackCloseProps } from "./feedback-close";
18
+ export type { FeedbackContentProps } from "./feedback-content";
19
+ export type { FeedbackFieldProps } from "./feedback-field";
20
+ export type { FeedbackFooterProps } from "./feedback-footer";
21
+ export type { FeedbackPanelProps } from "./feedback-panel";
22
+ export type { FeedbackSubmitProps } from "./feedback-submit";
23
+ export type { FeedbackTitleProps } from "./feedback-title";
24
+ export type { FeedbackTriggerProps } from "./feedback-trigger";