panelui-native 0.30.0 → 0.32.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 (61) hide show
  1. package/README.md +4 -2
  2. package/lib/module/components/calendar/index.js +107 -24
  3. package/lib/module/components/calendar/index.js.map +1 -1
  4. package/lib/module/components/heatmap-chart/index.js +9 -8
  5. package/lib/module/components/heatmap-chart/index.js.map +1 -1
  6. package/lib/module/components/map/index.js +50 -4
  7. package/lib/module/components/map/index.js.map +1 -1
  8. package/lib/module/components/plan/index.js +307 -21
  9. package/lib/module/components/plan/index.js.map +1 -1
  10. package/lib/module/components/post/index.js +992 -0
  11. package/lib/module/components/post/index.js.map +1 -0
  12. package/lib/module/components/response/index.js +296 -0
  13. package/lib/module/components/response/index.js.map +1 -0
  14. package/lib/module/components/response/markdown.js +463 -0
  15. package/lib/module/components/response/markdown.js.map +1 -0
  16. package/lib/module/components/select/index.js +97 -15
  17. package/lib/module/components/select/index.js.map +1 -1
  18. package/lib/module/components/table/index.js +632 -0
  19. package/lib/module/components/table/index.js.map +1 -0
  20. package/lib/module/icons/index.js +200 -0
  21. package/lib/module/icons/index.js.map +1 -1
  22. package/lib/module/index.js +4 -1
  23. package/lib/module/index.js.map +1 -1
  24. package/lib/module/utils/date.js +129 -22
  25. package/lib/module/utils/date.js.map +1 -1
  26. package/lib/typescript/src/components/calendar/index.d.ts +12 -3
  27. package/lib/typescript/src/components/calendar/index.d.ts.map +1 -1
  28. package/lib/typescript/src/components/heatmap-chart/index.d.ts.map +1 -1
  29. package/lib/typescript/src/components/map/index.d.ts +8 -1
  30. package/lib/typescript/src/components/map/index.d.ts.map +1 -1
  31. package/lib/typescript/src/components/plan/index.d.ts +121 -10
  32. package/lib/typescript/src/components/plan/index.d.ts.map +1 -1
  33. package/lib/typescript/src/components/post/index.d.ts +358 -0
  34. package/lib/typescript/src/components/post/index.d.ts.map +1 -0
  35. package/lib/typescript/src/components/response/index.d.ts +91 -0
  36. package/lib/typescript/src/components/response/index.d.ts.map +1 -0
  37. package/lib/typescript/src/components/response/markdown.d.ts +88 -0
  38. package/lib/typescript/src/components/response/markdown.d.ts.map +1 -0
  39. package/lib/typescript/src/components/select/index.d.ts +27 -2
  40. package/lib/typescript/src/components/select/index.d.ts.map +1 -1
  41. package/lib/typescript/src/components/table/index.d.ts +274 -0
  42. package/lib/typescript/src/components/table/index.d.ts.map +1 -0
  43. package/lib/typescript/src/icons/index.d.ts +40 -1
  44. package/lib/typescript/src/icons/index.d.ts.map +1 -1
  45. package/lib/typescript/src/index.d.ts +5 -2
  46. package/lib/typescript/src/index.d.ts.map +1 -1
  47. package/lib/typescript/src/utils/date.d.ts +49 -5
  48. package/lib/typescript/src/utils/date.d.ts.map +1 -1
  49. package/package.json +1 -1
  50. package/src/components/calendar/index.tsx +136 -23
  51. package/src/components/heatmap-chart/index.tsx +9 -9
  52. package/src/components/map/index.tsx +53 -4
  53. package/src/components/plan/index.tsx +344 -21
  54. package/src/components/post/index.tsx +1116 -0
  55. package/src/components/response/index.tsx +390 -0
  56. package/src/components/response/markdown.ts +489 -0
  57. package/src/components/select/index.tsx +132 -12
  58. package/src/components/table/index.tsx +747 -0
  59. package/src/icons/index.tsx +162 -1
  60. package/src/index.ts +60 -0
  61. package/src/utils/date.ts +146 -22
@@ -0,0 +1,992 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Post — a card carrying something somebody said, and what everyone did about it.
5
+ *
6
+ * Four shapes, because a feed is not one thing. `feed` is the full card: author,
7
+ * body, media and a row of counts. `vote` puts a score pill beside a headline
8
+ * and a thumbnail, the way a ranked community reads. `compact` drops the media
9
+ * and puts the name and handle on one line, for a dense timeline. `media` gives
10
+ * the image the whole card and lays the author over it.
11
+ *
12
+ * What they share is the anatomy — the same `Post.Header`, `Post.Body`,
13
+ * `Post.Footer` in every one — so moving between them is a prop rather than a
14
+ * rewrite. The variant decides padding, media shape and where the author sits;
15
+ * it does not decide which parts exist.
16
+ *
17
+ * ```tsx
18
+ * <Post variant="feed">
19
+ * <Post.Header>
20
+ * <Post.Author name="Dwayne F. White" verified timestamp="3m ago" avatar={face} />
21
+ * <Post.Action><EllipsisIcon /></Post.Action>
22
+ * </Post.Header>
23
+ * <Post.Body>I've been paying off my credit card #FinancialFreedom</Post.Body>
24
+ * <Post.Media source={photo} />
25
+ * <Post.Footer>
26
+ * <Post.Stat icon={EyeIcon} value="5,874" />
27
+ * <Post.Stat icon={HeartIcon} value="215" tone="like" />
28
+ * <Post.Stat icon={MessageCircleIcon} value="11" />
29
+ * <Post.Stat icon={BookmarkIcon} value="Save" align="end" />
30
+ * </Post.Footer>
31
+ * </Post>
32
+ * ```
33
+ *
34
+ * ## Counts change under the finger
35
+ *
36
+ * Every control here is a toggle over a number, and the number is the point: a
37
+ * like that lights up but leaves `215` sitting there has not told you it
38
+ * counted. So a stat that changes animates the old value out and the new one in
39
+ * along the direction of the change, and `Post.Votes` does the same with its
40
+ * arrows. The alternative — repainting the digits in place — is indisting-
41
+ * uishable from a re-render, which is exactly the doubt the animation exists to
42
+ * remove.
43
+ */
44
+ import { createContext, forwardRef, useContext, useEffect, useMemo, useRef } from 'react';
45
+ import { Image, Pressable, View } from 'react-native';
46
+ import Animated, { FadeIn, FadeInDown, FadeInUp, FadeOut, useAnimatedStyle, useReducedMotion, useSharedValue, withSequence, withSpring, withTiming } from 'react-native-reanimated';
47
+ import { LinearGradient } from 'expo-linear-gradient';
48
+ import { tv } from 'tailwind-variants';
49
+ import { useCSSVariable } from 'uniwind';
50
+ import { ArrowDownIcon, ArrowUpIcon, BadgeCheckIcon } from "../../icons/index.js";
51
+ import { usePrevious } from "../../hooks/use-previous.js";
52
+ import { AnimatedPressable } from "../../primitives/animated-pressable.js";
53
+ import { Text, textChildren } from "../../primitives/text.js";
54
+ import { cn } from "../../utils/cn.js";
55
+ import { selectionTick } from "../../utils/haptics.js";
56
+ import { Avatar } from "../avatar/index.js";
57
+
58
+ /** Long enough to see the value move, short enough to keep up with a fast tap. */
59
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
60
+ const COUNT_DURATION = 160;
61
+
62
+ /** The pop on a toggle: past 1 and back, so the press has a shape. */
63
+ const POP = {
64
+ mass: 0.4,
65
+ damping: 12,
66
+ stiffness: 320
67
+ };
68
+ const postVariants = tv({
69
+ slots: {
70
+ root: 'w-full overflow-hidden rounded-2xl border border-border bg-card',
71
+ header: 'flex-row items-start gap-3',
72
+ author: 'min-w-0 flex-1 gap-0.5',
73
+ authorLine: 'flex-row items-center gap-1.5',
74
+ name: 'font-semibold text-card-foreground',
75
+ handle: 'text-muted-foreground',
76
+ meta: 'text-sm text-muted-foreground',
77
+ // As tall as the name's own line box, so at `items-start` the menu lands on
78
+ // the username's line rather than somewhere between the two lines of the
79
+ // author block — and lands there identically in all four variants.
80
+ action: 'h-6 shrink-0 items-center justify-center',
81
+ // `flex-1` for the same reason `Post.Author` has it: it is the header's
82
+ // flexible side, and without it the row is only as wide as the group's
83
+ // name — so `Post.Action` sits against the name instead of at the edge.
84
+ community: 'min-w-0 h-6 flex-1 flex-row items-center gap-2',
85
+ communityName: 'text-xs font-semibold text-card-foreground',
86
+ communityMeta: 'text-xs text-muted-foreground',
87
+ title: 'text-base font-semibold leading-snug text-card-foreground',
88
+ body: 'text-card-foreground',
89
+ media: 'overflow-hidden bg-muted',
90
+ footer: 'flex-row items-center',
91
+ stat: 'flex-row items-center gap-1.5 rounded-full',
92
+ statLabel: 'text-sm text-muted-foreground',
93
+ votes: 'flex-row items-center gap-0.5 rounded-full bg-muted',
94
+ voteButton: 'h-8 w-8 items-center justify-center rounded-full',
95
+ voteScore: 'text-sm font-semibold tabular-nums'
96
+ },
97
+ variants: {
98
+ variant: {
99
+ /** The full card — author, body, media, counts. */
100
+ feed: {
101
+ header: 'p-4 pb-3',
102
+ body: 'px-4 pb-3 text-base leading-relaxed',
103
+ media: 'mx-4 rounded-xl',
104
+ footer: 'gap-5 p-4'
105
+ },
106
+ /** A headline with a score beside it, the way a ranked community reads. */
107
+ vote: {
108
+ header: 'px-4 pb-2 pt-3.5',
109
+ body: 'px-4 pb-3 text-sm leading-relaxed',
110
+ media: 'mx-4 rounded-xl',
111
+ footer: 'gap-2 px-4 pb-3.5'
112
+ },
113
+ /** No media, name and handle on one line — for a dense timeline. */
114
+ compact: {
115
+ root: 'rounded-xl',
116
+ header: 'p-3.5 pb-2',
117
+ body: 'px-3.5 pb-2.5 text-sm leading-relaxed',
118
+ media: 'mx-3.5 rounded-lg',
119
+ footer: 'gap-6 px-3.5 pb-3'
120
+ },
121
+ /** The image is the card; the author is laid over it. */
122
+ media: {
123
+ header: 'absolute inset-x-0 top-0 z-10 p-3',
124
+ body: 'px-4 pb-3 pt-3 text-sm leading-relaxed',
125
+ media: 'rounded-none',
126
+ footer: 'gap-5 px-4 pb-4'
127
+ }
128
+ },
129
+ size: {
130
+ default: {},
131
+ sm: {
132
+ name: 'text-sm',
133
+ meta: 'text-xs',
134
+ statLabel: 'text-xs'
135
+ }
136
+ }
137
+ },
138
+ defaultVariants: {
139
+ variant: 'feed',
140
+ size: 'default'
141
+ }
142
+ });
143
+ const PostContext = /*#__PURE__*/createContext({
144
+ variant: 'feed',
145
+ size: 'default'
146
+ });
147
+ function usePost() {
148
+ return useContext(PostContext);
149
+ }
150
+ const PostRoot = /*#__PURE__*/forwardRef(({
151
+ className,
152
+ variant = 'feed',
153
+ size = 'default',
154
+ onPress,
155
+ children,
156
+ ...props
157
+ }, ref) => {
158
+ const {
159
+ root
160
+ } = postVariants({
161
+ variant,
162
+ size
163
+ });
164
+ const context = useMemo(() => ({
165
+ variant,
166
+ size
167
+ }), [variant, size]);
168
+ const body = /*#__PURE__*/_jsx(PostContext.Provider, {
169
+ value: context,
170
+ children: children
171
+ });
172
+ if (!onPress) {
173
+ return /*#__PURE__*/_jsx(View, {
174
+ ref: ref,
175
+ className: root({
176
+ className
177
+ }),
178
+ ...props,
179
+ children: body
180
+ });
181
+ }
182
+ return (
183
+ /*#__PURE__*/
184
+ // `article` rather than `button`: the card opens, but the row of controls
185
+ // inside it is the reason anyone is here, and announcing the whole thing
186
+ // as one button buries them.
187
+ _jsx(AnimatedPressable, {
188
+ ref: ref,
189
+ accessibilityRole: "link",
190
+ onPress: onPress,
191
+ className: root({
192
+ className
193
+ }),
194
+ ...props,
195
+ children: body
196
+ })
197
+ );
198
+ });
199
+ PostRoot.displayName = 'Post';
200
+
201
+ /* -------------------------------------------------------------------------- */
202
+ /* Header */
203
+ /* -------------------------------------------------------------------------- */
204
+
205
+ /**
206
+ * The author row. `Post.Action` is pulled to the trailing edge, so the overflow
207
+ * menu stays in the corner as a long display name wraps rather than riding down
208
+ * with it.
209
+ */
210
+ const PostHeader = /*#__PURE__*/forwardRef(({
211
+ className,
212
+ children,
213
+ ...props
214
+ }, ref) => {
215
+ const {
216
+ variant,
217
+ size
218
+ } = usePost();
219
+ const {
220
+ header
221
+ } = postVariants({
222
+ variant,
223
+ size
224
+ });
225
+ const content = [];
226
+ const actions = [];
227
+ for (const child of Array.isArray(children) ? children : [children]) {
228
+ if (isType(child, PostAction)) actions.push(child);else content.push(child);
229
+ }
230
+ return /*#__PURE__*/_jsxs(View, {
231
+ ref: ref,
232
+ className: header({
233
+ className
234
+ }),
235
+ ...props,
236
+ children: [content, actions]
237
+ });
238
+ });
239
+ PostHeader.displayName = 'Post.Header';
240
+ function isType(child, type) {
241
+ return typeof child === 'object' && child !== null && 'type' in child && child.type === type;
242
+ }
243
+ /**
244
+ * Who posted it.
245
+ *
246
+ * The handle sits beside the name in `compact` and beneath it everywhere else,
247
+ * which is the difference between a timeline row and a card: a dense row cannot
248
+ * afford a second line, and a card looks starved without one.
249
+ */
250
+ const PostAuthor = /*#__PURE__*/forwardRef(({
251
+ className,
252
+ name,
253
+ handle,
254
+ avatar,
255
+ fallback,
256
+ verified,
257
+ timestamp,
258
+ children,
259
+ ...props
260
+ }, ref) => {
261
+ const {
262
+ variant,
263
+ size
264
+ } = usePost();
265
+ const {
266
+ author,
267
+ authorLine,
268
+ name: nameSlot,
269
+ handle: handleSlot,
270
+ meta
271
+ } = postVariants({
272
+ variant,
273
+ size
274
+ });
275
+ const verifiedColor = useCSSVariable('--color-primary');
276
+ const onVerified = useCSSVariable('--color-primary-foreground');
277
+ const inline = variant === 'compact';
278
+ const overlaid = variant === 'media';
279
+ return (
280
+ /*#__PURE__*/
281
+ // Top-aligned, not centred. The name is the first line of the block, and
282
+ // aligning to the top is what puts it — and therefore the menu opposite
283
+ // it — on the same rule in every variant, whether the block below the
284
+ // name is one line, two, or none.
285
+ _jsxs(View, {
286
+ ref: ref,
287
+ className: "min-w-0 flex-1 flex-row items-start gap-2.5",
288
+ ...props,
289
+ children: [/*#__PURE__*/_jsx(Avatar, {
290
+ size: variant === 'compact' ? 'sm' : 'md',
291
+ source: avatar,
292
+ fallback: fallback ?? initials(name),
293
+ className: overlaid ? 'border-white/30' : undefined
294
+ }), /*#__PURE__*/_jsxs(View, {
295
+ className: author({
296
+ className
297
+ }),
298
+ children: [/*#__PURE__*/_jsxs(View, {
299
+ className: authorLine({
300
+ className: 'h-6'
301
+ }),
302
+ children: [/*#__PURE__*/_jsx(Text, {
303
+ numberOfLines: 1,
304
+ className: nameSlot({
305
+ className: cn('shrink', overlaid && 'text-white')
306
+ }),
307
+ children: name
308
+ }), verified ? /*#__PURE__*/_jsx(BadgeCheckIcon, {
309
+ size: 15,
310
+ color: typeof verifiedColor === 'string' ? verifiedColor : '#2563eb',
311
+ checkColor: typeof onVerified === 'string' ? onVerified : '#ffffff'
312
+ }) : null, inline && handle ? /*#__PURE__*/_jsx(Text, {
313
+ size: "sm",
314
+ numberOfLines: 1,
315
+ className: handleSlot({
316
+ className: 'shrink'
317
+ }),
318
+ children: handle
319
+ }) : null, inline && timestamp !== undefined ? /*#__PURE__*/_jsxs(_Fragment, {
320
+ children: [/*#__PURE__*/_jsx(Text, {
321
+ size: "sm",
322
+ className: handleSlot(),
323
+ children: "\xB7"
324
+ }), textChildren(timestamp, text => /*#__PURE__*/_jsx(Text, {
325
+ size: "sm",
326
+ className: handleSlot(),
327
+ children: text
328
+ }))]
329
+ }) : null]
330
+ }), !inline && (handle || timestamp !== undefined) ? /*#__PURE__*/_jsx(Text, {
331
+ numberOfLines: 1,
332
+ className: meta({
333
+ className: overlaid ? 'text-white/80' : undefined
334
+ }),
335
+ children: [handle, typeof timestamp === 'string' ? timestamp : undefined].filter(Boolean).join(' · ')
336
+ }) : null, !inline && timestamp !== undefined && typeof timestamp !== 'string' ? timestamp : null, children]
337
+ })]
338
+ })
339
+ );
340
+ });
341
+ PostAuthor.displayName = 'Post.Author';
342
+
343
+ /** First letters of the first two words — enough to tell two people apart. */
344
+ function initials(name) {
345
+ return name.split(/\s+/).slice(0, 2).map(word => word[0] ?? '').join('').toUpperCase();
346
+ }
347
+ /** The header's trailing slot — an overflow menu, a follow button, a badge. */
348
+ const PostAction = /*#__PURE__*/forwardRef(({
349
+ className,
350
+ children,
351
+ ...props
352
+ }, ref) => {
353
+ const {
354
+ variant,
355
+ size
356
+ } = usePost();
357
+ const {
358
+ action
359
+ } = postVariants({
360
+ variant,
361
+ size
362
+ });
363
+ return /*#__PURE__*/_jsx(View, {
364
+ ref: ref,
365
+ className: action({
366
+ className
367
+ }),
368
+ ...props,
369
+ children: children
370
+ });
371
+ });
372
+ PostAction.displayName = 'Post.Action';
373
+ /**
374
+ * The group a post was made in, above its headline.
375
+ *
376
+ * Smaller and quieter than an author row, because in a ranked community the
377
+ * post's subject is the headline — the group is where it came from, not who
378
+ * said it.
379
+ */
380
+ const PostCommunity = /*#__PURE__*/forwardRef(({
381
+ className,
382
+ name,
383
+ avatar,
384
+ meta,
385
+ children,
386
+ ...props
387
+ }, ref) => {
388
+ const {
389
+ variant,
390
+ size
391
+ } = usePost();
392
+ const {
393
+ community,
394
+ communityName,
395
+ communityMeta
396
+ } = postVariants({
397
+ variant,
398
+ size
399
+ });
400
+ return /*#__PURE__*/_jsxs(View, {
401
+ ref: ref,
402
+ className: community({
403
+ className
404
+ }),
405
+ ...props,
406
+ children: [/*#__PURE__*/_jsx(Avatar, {
407
+ size: "sm",
408
+ source: avatar,
409
+ fallback: name.replace(/^[^a-z0-9]*/i, '').slice(0, 1).toUpperCase(),
410
+ className: "h-6 w-6"
411
+ }), /*#__PURE__*/_jsx(Text, {
412
+ numberOfLines: 1,
413
+ className: communityName({
414
+ className: 'shrink'
415
+ }),
416
+ children: name
417
+ }), meta !== undefined ? /*#__PURE__*/_jsxs(_Fragment, {
418
+ children: [/*#__PURE__*/_jsx(Text, {
419
+ className: communityMeta(),
420
+ children: "\xB7"
421
+ }), textChildren(meta, text => /*#__PURE__*/_jsx(Text, {
422
+ className: communityMeta(),
423
+ children: text
424
+ }))]
425
+ }) : null, children]
426
+ });
427
+ });
428
+ PostCommunity.displayName = 'Post.Community';
429
+
430
+ /* -------------------------------------------------------------------------- */
431
+ /* Body */
432
+ /* -------------------------------------------------------------------------- */
433
+
434
+ /** The headline of a post whose subject is a headline rather than a person. */
435
+ const PostTitle = /*#__PURE__*/forwardRef(({
436
+ className,
437
+ children,
438
+ ...props
439
+ }, ref) => {
440
+ const {
441
+ variant,
442
+ size
443
+ } = usePost();
444
+ const {
445
+ title
446
+ } = postVariants({
447
+ variant,
448
+ size
449
+ });
450
+ return /*#__PURE__*/_jsx(View, {
451
+ ref: ref,
452
+ className: cn('px-4 pb-2', className),
453
+ ...props,
454
+ children: textChildren(children, text => /*#__PURE__*/_jsx(Text, {
455
+ className: title(),
456
+ children: text
457
+ }))
458
+ });
459
+ });
460
+ PostTitle.displayName = 'Post.Title';
461
+ /**
462
+ * What was written.
463
+ *
464
+ * Hashtags and mentions inside a string child are picked out and coloured. They
465
+ * are found in the text rather than declared, because a post body arrives as
466
+ * one string from wherever it was typed — asking the caller to pre-split it
467
+ * would mean every caller writing the same tokeniser.
468
+ *
469
+ * A non-string child is left entirely alone: it has already been laid out by
470
+ * someone who knew more than we do.
471
+ */
472
+ const PostBody = /*#__PURE__*/forwardRef(({
473
+ className,
474
+ numberOfLines,
475
+ onTagPress,
476
+ onMentionPress,
477
+ children,
478
+ ...props
479
+ }, ref) => {
480
+ const {
481
+ variant,
482
+ size
483
+ } = usePost();
484
+ const {
485
+ body
486
+ } = postVariants({
487
+ variant,
488
+ size
489
+ });
490
+ if (typeof children !== 'string') {
491
+ return /*#__PURE__*/_jsx(View, {
492
+ ref: ref,
493
+ className: cn(body(), className),
494
+ ...props,
495
+ children: children
496
+ });
497
+ }
498
+ return /*#__PURE__*/_jsx(View, {
499
+ ref: ref,
500
+ className: cn(body({
501
+ className
502
+ })),
503
+ ...props,
504
+ children: /*#__PURE__*/_jsx(Text, {
505
+ numberOfLines: numberOfLines,
506
+ className: body({
507
+ className: 'p-0'
508
+ }),
509
+ children: tokenize(children).map((token, index) => token.kind === 'text' ? token.value : /*#__PURE__*/_jsx(Text, {
510
+ className: "text-primary",
511
+ onPress: token.kind === 'tag' ? onTagPress && (() => onTagPress(token.value.slice(1))) : onMentionPress && (() => onMentionPress(token.value.slice(1))),
512
+ children: token.value
513
+ }, index))
514
+ })
515
+ });
516
+ });
517
+ PostBody.displayName = 'Post.Body';
518
+ /**
519
+ * Splits a body into plain runs, hashtags and mentions.
520
+ *
521
+ * The tag pattern stops at whitespace and at punctuation that ends a sentence,
522
+ * so "#DebtSnowball." highlights the tag and leaves the full stop black — the
523
+ * naive `\S+` swallows it and the sentence loses its ending.
524
+ */
525
+ function tokenize(text) {
526
+ const pattern = /[#@][\w-]+/g;
527
+ const tokens = [];
528
+ let last = 0;
529
+ for (const match of text.matchAll(pattern)) {
530
+ const start = match.index ?? 0;
531
+ if (start > last) tokens.push({
532
+ kind: 'text',
533
+ value: text.slice(last, start)
534
+ });
535
+ tokens.push({
536
+ kind: match[0].startsWith('#') ? 'tag' : 'mention',
537
+ value: match[0]
538
+ });
539
+ last = start + match[0].length;
540
+ }
541
+ if (last < text.length) tokens.push({
542
+ kind: 'text',
543
+ value: text.slice(last)
544
+ });
545
+ return tokens;
546
+ }
547
+ /**
548
+ * Black, fading to nothing.
549
+ *
550
+ * Three stops rather than two: a straight linear ramp from 55% to nothing has a
551
+ * visible shoulder where it meets the picture, and the eye reads that shoulder
552
+ * as an edge — which is the whole thing a scrim exists to avoid.
553
+ */
554
+ const SCRIM_COLOURS = ['rgba(0,0,0,0.55)', 'rgba(0,0,0,0.28)', 'rgba(0,0,0,0)'];
555
+
556
+ /**
557
+ * The picture.
558
+ *
559
+ * A fixed aspect ratio rather than the image's own, because a feed of cards
560
+ * whose heights are decided by whatever was uploaded scrolls like a broken
561
+ * staircase. The image covers the box and is cropped by it.
562
+ */
563
+ const PostMedia = /*#__PURE__*/forwardRef(({
564
+ className,
565
+ source,
566
+ aspectRatio = 16 / 10,
567
+ scrim,
568
+ overlay,
569
+ alt,
570
+ onPress,
571
+ children,
572
+ ...props
573
+ }, ref) => {
574
+ const {
575
+ variant,
576
+ size
577
+ } = usePost();
578
+ const {
579
+ media
580
+ } = postVariants({
581
+ variant,
582
+ size
583
+ });
584
+ // The media variant lays the author over the image, so it needs shading by
585
+ // default — the caller should not have to remember what makes their own
586
+ // layout readable.
587
+ const shade = scrim ?? (variant === 'media' ? 'top' : 'none');
588
+ const picture = /*#__PURE__*/_jsxs(_Fragment, {
589
+ children: [/*#__PURE__*/_jsx(Image, {
590
+ source: source,
591
+ resizeMode: "cover",
592
+ accessible: !!alt,
593
+ accessibilityLabel: alt,
594
+ accessibilityRole: alt ? 'image' : undefined,
595
+ className: "h-full w-full"
596
+ }), shade === 'top' || shade === 'both' ? /*#__PURE__*/_jsx(LinearGradient, {
597
+ pointerEvents: "none",
598
+ colors: SCRIM_COLOURS
599
+ // A fixed height, not a fraction. The scrim is there to carry one
600
+ // row of type, and a fraction of a 4:5 portrait shades half the
601
+ // photograph to do a job that needs the top 112 points of it.
602
+ ,
603
+ className: "absolute inset-x-0 top-0 h-28"
604
+ }) : null, shade === 'bottom' || shade === 'both' ? /*#__PURE__*/_jsx(LinearGradient, {
605
+ pointerEvents: "none",
606
+ colors: [SCRIM_COLOURS[2], SCRIM_COLOURS[1], SCRIM_COLOURS[0]],
607
+ className: "absolute inset-x-0 bottom-0 h-32"
608
+ }) : null, overlay ? /*#__PURE__*/_jsx(View, {
609
+ pointerEvents: "box-none",
610
+ className: "absolute inset-0",
611
+ children: overlay
612
+ }) : null, children]
613
+ });
614
+ if (!onPress) {
615
+ return /*#__PURE__*/_jsx(View, {
616
+ ref: ref,
617
+ className: media({
618
+ className
619
+ }),
620
+ style: {
621
+ aspectRatio
622
+ },
623
+ ...props,
624
+ children: picture
625
+ });
626
+ }
627
+ return /*#__PURE__*/_jsx(Pressable, {
628
+ ref: ref,
629
+ accessibilityRole: "imagebutton",
630
+ accessibilityLabel: alt,
631
+ onPress: onPress,
632
+ className: media({
633
+ className
634
+ }),
635
+ style: {
636
+ aspectRatio
637
+ },
638
+ ...props,
639
+ children: picture
640
+ });
641
+ });
642
+ PostMedia.displayName = 'Post.Media';
643
+
644
+ /* -------------------------------------------------------------------------- */
645
+ /* Footer */
646
+ /* -------------------------------------------------------------------------- */
647
+
648
+ /** The row of counts and controls under the post. */
649
+ const PostFooter = /*#__PURE__*/forwardRef(({
650
+ className,
651
+ children,
652
+ ...props
653
+ }, ref) => {
654
+ const {
655
+ variant,
656
+ size
657
+ } = usePost();
658
+ const {
659
+ footer
660
+ } = postVariants({
661
+ variant,
662
+ size
663
+ });
664
+ return /*#__PURE__*/_jsx(View, {
665
+ ref: ref,
666
+ className: footer({
667
+ className
668
+ }),
669
+ ...props,
670
+ children: children
671
+ });
672
+ });
673
+ PostFooter.displayName = 'Post.Footer';
674
+
675
+ /** What lighting up means. A like is red, a save is the accent, a repost green. */
676
+ const statTone = {
677
+ default: '--color-primary',
678
+ like: '--color-destructive',
679
+ save: '--color-primary',
680
+ repost: '--color-success'
681
+ };
682
+ /**
683
+ * One count in the footer, and the control that changes it.
684
+ *
685
+ * Pressing it pops the icon past its own size and settles back, fills it, and
686
+ * animates the value: the old number leaves upwards and the new one arrives
687
+ * from below when the count goes up, and the other way when it comes down.
688
+ * Repainting the digits in place is indistinguishable from a re-render, which
689
+ * is the doubt this exists to remove.
690
+ */
691
+ const PostStat = /*#__PURE__*/forwardRef(({
692
+ className,
693
+ icon: Icon,
694
+ value,
695
+ active = false,
696
+ tone = 'default',
697
+ align = 'start',
698
+ onPress,
699
+ children,
700
+ ...props
701
+ }, ref) => {
702
+ const {
703
+ variant,
704
+ size
705
+ } = usePost();
706
+ const {
707
+ stat,
708
+ statLabel
709
+ } = postVariants({
710
+ variant,
711
+ size
712
+ });
713
+ const litColor = useCSSVariable(statTone[tone]);
714
+ const restColor = useCSSVariable('--color-muted-foreground');
715
+ const reducedMotion = useReducedMotion();
716
+ const pop = useSharedValue(1);
717
+ const first = useRef(true);
718
+ useEffect(() => {
719
+ if (first.current) {
720
+ first.current = false;
721
+ return;
722
+ }
723
+ if (reducedMotion) return;
724
+ // Past 1 and back. A toggle that only grows reads as a state that got
725
+ // bigger; one that overshoots reads as a press.
726
+ pop.value = withSequence(withTiming(active ? 1.25 : 0.85, {
727
+ duration: 90
728
+ }), withSpring(1, POP));
729
+ }, [active, reducedMotion, pop]);
730
+ const iconStyle = useAnimatedStyle(() => ({
731
+ transform: [{
732
+ scale: pop.value
733
+ }]
734
+ }));
735
+ const colour = active ? typeof litColor === 'string' ? litColor : '#e11d48' : typeof restColor === 'string' ? restColor : '#737373';
736
+ const inner = /*#__PURE__*/_jsxs(_Fragment, {
737
+ children: [Icon ? /*#__PURE__*/_jsx(Animated.View, {
738
+ style: iconStyle,
739
+ children: /*#__PURE__*/_jsx(Icon, {
740
+ size: variant === 'compact' ? 15 : 17,
741
+ color: colour,
742
+ filled: active
743
+ })
744
+ }) : null, value !== undefined ? /*#__PURE__*/_jsx(PostCount, {
745
+ value: value,
746
+ className: statLabel({
747
+ className: active ? '' : undefined
748
+ }),
749
+ color: active ? colour : undefined
750
+ }) : null, children]
751
+ });
752
+ const classes = stat({
753
+ className: cn(align === 'end' && 'ms-auto', className)
754
+ });
755
+ if (!onPress) {
756
+ return /*#__PURE__*/_jsx(View, {
757
+ ref: ref,
758
+ className: classes,
759
+ children: inner
760
+ });
761
+ }
762
+ return /*#__PURE__*/_jsx(Pressable, {
763
+ ref: ref,
764
+ accessibilityRole: "button",
765
+ accessibilityState: {
766
+ selected: active
767
+ }
768
+ // The row is dense, so the targets overlap their own boxes rather than
769
+ // being spaced far enough apart to be hit — spacing them would be a
770
+ // footer twice as wide as the numbers in it.
771
+ ,
772
+ hitSlop: 10,
773
+ onPress: event => {
774
+ selectionTick();
775
+ onPress(event);
776
+ },
777
+ className: classes,
778
+ ...props,
779
+ children: inner
780
+ });
781
+ });
782
+ PostStat.displayName = 'Post.Stat';
783
+
784
+ /**
785
+ * A number that moves when it changes.
786
+ *
787
+ * The new value arrives from the direction the count travelled — rising into
788
+ * place when it went up, dropping in when it came down — while the old one
789
+ * fades. `215 → 216` then reads as a count going up rather than as text being
790
+ * replaced, which is the doubt worth removing: repainting digits in place is
791
+ * indistinguishable from a re-render.
792
+ *
793
+ * Only the entering half carries the direction. The element leaving was
794
+ * rendered before anyone knew which way the number would go, so asking it to
795
+ * animate knowingly would mean knowing the future.
796
+ *
797
+ * A value that is not a number — "Save" — cross-fades, since there is no
798
+ * direction for a word to go.
799
+ */
800
+ function PostCount({
801
+ value,
802
+ className,
803
+ color
804
+ }) {
805
+ const reducedMotion = useReducedMotion();
806
+ // Read during the render the value changes on, not after it: an effect would
807
+ // settle the direction one frame after the animation had already started.
808
+ const before = numeric(usePrevious(value));
809
+ const after = numeric(value);
810
+ const direction = before === null || after === null || before === after ? 0 : after > before ? 1 : -1;
811
+ const label = /*#__PURE__*/_jsx(Text, {
812
+ className: className,
813
+ style: color ? {
814
+ color
815
+ } : undefined,
816
+ children: value
817
+ });
818
+ if (reducedMotion) return label;
819
+ return /*#__PURE__*/_jsx(Animated.View, {
820
+ entering: direction === 1 ? FadeInUp.duration(COUNT_DURATION) : direction === -1 ? FadeInDown.duration(COUNT_DURATION) : FadeIn.duration(COUNT_DURATION),
821
+ exiting: FadeOut.duration(COUNT_DURATION),
822
+ children: label
823
+ }, String(value));
824
+ }
825
+
826
+ /** The value as a number, where it is one. `"5,874"` counts; `"Save"` does not. */
827
+ function numeric(value) {
828
+ if (typeof value === 'number') return value;
829
+ if (typeof value !== 'string') return null;
830
+ const parsed = Number(value.replace(/[,\s]/g, ''));
831
+ return Number.isFinite(parsed) ? parsed : null;
832
+ }
833
+
834
+ /* -------------------------------------------------------------------------- */
835
+ /* Votes */
836
+ /* -------------------------------------------------------------------------- */
837
+
838
+ /**
839
+ * The score, and the two arrows that move it.
840
+ *
841
+ * Both arrows fill and take a colour when cast, and the whole pill tints with
842
+ * them — the arrow alone is 16pt of a card, which is not enough to notice from
843
+ * arm's length. The score rolls in the direction of the change.
844
+ *
845
+ * Pressing the arrow already cast clears the vote. This is not a nicety: a
846
+ * score is a thing people change their minds about, and a vote that cannot be
847
+ * withdrawn is one they think twice before casting.
848
+ */
849
+ const PostVotes = /*#__PURE__*/forwardRef(({
850
+ className,
851
+ score,
852
+ vote = null,
853
+ onVote,
854
+ orientation = 'horizontal',
855
+ disabled = false,
856
+ ...props
857
+ }, ref) => {
858
+ const {
859
+ variant,
860
+ size
861
+ } = usePost();
862
+ const {
863
+ votes,
864
+ voteButton,
865
+ voteScore
866
+ } = postVariants({
867
+ variant,
868
+ size
869
+ });
870
+ const upColor = useCSSVariable('--color-success');
871
+ const downColor = useCSSVariable('--color-destructive');
872
+ const restColor = useCSSVariable('--color-muted-foreground');
873
+ const up = typeof upColor === 'string' ? upColor : '#16a34a';
874
+ const down = typeof downColor === 'string' ? downColor : '#dc2626';
875
+ const rest = typeof restColor === 'string' ? restColor : '#737373';
876
+ const cast = next => {
877
+ selectionTick();
878
+ onVote?.(vote === next ? null : next);
879
+ };
880
+ const scoreColour = vote === 'up' ? up : vote === 'down' ? down : undefined;
881
+ return /*#__PURE__*/_jsxs(View, {
882
+ ref: ref,
883
+ accessibilityRole: "adjustable",
884
+ accessibilityLabel: "Score",
885
+ accessibilityValue: {
886
+ text: String(score)
887
+ },
888
+ className: votes({
889
+ className: cn(orientation === 'vertical' && 'flex-col gap-0', vote === 'up' && 'bg-success/12', vote === 'down' && 'bg-destructive/12', className)
890
+ }),
891
+ ...props,
892
+ children: [/*#__PURE__*/_jsx(VoteArrow, {
893
+ direction: "up",
894
+ active: vote === 'up',
895
+ color: vote === 'up' ? up : rest,
896
+ disabled: disabled,
897
+ className: voteButton(),
898
+ onPress: () => cast('up')
899
+ }), /*#__PURE__*/_jsx(PostCount, {
900
+ value: score,
901
+ className: voteScore({
902
+ className: 'min-w-6 text-center'
903
+ }),
904
+ color: scoreColour
905
+ }), /*#__PURE__*/_jsx(VoteArrow, {
906
+ direction: "down",
907
+ active: vote === 'down',
908
+ color: vote === 'down' ? down : rest,
909
+ disabled: disabled,
910
+ className: voteButton(),
911
+ onPress: () => cast('down')
912
+ })]
913
+ });
914
+ });
915
+ PostVotes.displayName = 'Post.Votes';
916
+
917
+ /**
918
+ * One arrow.
919
+ *
920
+ * It travels along its own axis as it is cast — up for the up arrow, down for
921
+ * the down one — and springs back. The direction of the throw is the whole
922
+ * point: an arrow that merely scales says *something happened*, one that moves
923
+ * the way it points says *which way*.
924
+ */
925
+ function VoteArrow({
926
+ direction,
927
+ active,
928
+ color,
929
+ disabled,
930
+ className,
931
+ onPress
932
+ }) {
933
+ const reducedMotion = useReducedMotion();
934
+ const shift = useSharedValue(0);
935
+ const scale = useSharedValue(1);
936
+ const first = useRef(true);
937
+ useEffect(() => {
938
+ if (first.current) {
939
+ first.current = false;
940
+ return;
941
+ }
942
+ if (reducedMotion) return;
943
+ const travel = direction === 'up' ? -5 : 5;
944
+ shift.value = withSequence(withTiming(active ? travel : -travel * 0.4, {
945
+ duration: 100
946
+ }), withSpring(0, POP));
947
+ scale.value = withSequence(withTiming(active ? 1.2 : 0.9, {
948
+ duration: 100
949
+ }), withSpring(1, POP));
950
+ }, [active, direction, reducedMotion, shift, scale]);
951
+ const style = useAnimatedStyle(() => ({
952
+ transform: [{
953
+ translateY: shift.value
954
+ }, {
955
+ scale: scale.value
956
+ }]
957
+ }));
958
+ const Arrow = direction === 'up' ? ArrowUpIcon : ArrowDownIcon;
959
+ return /*#__PURE__*/_jsx(Pressable, {
960
+ accessibilityRole: "button",
961
+ accessibilityLabel: direction === 'up' ? 'Upvote' : 'Downvote',
962
+ accessibilityState: {
963
+ selected: active,
964
+ disabled
965
+ },
966
+ disabled: disabled,
967
+ hitSlop: 6,
968
+ onPress: onPress,
969
+ className: cn(className, disabled && 'opacity-[0.64]'),
970
+ children: /*#__PURE__*/_jsx(Animated.View, {
971
+ style: style,
972
+ children: /*#__PURE__*/_jsx(Arrow, {
973
+ size: 17,
974
+ color: color,
975
+ filled: active
976
+ })
977
+ })
978
+ });
979
+ }
980
+ export const Post = Object.assign(PostRoot, {
981
+ Header: PostHeader,
982
+ Author: PostAuthor,
983
+ Action: PostAction,
984
+ Community: PostCommunity,
985
+ Title: PostTitle,
986
+ Body: PostBody,
987
+ Media: PostMedia,
988
+ Footer: PostFooter,
989
+ Stat: PostStat,
990
+ Votes: PostVotes
991
+ });
992
+ //# sourceMappingURL=index.js.map