panelui-native 0.86.1 → 0.88.0

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.
Files changed (63) hide show
  1. package/README.md +4 -2
  2. package/lib/module/components/animated-badge/index.js +451 -0
  3. package/lib/module/components/animated-badge/index.js.map +1 -0
  4. package/lib/module/components/bottom-sheet/index.js +17 -6
  5. package/lib/module/components/bottom-sheet/index.js.map +1 -1
  6. package/lib/module/components/bubble-chart/index.js +1782 -0
  7. package/lib/module/components/bubble-chart/index.js.map +1 -0
  8. package/lib/module/components/feedback-dialog/index.js +474 -0
  9. package/lib/module/components/feedback-dialog/index.js.map +1 -0
  10. package/lib/module/components/pyramid-chart/index.js +1143 -0
  11. package/lib/module/components/pyramid-chart/index.js.map +1 -0
  12. package/lib/module/components/scroll-blur/index.js +363 -0
  13. package/lib/module/components/scroll-blur/index.js.map +1 -0
  14. package/lib/module/components/scroll-canvas/index.js +2 -0
  15. package/lib/module/components/scroll-canvas/index.js.map +1 -1
  16. package/lib/module/components/scroll-text/index.js +3 -0
  17. package/lib/module/components/scroll-text/index.js.map +1 -1
  18. package/lib/module/components/search-bar/index.js +753 -25
  19. package/lib/module/components/search-bar/index.js.map +1 -1
  20. package/lib/module/hooks/use-keyboard-avoidance.js +21 -1
  21. package/lib/module/hooks/use-keyboard-avoidance.js.map +1 -1
  22. package/lib/module/hooks/use-reveal-progress.js +21 -1
  23. package/lib/module/hooks/use-reveal-progress.js.map +1 -1
  24. package/lib/module/index.js +5 -0
  25. package/lib/module/index.js.map +1 -1
  26. package/lib/module/utils/chart.js +24 -0
  27. package/lib/module/utils/chart.js.map +1 -1
  28. package/lib/typescript/src/components/animated-badge/index.d.ts +224 -0
  29. package/lib/typescript/src/components/animated-badge/index.d.ts.map +1 -0
  30. package/lib/typescript/src/components/bottom-sheet/index.d.ts +1 -15
  31. package/lib/typescript/src/components/bottom-sheet/index.d.ts.map +1 -1
  32. package/lib/typescript/src/components/bubble-chart/index.d.ts +481 -0
  33. package/lib/typescript/src/components/bubble-chart/index.d.ts.map +1 -0
  34. package/lib/typescript/src/components/feedback-dialog/index.d.ts +156 -0
  35. package/lib/typescript/src/components/feedback-dialog/index.d.ts.map +1 -0
  36. package/lib/typescript/src/components/pyramid-chart/index.d.ts +328 -0
  37. package/lib/typescript/src/components/pyramid-chart/index.d.ts.map +1 -0
  38. package/lib/typescript/src/components/scroll-blur/index.d.ts +113 -0
  39. package/lib/typescript/src/components/scroll-blur/index.d.ts.map +1 -0
  40. package/lib/typescript/src/components/scroll-canvas/index.d.ts.map +1 -1
  41. package/lib/typescript/src/components/search-bar/index.d.ts +350 -6
  42. package/lib/typescript/src/components/search-bar/index.d.ts.map +1 -1
  43. package/lib/typescript/src/hooks/use-keyboard-avoidance.d.ts.map +1 -1
  44. package/lib/typescript/src/hooks/use-reveal-progress.d.ts +6 -27
  45. package/lib/typescript/src/hooks/use-reveal-progress.d.ts.map +1 -1
  46. package/lib/typescript/src/index.d.ts +6 -1
  47. package/lib/typescript/src/index.d.ts.map +1 -1
  48. package/lib/typescript/src/utils/chart.d.ts +14 -0
  49. package/lib/typescript/src/utils/chart.d.ts.map +1 -1
  50. package/package.json +1 -1
  51. package/src/components/animated-badge/index.tsx +513 -0
  52. package/src/components/bottom-sheet/index.tsx +23 -8
  53. package/src/components/bubble-chart/index.tsx +2145 -0
  54. package/src/components/feedback-dialog/index.tsx +557 -0
  55. package/src/components/pyramid-chart/index.tsx +1360 -0
  56. package/src/components/scroll-blur/index.tsx +466 -0
  57. package/src/components/scroll-canvas/index.tsx +2 -1
  58. package/src/components/scroll-text/index.tsx +3 -3
  59. package/src/components/search-bar/index.tsx +933 -45
  60. package/src/hooks/use-keyboard-avoidance.ts +24 -1
  61. package/src/hooks/use-reveal-progress.ts +30 -1
  62. package/src/index.ts +72 -1
  63. package/src/utils/chart.ts +27 -0
@@ -0,0 +1,557 @@
1
+ /**
2
+ * FeedbackDialog — a dialog whose body is something to write in, and whose
3
+ * actions sit in a band around it.
4
+ *
5
+ * ```tsx
6
+ * <FeedbackDialog open={open} onOpenChange={setOpen}>
7
+ * <FeedbackDialog.Content>
8
+ * <FeedbackDialog.Panel>
9
+ * <FeedbackDialog.Title>Help us improve, Gabe!</FeedbackDialog.Title>
10
+ * <FeedbackDialog.Close />
11
+ * <FeedbackDialog.Field value={text} onChangeText={setText} />
12
+ * </FeedbackDialog.Panel>
13
+ * <FeedbackDialog.Footer>
14
+ * <FeedbackDialog.Cancel />
15
+ * <FeedbackDialog.Submit onPress={send} />
16
+ * </FeedbackDialog.Footer>
17
+ * </FeedbackDialog.Content>
18
+ * </FeedbackDialog>
19
+ * ```
20
+ *
21
+ * ## Why the panel is a panel and not the dialog
22
+ *
23
+ * A dialog that asks for a sentence has two jobs on screen at once: hold what
24
+ * is being written, and offer the two things to do with it. `Dialog` puts both
25
+ * on one surface, which is right when the body is a line of prose — nothing
26
+ * about it says "this is where you type".
27
+ *
28
+ * Here the writing surface is a well set into the dialog, and the buttons sit
29
+ * in the band around it. The recess is the whole affordance: it says the field
30
+ * is the page and everything else is the frame around it, before a word has
31
+ * been read. It is also why the field has no border of its own — an outline
32
+ * inside a well is two edges saying the same thing.
33
+ *
34
+ * ## The actions are equal, and one of them is not
35
+ *
36
+ * Cancel and Submit take the same width, because they are the same size of
37
+ * decision — this is a sentence somebody wrote, not a deletion. What separates
38
+ * them is weight: Submit is filled in the foreground colour and Cancel is a
39
+ * tint of it, so the difference is legible at a glance without either being
40
+ * hidden.
41
+ *
42
+ * ## Nothing is submitted empty
43
+ *
44
+ * `Submit` disables itself while the field is empty. A dialog that accepts an
45
+ * empty answer produces empty feedback, and the person who sent it believes
46
+ * they said something.
47
+ */
48
+ import {
49
+ createContext,
50
+ forwardRef,
51
+ isValidElement,
52
+ useCallback,
53
+ useContext,
54
+ useMemo,
55
+ useState,
56
+ type ReactElement,
57
+ type ReactNode,
58
+ } from 'react';
59
+ import {
60
+ Pressable,
61
+ TextInput,
62
+ View,
63
+ type TextInputProps,
64
+ type ViewProps,
65
+ } from 'react-native';
66
+ import Animated, { FadeIn, FadeOut, ZoomIn } from 'react-native-reanimated';
67
+ import { tv } from 'tailwind-variants';
68
+ import { XIcon } from '../../icons';
69
+ import { AnimatedPressable } from '../../primitives/animated-pressable';
70
+ import { KeyboardAvoider } from '../../primitives/keyboard-avoider';
71
+ import { ModalPortal } from '../../primitives/portal';
72
+ import { Scrim } from '../../primitives/scrim';
73
+ import { Text, textChildren, type TextProps } from '../../primitives/text';
74
+ import { useBackHandler } from '../../hooks/use-back-handler';
75
+ import { cn } from '../../utils/cn';
76
+
77
+ /**
78
+ * The shell's padding, in points, and the corner it draws.
79
+ *
80
+ * The panel's corner is the shell's less that padding, which is what keeps the
81
+ * two curves concentric — a well whose radius does not account for the band
82
+ * around it leaves a crescent of shell at each corner, thicker there than it is
83
+ * along the sides.
84
+ */
85
+ const SHELL_PADDING = 8;
86
+ const SHELL_RADIUS = 38;
87
+ const PANEL_RADIUS = SHELL_RADIUS - SHELL_PADDING;
88
+
89
+ /**
90
+ * How far the action row is held in from the shell's own edge, beyond the
91
+ * padding the panel gets.
92
+ *
93
+ * The buttons are narrower than the well above them on purpose: a row that ran
94
+ * the full width would read as a third edge of the dialog rather than as two
95
+ * things to press.
96
+ */
97
+ const FOOTER_INSET = 26;
98
+
99
+ /** Room to write in before the field starts growing. */
100
+ const FIELD_MIN_HEIGHT = 200;
101
+
102
+ /**
103
+ * The ✕'s drawn size, and the slop that takes its touch box to 48.
104
+ *
105
+ * Made up with slop rather than with size, because the glyph is set against
106
+ * the title's cap height and a circle large enough to press comfortably would
107
+ * be taller than the line it sits on.
108
+ */
109
+ const CLOSE_SIZE = 22;
110
+ const CLOSE_HIT_SLOP = 13;
111
+
112
+ const feedbackDialogVariants = tv({
113
+ slots: {
114
+ /*
115
+ * The shell is `bg-popover` with an `inset` band laid over it rather than
116
+ * a colour of its own. `--color-inset` is a translucent black in every
117
+ * theme, so the shell always comes out darker than the panel it holds —
118
+ * which is the one thing this design cannot get from the surface ladder,
119
+ * because that ladder runs darker in a light theme and lighter in a dark
120
+ * one, and the recess has to read the same way in both.
121
+ */
122
+ shell: 'w-full max-w-sm overflow-hidden bg-popover shadow-xl',
123
+ recess: 'absolute inset-0 bg-inset',
124
+ panel: 'overflow-hidden bg-popover',
125
+ title: 'pe-9 text-[22px] font-semibold leading-[28px] text-popover-foreground',
126
+ close: 'absolute items-center justify-center rounded-full bg-foreground/10',
127
+ field: 'p-0 text-[17px] leading-[24px] text-muted-foreground',
128
+ footer: 'flex-row items-center gap-3.5',
129
+ action: 'h-11 flex-1 items-center justify-center rounded-full',
130
+ actionLabel: 'text-[16px]',
131
+ },
132
+ variants: {
133
+ tone: {
134
+ /** The one that discards. A tint of the foreground, not a fill. */
135
+ cancel: {
136
+ action: 'bg-foreground/10',
137
+ actionLabel: 'font-medium text-muted-foreground',
138
+ },
139
+ /** The one that sends. Filled, and the only filled thing on the dialog. */
140
+ submit: {
141
+ action: 'bg-primary',
142
+ actionLabel: 'font-semibold text-primary-foreground',
143
+ },
144
+ },
145
+ disabled: {
146
+ true: { action: 'opacity-[0.45]' },
147
+ },
148
+ },
149
+ });
150
+
151
+ interface FeedbackDialogContextValue {
152
+ open: boolean;
153
+ setOpen: (open: boolean) => void;
154
+ /** What is in the field, so Submit can refuse to send nothing. */
155
+ value: string;
156
+ setValue: (value: string) => void;
157
+ }
158
+
159
+ const FeedbackDialogContext = createContext<FeedbackDialogContextValue | null>(null);
160
+
161
+ function useFeedbackDialog(component: string): FeedbackDialogContextValue {
162
+ const context = useContext(FeedbackDialogContext);
163
+ if (!context) {
164
+ throw new Error(`${component} must be used within a <FeedbackDialog>`);
165
+ }
166
+ return context;
167
+ }
168
+
169
+ export interface FeedbackDialogProps {
170
+ children: ReactNode;
171
+ /** Controlled open state. */
172
+ open?: boolean;
173
+ onOpenChange?: (open: boolean) => void;
174
+ /** Initial state when uncontrolled. */
175
+ defaultOpen?: boolean;
176
+ /** The message, when the caller holds it. Leave unset to let the field keep it. */
177
+ value?: string;
178
+ /** Starting message for an uncontrolled field. Ignored once `value` is passed. */
179
+ defaultValue?: string;
180
+ onValueChange?: (value: string) => void;
181
+ }
182
+
183
+ function FeedbackDialogRoot({
184
+ children,
185
+ open,
186
+ onOpenChange,
187
+ defaultOpen = false,
188
+ value,
189
+ defaultValue = '',
190
+ onValueChange,
191
+ }: FeedbackDialogProps) {
192
+ const [internalOpen, setInternalOpen] = useState(defaultOpen);
193
+ const openControlled = open !== undefined;
194
+ const resolvedOpen = openControlled ? open : internalOpen;
195
+
196
+ const [internalValue, setInternalValue] = useState(defaultValue);
197
+ const valueControlled = value !== undefined;
198
+ const resolvedValue = valueControlled ? value : internalValue;
199
+
200
+ const setOpen = useCallback(
201
+ (next: boolean) => {
202
+ if (!openControlled) setInternalOpen(next);
203
+ onOpenChange?.(next);
204
+ },
205
+ [openControlled, onOpenChange]
206
+ );
207
+
208
+ const setValue = useCallback(
209
+ (next: string) => {
210
+ if (!valueControlled) setInternalValue(next);
211
+ onValueChange?.(next);
212
+ },
213
+ [valueControlled, onValueChange]
214
+ );
215
+
216
+ const context = useMemo(
217
+ () => ({ open: resolvedOpen, setOpen, value: resolvedValue, setValue }),
218
+ [resolvedOpen, setOpen, resolvedValue, setValue]
219
+ );
220
+
221
+ return (
222
+ <FeedbackDialogContext.Provider value={context}>
223
+ {children}
224
+ </FeedbackDialogContext.Provider>
225
+ );
226
+ }
227
+ FeedbackDialogRoot.displayName = 'FeedbackDialog';
228
+
229
+ interface FeedbackDialogTriggerProps {
230
+ children: ReactElement<{ onPress?: (...args: unknown[]) => void }>;
231
+ }
232
+
233
+ /** Wraps its child and opens the dialog on press. */
234
+ function FeedbackDialogTrigger({ children }: FeedbackDialogTriggerProps) {
235
+ const { setOpen } = useFeedbackDialog('FeedbackDialog.Trigger');
236
+ if (!isValidElement(children)) return children;
237
+ return (
238
+ <Pressable onPress={() => setOpen(true)}>
239
+ <View pointerEvents="none">{children}</View>
240
+ </Pressable>
241
+ );
242
+ }
243
+ FeedbackDialogTrigger.displayName = 'FeedbackDialog.Trigger';
244
+
245
+ export interface FeedbackDialogContentProps extends ViewProps {
246
+ className?: string;
247
+ /** Whether tapping outside or pressing back closes it. */
248
+ dismissible?: boolean;
249
+ /** Frost the screen behind instead of dimming it. Needs `expo-blur`. */
250
+ blur?: boolean;
251
+ children?: ReactNode;
252
+ }
253
+
254
+ /** The shell: the recessed band, and everything laid in it. */
255
+ function FeedbackDialogContent({
256
+ className,
257
+ dismissible = true,
258
+ blur = false,
259
+ children,
260
+ ...props
261
+ }: FeedbackDialogContentProps) {
262
+ const context = useFeedbackDialog('FeedbackDialog.Content');
263
+ const { open, setOpen } = context;
264
+ const slots = feedbackDialogVariants();
265
+
266
+ useBackHandler(open && dismissible, () => setOpen(false));
267
+
268
+ if (!open) return null;
269
+
270
+ return (
271
+ <ModalPortal>
272
+ {/* Portal content mounts under PortalHost, outside this provider's
273
+ subtree — re-provide the context so Close and Submit keep working. */}
274
+ <FeedbackDialogContext.Provider value={context}>
275
+ <Animated.View
276
+ entering={FadeIn.duration(150)}
277
+ exiting={FadeOut.duration(150)}
278
+ className="absolute inset-0 items-center justify-center p-6"
279
+ >
280
+ <Scrim blur={blur} />
281
+ <Pressable
282
+ accessibilityLabel="Close dialog"
283
+ className="absolute inset-0"
284
+ onPress={dismissible ? () => setOpen(false) : undefined}
285
+ />
286
+ {/*
287
+ The body is a field, so the dialog gets out of the keyboard's way
288
+ rather than sitting behind it. `lift` and not `dock`: it is centred
289
+ on the screen rather than pinned to an edge, so it only has to move
290
+ by however much the keyboard actually overlaps it.
291
+ */}
292
+ <KeyboardAvoider mode="lift" offset={16} className="w-full items-center">
293
+ <Animated.View
294
+ entering={ZoomIn.springify().damping(18).stiffness(250).mass(0.6)}
295
+ exiting={FadeOut.duration(120)}
296
+ accessibilityViewIsModal
297
+ className={slots.shell({ className })}
298
+ style={{ borderRadius: SHELL_RADIUS, padding: SHELL_PADDING }}
299
+ {...props}
300
+ >
301
+ <View pointerEvents="none" className={slots.recess()} />
302
+ {textChildren(children)}
303
+ </Animated.View>
304
+ </KeyboardAvoider>
305
+ </Animated.View>
306
+ </FeedbackDialogContext.Provider>
307
+ </ModalPortal>
308
+ );
309
+ }
310
+ FeedbackDialogContent.displayName = 'FeedbackDialog.Content';
311
+
312
+ export interface FeedbackDialogPanelProps extends ViewProps {
313
+ className?: string;
314
+ children?: ReactNode;
315
+ }
316
+
317
+ /** The well set into the shell: the title, the ✕ and the field. */
318
+ const FeedbackDialogPanel = forwardRef<View, FeedbackDialogPanelProps>(
319
+ ({ className, children, ...props }, ref) => {
320
+ const slots = feedbackDialogVariants();
321
+ return (
322
+ <View
323
+ ref={ref}
324
+ className={cn(slots.panel(), 'gap-4 p-5', className)}
325
+ style={{ borderRadius: PANEL_RADIUS }}
326
+ {...props}
327
+ >
328
+ {textChildren(children)}
329
+ </View>
330
+ );
331
+ }
332
+ );
333
+ FeedbackDialogPanel.displayName = 'FeedbackDialog.Panel';
334
+
335
+ const FeedbackDialogTitle = forwardRef<React.ElementRef<typeof Text>, TextProps>(
336
+ ({ className, ...props }, ref) => {
337
+ const slots = feedbackDialogVariants();
338
+ return <Text ref={ref} className={slots.title({ className })} {...props} />;
339
+ }
340
+ );
341
+ FeedbackDialogTitle.displayName = 'FeedbackDialog.Title';
342
+
343
+ export interface FeedbackDialogCloseProps extends ViewProps {
344
+ className?: string;
345
+ /** How the ✕ announces itself. */
346
+ label?: string;
347
+ /** Runs instead of closing. Call `onOpenChange` yourself if you pass this. */
348
+ onPress?: () => void;
349
+ }
350
+
351
+ /** The ✕ in the panel's corner. */
352
+ function FeedbackDialogClose({
353
+ className,
354
+ label = 'Close',
355
+ onPress,
356
+ ...props
357
+ }: FeedbackDialogCloseProps) {
358
+ const { setOpen } = useFeedbackDialog('FeedbackDialog.Close');
359
+ const slots = feedbackDialogVariants();
360
+ return (
361
+ <AnimatedPressable
362
+ accessibilityRole="button"
363
+ accessibilityLabel={label}
364
+ hitSlop={CLOSE_HIT_SLOP}
365
+ pressScale={1}
366
+ pressOpacity={0.55}
367
+ onPress={onPress ?? (() => setOpen(false))}
368
+ className={slots.close({ className })}
369
+ style={{ top: 20, right: 20, width: CLOSE_SIZE, height: CLOSE_SIZE }}
370
+ {...props}
371
+ >
372
+ <XIcon size={12} />
373
+ </AnimatedPressable>
374
+ );
375
+ }
376
+ FeedbackDialogClose.displayName = 'FeedbackDialog.Close';
377
+
378
+ export interface FeedbackDialogFieldProps
379
+ extends Omit<TextInputProps, 'value' | 'onChangeText'> {
380
+ className?: string;
381
+ /** The message. Leave unset to let the dialog hold it. */
382
+ value?: string;
383
+ onChangeText?: (value: string) => void;
384
+ /** Room to write in before the field starts growing. */
385
+ minHeight?: number;
386
+ }
387
+
388
+ /**
389
+ * What is being written.
390
+ *
391
+ * No border and no background of its own: it is already inside a well, and an
392
+ * outline drawn inside one is two edges making the same point.
393
+ *
394
+ * The caret is deliberately left to the platform. Every system draws its own
395
+ * accent there, and a field that overrides it is a field that looks like it
396
+ * belongs to a different phone.
397
+ */
398
+ const FeedbackDialogField = forwardRef<TextInput, FeedbackDialogFieldProps>(
399
+ (
400
+ {
401
+ className,
402
+ value,
403
+ onChangeText,
404
+ minHeight = FIELD_MIN_HEIGHT,
405
+ placeholder = 'Tell us what you think',
406
+ style,
407
+ ...props
408
+ },
409
+ ref
410
+ ) => {
411
+ const dialog = useFeedbackDialog('FeedbackDialog.Field');
412
+ const slots = feedbackDialogVariants();
413
+ const text = value ?? dialog.value;
414
+
415
+ return (
416
+ <TextInput
417
+ ref={ref}
418
+ multiline
419
+ textAlignVertical="top"
420
+ placeholder={placeholder}
421
+ value={text}
422
+ onChangeText={(next) => {
423
+ dialog.setValue(next);
424
+ onChangeText?.(next);
425
+ }}
426
+ className={slots.field({ className })}
427
+ style={[{ minHeight }, style]}
428
+ {...props}
429
+ />
430
+ );
431
+ }
432
+ );
433
+ FeedbackDialogField.displayName = 'FeedbackDialog.Field';
434
+
435
+ export interface FeedbackDialogFooterProps extends ViewProps {
436
+ className?: string;
437
+ children?: ReactNode;
438
+ }
439
+
440
+ /** The action row, in the band under the panel. */
441
+ const FeedbackDialogFooter = forwardRef<View, FeedbackDialogFooterProps>(
442
+ ({ className, children, style, ...props }, ref) => {
443
+ const slots = feedbackDialogVariants();
444
+ return (
445
+ <View
446
+ ref={ref}
447
+ className={slots.footer({ className })}
448
+ style={[{ marginTop: 16, marginHorizontal: FOOTER_INSET - SHELL_PADDING }, style]}
449
+ {...props}
450
+ >
451
+ {children}
452
+ </View>
453
+ );
454
+ }
455
+ );
456
+ FeedbackDialogFooter.displayName = 'FeedbackDialog.Footer';
457
+
458
+ export interface FeedbackDialogActionProps extends ViewProps {
459
+ className?: string;
460
+ labelClassName?: string;
461
+ disabled?: boolean;
462
+ onPress?: () => void;
463
+ children?: ReactNode;
464
+ }
465
+
466
+ /** Shared body for the two buttons, so they can only differ in the ways they should. */
467
+ function Action({
468
+ tone,
469
+ className,
470
+ labelClassName,
471
+ disabled,
472
+ onPress,
473
+ children,
474
+ ...props
475
+ }: FeedbackDialogActionProps & { tone: 'cancel' | 'submit' }) {
476
+ const slots = feedbackDialogVariants({ tone, disabled: !!disabled });
477
+ return (
478
+ <AnimatedPressable
479
+ accessibilityRole="button"
480
+ accessibilityState={{ disabled: !!disabled }}
481
+ disabled={disabled}
482
+ pressScale={0.97}
483
+ pressOpacity={0.9}
484
+ onPress={onPress}
485
+ className={slots.action({ className })}
486
+ {...props}
487
+ >
488
+ {textChildren(children, (label) => (
489
+ <Text className={slots.actionLabel({ className: labelClassName })}>{label}</Text>
490
+ ))}
491
+ </AnimatedPressable>
492
+ );
493
+ }
494
+
495
+ /** Discards and closes. Give it `onPress` to do something else first. */
496
+ function FeedbackDialogCancel({
497
+ children = 'Cancel',
498
+ onPress,
499
+ ...props
500
+ }: FeedbackDialogActionProps) {
501
+ const { setOpen } = useFeedbackDialog('FeedbackDialog.Cancel');
502
+ return (
503
+ <Action tone="cancel" onPress={onPress ?? (() => setOpen(false))} {...props}>
504
+ {children}
505
+ </Action>
506
+ );
507
+ }
508
+ FeedbackDialogCancel.displayName = 'FeedbackDialog.Cancel';
509
+
510
+ export interface FeedbackDialogSubmitProps extends FeedbackDialogActionProps {
511
+ /**
512
+ * Hand the message to the caller. The dialog does not close itself here —
513
+ * sending usually has to finish first, and a dialog that closed on the press
514
+ * would take its own error message with it.
515
+ */
516
+ onSubmit?: (value: string) => void;
517
+ }
518
+
519
+ /** Sends. Inert while the field is empty. */
520
+ function FeedbackDialogSubmit({
521
+ children = 'Submit',
522
+ disabled,
523
+ onPress,
524
+ onSubmit,
525
+ ...props
526
+ }: FeedbackDialogSubmitProps) {
527
+ const { value } = useFeedbackDialog('FeedbackDialog.Submit');
528
+ // Empty feedback is worse than none: it is sent by somebody who believes
529
+ // they said something.
530
+ const empty = value.trim().length === 0;
531
+ return (
532
+ <Action
533
+ tone="submit"
534
+ disabled={disabled ?? empty}
535
+ onPress={() => {
536
+ onPress?.();
537
+ onSubmit?.(value);
538
+ }}
539
+ {...props}
540
+ >
541
+ {children}
542
+ </Action>
543
+ );
544
+ }
545
+ FeedbackDialogSubmit.displayName = 'FeedbackDialog.Submit';
546
+
547
+ export const FeedbackDialog = Object.assign(FeedbackDialogRoot, {
548
+ Trigger: FeedbackDialogTrigger,
549
+ Content: FeedbackDialogContent,
550
+ Panel: FeedbackDialogPanel,
551
+ Title: FeedbackDialogTitle,
552
+ Close: FeedbackDialogClose,
553
+ Field: FeedbackDialogField,
554
+ Footer: FeedbackDialogFooter,
555
+ Cancel: FeedbackDialogCancel,
556
+ Submit: FeedbackDialogSubmit,
557
+ });