panelui-native 0.91.0 → 0.93.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.
- package/lib/module/components/frame/index.js +178 -34
- package/lib/module/components/frame/index.js.map +1 -1
- package/lib/module/components/page-header/index.js +577 -0
- package/lib/module/components/page-header/index.js.map +1 -0
- package/lib/module/components/skeleton/index.js +2 -1
- package/lib/module/components/skeleton/index.js.map +1 -1
- package/lib/module/index.js +1 -0
- package/lib/module/index.js.map +1 -1
- package/lib/typescript/src/components/frame/index.d.ts +19 -2
- package/lib/typescript/src/components/frame/index.d.ts.map +1 -1
- package/lib/typescript/src/components/page-header/index.d.ts +278 -0
- package/lib/typescript/src/components/page-header/index.d.ts.map +1 -0
- package/lib/typescript/src/components/skeleton/index.d.ts +10 -1
- package/lib/typescript/src/components/skeleton/index.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +2 -1
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/frame/index.tsx +180 -29
- package/src/components/page-header/index.tsx +674 -0
- package/src/components/skeleton/index.tsx +11 -1
- package/src/index.ts +17 -0
|
@@ -0,0 +1,674 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PageHeader — the top of a profile screen: a cover, the face over it, who it
|
|
3
|
+
* belongs to, and what you can do about them.
|
|
4
|
+
*
|
|
5
|
+
* Every social app opens a profile the same way, and rebuilding it per screen
|
|
6
|
+
* is how the cover ends up a different height on each one. The parts here are
|
|
7
|
+
* the pieces that arrangement is made of, and the two variants are the two
|
|
8
|
+
* places it lives: `card`, a self-contained surface in a scroll of other
|
|
9
|
+
* cards, and `page`, the header of the screen itself.
|
|
10
|
+
*
|
|
11
|
+
* ```tsx
|
|
12
|
+
* <PageHeader>
|
|
13
|
+
* <PageHeader.Cover />
|
|
14
|
+
* <PageHeader.Avatar source={face} fallback="OR" verified />
|
|
15
|
+
* <PageHeader.Content>
|
|
16
|
+
* <PageHeader.Title>Olivia Rhye</PageHeader.Title>
|
|
17
|
+
* <PageHeader.Description>@oliviarhye</PageHeader.Description>
|
|
18
|
+
* </PageHeader.Content>
|
|
19
|
+
* <PageHeader.Actions>
|
|
20
|
+
* <Button className="flex-1">Follow</Button>
|
|
21
|
+
* </PageHeader.Actions>
|
|
22
|
+
* </PageHeader>
|
|
23
|
+
* ```
|
|
24
|
+
*
|
|
25
|
+
* The cover in `card` is held off the card's edges and rounded on its top
|
|
26
|
+
* corners only, so its bottom edge meets the content rather than floating
|
|
27
|
+
* above it. The radius pair is `rounded-3xl` on the card and `rounded-2xl` on
|
|
28
|
+
* the cover, one step apart on the theme's own scale — which is the same 8
|
|
29
|
+
* points the card is padded by, so the two curves stay concentric without a
|
|
30
|
+
* hardcoded number that would be wrong in half the themes.
|
|
31
|
+
*
|
|
32
|
+
* The avatar lifts itself over the cover's bottom edge, and it works out
|
|
33
|
+
* whether to: the root looks for a `PageHeader.Cover` among its children and
|
|
34
|
+
* says so through context, because a header with no cover has nothing to
|
|
35
|
+
* overlap and an avatar that lifted anyway would hang off the top of it.
|
|
36
|
+
*
|
|
37
|
+
* The ring around the face is drawn in the surface behind it — the card, or
|
|
38
|
+
* the page — rather than in a border colour, so the face reads as punched out
|
|
39
|
+
* of the cover instead of outlined on top of it.
|
|
40
|
+
*/
|
|
41
|
+
import {
|
|
42
|
+
Children,
|
|
43
|
+
cloneElement,
|
|
44
|
+
createContext,
|
|
45
|
+
forwardRef,
|
|
46
|
+
isValidElement,
|
|
47
|
+
useContext,
|
|
48
|
+
useMemo,
|
|
49
|
+
type ReactNode,
|
|
50
|
+
} from 'react';
|
|
51
|
+
import {
|
|
52
|
+
Image,
|
|
53
|
+
StyleSheet,
|
|
54
|
+
View,
|
|
55
|
+
type ImageSourcePropType,
|
|
56
|
+
type PressableProps,
|
|
57
|
+
type Text as RNText,
|
|
58
|
+
type ViewProps,
|
|
59
|
+
} from 'react-native';
|
|
60
|
+
import { LinearGradient } from 'expo-linear-gradient';
|
|
61
|
+
import { tv, type VariantProps } from 'tailwind-variants';
|
|
62
|
+
import { useCSSVariable } from 'uniwind';
|
|
63
|
+
import { AnimatedPressable } from '../../primitives/animated-pressable';
|
|
64
|
+
import { Text, type TextProps, textChildren } from '../../primitives/text';
|
|
65
|
+
import { cn } from '../../utils/cn';
|
|
66
|
+
import { BadgeCheckIcon, IconColorProvider } from '../../icons';
|
|
67
|
+
import { Avatar, AVATAR_SIZE_POINTS, type AvatarProps, type AvatarSizeName } from '../avatar';
|
|
68
|
+
|
|
69
|
+
/** Height of a cover that is not given one. Two lines of nothing, and enough
|
|
70
|
+
* of a band for a face to sit half over. */
|
|
71
|
+
const COVER_HEIGHT = 120;
|
|
72
|
+
|
|
73
|
+
/** Points of surface drawn around the face. */
|
|
74
|
+
const AVATAR_RING = 4;
|
|
75
|
+
|
|
76
|
+
/** The rosette, as a share of the face it sits on. */
|
|
77
|
+
const BADGE_RATIO = 0.3;
|
|
78
|
+
|
|
79
|
+
/** The cover gradient, when no image is given. Both are series tokens, so an
|
|
80
|
+
* app that puts its charts on brand puts this on brand with them. */
|
|
81
|
+
const GRADIENT = ['--color-chart-2', '--color-chart-5'] as const;
|
|
82
|
+
const GRADIENT_FALLBACK = ['#3b82f6', '#8b5cf6'] as const;
|
|
83
|
+
|
|
84
|
+
const pageHeaderVariants = tv({
|
|
85
|
+
slots: {
|
|
86
|
+
root: 'w-full',
|
|
87
|
+
cover: 'overflow-hidden bg-muted',
|
|
88
|
+
avatar: 'self-start',
|
|
89
|
+
ring: 'overflow-hidden rounded-full',
|
|
90
|
+
row: 'flex-row items-center gap-5',
|
|
91
|
+
content: 'gap-1',
|
|
92
|
+
meta: 'flex-row items-center gap-1.5',
|
|
93
|
+
stats: 'flex-row items-center',
|
|
94
|
+
actions: 'flex-row items-center gap-2',
|
|
95
|
+
},
|
|
96
|
+
variants: {
|
|
97
|
+
variant: {
|
|
98
|
+
// `overflow-hidden` clips the cover's own square bottom corners to the
|
|
99
|
+
// card's radius on the way down; `p-2` is what holds the cover off the
|
|
100
|
+
// card's edges, and is the difference between the two radii above.
|
|
101
|
+
card: {
|
|
102
|
+
root: 'overflow-hidden rounded-3xl border border-border bg-card p-2',
|
|
103
|
+
cover: 'rounded-t-2xl',
|
|
104
|
+
ring: 'border-card',
|
|
105
|
+
avatar: 'px-3',
|
|
106
|
+
row: 'px-3',
|
|
107
|
+
content: 'px-3 pt-3',
|
|
108
|
+
actions: 'px-3 pb-2 pt-4',
|
|
109
|
+
},
|
|
110
|
+
// No surface of its own, and the cover runs to the screen's edges.
|
|
111
|
+
page: {
|
|
112
|
+
root: 'bg-background',
|
|
113
|
+
ring: 'border-background',
|
|
114
|
+
avatar: 'px-4',
|
|
115
|
+
row: 'px-4',
|
|
116
|
+
content: 'px-4 pt-3',
|
|
117
|
+
actions: 'px-4 pb-4 pt-4',
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
align: {
|
|
121
|
+
start: {},
|
|
122
|
+
center: {
|
|
123
|
+
avatar: 'self-center',
|
|
124
|
+
row: 'justify-center',
|
|
125
|
+
content: 'items-center',
|
|
126
|
+
meta: 'justify-center',
|
|
127
|
+
stats: 'justify-center',
|
|
128
|
+
actions: 'justify-center',
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
defaultVariants: {
|
|
133
|
+
variant: 'card',
|
|
134
|
+
align: 'center',
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
type PageHeaderVariantProps = VariantProps<typeof pageHeaderVariants>;
|
|
139
|
+
|
|
140
|
+
/** Where the header lives: a card in a scroll, or the top of the screen. */
|
|
141
|
+
export type PageHeaderVariant = NonNullable<PageHeaderVariantProps['variant']>;
|
|
142
|
+
|
|
143
|
+
/** Which edge the face and the text line up on. */
|
|
144
|
+
export type PageHeaderAlign = NonNullable<PageHeaderVariantProps['align']>;
|
|
145
|
+
|
|
146
|
+
interface PageHeaderContextValue {
|
|
147
|
+
variant: PageHeaderVariant;
|
|
148
|
+
align: PageHeaderAlign;
|
|
149
|
+
hasCover: boolean;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const PageHeaderContext = createContext<PageHeaderContextValue | undefined>(undefined);
|
|
153
|
+
|
|
154
|
+
function usePageHeader(component: string): PageHeaderContextValue {
|
|
155
|
+
const context = useContext(PageHeaderContext);
|
|
156
|
+
if (!context) throw new Error(`${component} must be used within a <PageHeader>`);
|
|
157
|
+
return context;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* True inside a `PageHeader.Row`. The avatar carries the body's inset and its
|
|
162
|
+
* own alignment when it stands alone, and neither when something else is
|
|
163
|
+
* already holding the row it sits in.
|
|
164
|
+
*/
|
|
165
|
+
const PageHeaderRowContext = createContext(false);
|
|
166
|
+
|
|
167
|
+
/** How a stats row reads its numbers out. */
|
|
168
|
+
const PageHeaderStatsContext = createContext<PageHeaderStatsLayout>('stacked');
|
|
169
|
+
|
|
170
|
+
export interface PageHeaderProps extends ViewProps {
|
|
171
|
+
className?: string;
|
|
172
|
+
children?: ReactNode;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export interface PageHeaderRootProps extends PageHeaderProps {
|
|
176
|
+
/**
|
|
177
|
+
* `card` is a surface of its own, with the cover held off its edges. `page`
|
|
178
|
+
* drops the surface and lets the cover run to the screen's edges.
|
|
179
|
+
*/
|
|
180
|
+
variant?: PageHeaderVariant;
|
|
181
|
+
/**
|
|
182
|
+
* Which edge the face, the text and the actions line up on. `center` is the
|
|
183
|
+
* profile card; `start` is the screen header, where the name is the first
|
|
184
|
+
* thing on the line rather than the middle of it.
|
|
185
|
+
*/
|
|
186
|
+
align?: PageHeaderAlign;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const PageHeaderRoot = forwardRef<View, PageHeaderRootProps>(
|
|
190
|
+
({ className, variant = 'card', align = 'center', children, ...props }, ref) => {
|
|
191
|
+
// Whether the avatar has anything to overlap. Read off the children rather
|
|
192
|
+
// than asked for, because a caller who has written a cover should not also
|
|
193
|
+
// have to say that they have.
|
|
194
|
+
const hasCover = Children.toArray(children).some(
|
|
195
|
+
(child) => isValidElement(child) && child.type === PageHeaderCover
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
const context = useMemo(
|
|
199
|
+
() => ({ variant, align, hasCover }),
|
|
200
|
+
[variant, align, hasCover]
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
return (
|
|
204
|
+
<View
|
|
205
|
+
{...props}
|
|
206
|
+
ref={ref}
|
|
207
|
+
className={pageHeaderVariants({ variant, align }).root({ className })}
|
|
208
|
+
>
|
|
209
|
+
<PageHeaderContext.Provider value={context}>{children}</PageHeaderContext.Provider>
|
|
210
|
+
</View>
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
);
|
|
214
|
+
PageHeaderRoot.displayName = 'PageHeader';
|
|
215
|
+
|
|
216
|
+
export interface PageHeaderCoverProps extends ViewProps {
|
|
217
|
+
className?: string;
|
|
218
|
+
/** The banner. Left out, the cover draws a gradient instead. */
|
|
219
|
+
source?: ImageSourcePropType;
|
|
220
|
+
/** How tall the band is. */
|
|
221
|
+
height?: number;
|
|
222
|
+
/**
|
|
223
|
+
* The gradient, when there is no image. Two colours or more, as real colour
|
|
224
|
+
* strings — this is painted rather than classed.
|
|
225
|
+
*/
|
|
226
|
+
colors?: readonly [string, string, ...string[]];
|
|
227
|
+
/** What the banner shows. Left out, it is treated as decoration. */
|
|
228
|
+
alt?: string;
|
|
229
|
+
children?: ReactNode;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* The banner. An image when given one, a gradient when not — a header with no
|
|
234
|
+
* banner still has to read as a header, and an empty band reads as a bug.
|
|
235
|
+
*/
|
|
236
|
+
const PageHeaderCover = forwardRef<View, PageHeaderCoverProps>(
|
|
237
|
+
({ className, source, height = COVER_HEIGHT, colors, alt, style, children, ...props }, ref) => {
|
|
238
|
+
const { variant } = usePageHeader('PageHeader.Cover');
|
|
239
|
+
const from = useCSSVariable(GRADIENT[0]);
|
|
240
|
+
const to = useCSSVariable(GRADIENT[1]);
|
|
241
|
+
|
|
242
|
+
// `useCSSVariable` can hand back something that is not a colour string, and
|
|
243
|
+
// a gradient given one paints nothing at all.
|
|
244
|
+
const ramp =
|
|
245
|
+
colors ??
|
|
246
|
+
([
|
|
247
|
+
typeof from === 'string' ? from : GRADIENT_FALLBACK[0],
|
|
248
|
+
typeof to === 'string' ? to : GRADIENT_FALLBACK[1],
|
|
249
|
+
] as const);
|
|
250
|
+
|
|
251
|
+
return (
|
|
252
|
+
<View
|
|
253
|
+
{...props}
|
|
254
|
+
ref={ref}
|
|
255
|
+
className={pageHeaderVariants({ variant }).cover({ className })}
|
|
256
|
+
style={[{ height }, style]}
|
|
257
|
+
>
|
|
258
|
+
{source ? (
|
|
259
|
+
<Image
|
|
260
|
+
source={source}
|
|
261
|
+
resizeMode="cover"
|
|
262
|
+
style={StyleSheet.absoluteFill}
|
|
263
|
+
accessible={!!alt}
|
|
264
|
+
accessibilityRole={alt ? 'image' : undefined}
|
|
265
|
+
accessibilityLabel={alt}
|
|
266
|
+
/>
|
|
267
|
+
) : (
|
|
268
|
+
<LinearGradient
|
|
269
|
+
colors={ramp}
|
|
270
|
+
start={{ x: 0, y: 0 }}
|
|
271
|
+
end={{ x: 0, y: 1 }}
|
|
272
|
+
// Filled by style rather than by class. `className` on a component
|
|
273
|
+
// from another package is only as good as that package's style
|
|
274
|
+
// forwarding, and a gradient that silently measures zero is a
|
|
275
|
+
// banner that is simply not there.
|
|
276
|
+
style={StyleSheet.absoluteFill}
|
|
277
|
+
accessibilityElementsHidden
|
|
278
|
+
importantForAccessibility="no-hide-descendants"
|
|
279
|
+
/>
|
|
280
|
+
)}
|
|
281
|
+
{children}
|
|
282
|
+
</View>
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
);
|
|
286
|
+
PageHeaderCover.displayName = 'PageHeader.Cover';
|
|
287
|
+
|
|
288
|
+
export interface PageHeaderAvatarProps extends Omit<AvatarProps, 'size' | 'children'> {
|
|
289
|
+
className?: string;
|
|
290
|
+
/** How big the face is. */
|
|
291
|
+
size?: AvatarSizeName;
|
|
292
|
+
/**
|
|
293
|
+
* What goes in the ring instead of a face — a logo, a monogram, a live
|
|
294
|
+
* thumbnail. It fills the ring, so give it its own background and padding.
|
|
295
|
+
*/
|
|
296
|
+
children?: ReactNode;
|
|
297
|
+
/** Draws the verification rosette in the face's bottom corner. */
|
|
298
|
+
verified?: boolean;
|
|
299
|
+
/**
|
|
300
|
+
* Anything else for that corner — a camera button, a presence dot, a "+".
|
|
301
|
+
* Wins over `verified`.
|
|
302
|
+
*/
|
|
303
|
+
badge?: ReactNode;
|
|
304
|
+
/**
|
|
305
|
+
* Whether the face lifts over the cover's bottom edge. Set by the presence
|
|
306
|
+
* of a `PageHeader.Cover`; pass it to override that either way.
|
|
307
|
+
*/
|
|
308
|
+
overlap?: boolean;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* The face, in a ring of whatever surface is behind it.
|
|
313
|
+
*
|
|
314
|
+
* The badge is drawn here rather than with `Avatar.Badge` because that one is
|
|
315
|
+
* pinned to the top corner, where an unread count belongs. A verification mark
|
|
316
|
+
* belongs at the bottom, beside the name it is vouching for.
|
|
317
|
+
*/
|
|
318
|
+
const PageHeaderAvatar = forwardRef<View, PageHeaderAvatarProps>(
|
|
319
|
+
(
|
|
320
|
+
{
|
|
321
|
+
className,
|
|
322
|
+
size = 'xl',
|
|
323
|
+
verified,
|
|
324
|
+
badge,
|
|
325
|
+
overlap,
|
|
326
|
+
source,
|
|
327
|
+
fallback,
|
|
328
|
+
imageProps,
|
|
329
|
+
children,
|
|
330
|
+
style,
|
|
331
|
+
...props
|
|
332
|
+
},
|
|
333
|
+
ref
|
|
334
|
+
) => {
|
|
335
|
+
const { variant, align, hasCover } = usePageHeader('PageHeader.Avatar');
|
|
336
|
+
const inRow = useContext(PageHeaderRowContext);
|
|
337
|
+
const slots = pageHeaderVariants({ variant, align });
|
|
338
|
+
const primary = useCSSVariable('--color-primary');
|
|
339
|
+
const onPrimary = useCSSVariable('--color-primary-foreground');
|
|
340
|
+
|
|
341
|
+
const diameter = AVATAR_SIZE_POINTS[size] ?? AVATAR_SIZE_POINTS.xl;
|
|
342
|
+
const lifts = overlap ?? hasCover;
|
|
343
|
+
const mark = badge ?? (verified ? (
|
|
344
|
+
<BadgeCheckIcon
|
|
345
|
+
size={Math.round(diameter * BADGE_RATIO)}
|
|
346
|
+
color={typeof primary === 'string' ? primary : '#2563eb'}
|
|
347
|
+
checkColor={typeof onPrimary === 'string' ? onPrimary : '#ffffff'}
|
|
348
|
+
/>
|
|
349
|
+
) : null);
|
|
350
|
+
|
|
351
|
+
return (
|
|
352
|
+
<View
|
|
353
|
+
{...props}
|
|
354
|
+
ref={ref}
|
|
355
|
+
// Inside a row the row is already holding the inset and the alignment;
|
|
356
|
+
// repeating them here would indent the face past the text under it.
|
|
357
|
+
className={inRow ? cn('self-start', className) : slots.avatar({ className })}
|
|
358
|
+
style={[lifts ? { marginTop: -Math.round(diameter / 2) } : null, style]}
|
|
359
|
+
>
|
|
360
|
+
{/*
|
|
361
|
+
Two views, because the ring clips. A badge hung on the clipping one
|
|
362
|
+
would be cut in half by the circle it is meant to sit against.
|
|
363
|
+
*/}
|
|
364
|
+
<View
|
|
365
|
+
className={slots.ring()}
|
|
366
|
+
style={
|
|
367
|
+
// With a face inside, the ring is the face plus its own border.
|
|
368
|
+
// With anything else there is nothing to measure, so the ring is
|
|
369
|
+
// given the size the face would have made.
|
|
370
|
+
children
|
|
371
|
+
? {
|
|
372
|
+
borderWidth: AVATAR_RING,
|
|
373
|
+
width: diameter + AVATAR_RING * 2,
|
|
374
|
+
height: diameter + AVATAR_RING * 2,
|
|
375
|
+
}
|
|
376
|
+
: { borderWidth: AVATAR_RING }
|
|
377
|
+
}
|
|
378
|
+
>
|
|
379
|
+
{children ?? (
|
|
380
|
+
<Avatar
|
|
381
|
+
size={size}
|
|
382
|
+
source={source}
|
|
383
|
+
fallback={fallback}
|
|
384
|
+
imageProps={imageProps}
|
|
385
|
+
className="border-0"
|
|
386
|
+
/>
|
|
387
|
+
)}
|
|
388
|
+
</View>
|
|
389
|
+
{mark ? <View className="absolute bottom-0.5 end-0.5">{mark}</View> : null}
|
|
390
|
+
</View>
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
);
|
|
394
|
+
PageHeaderAvatar.displayName = 'PageHeader.Avatar';
|
|
395
|
+
|
|
396
|
+
export interface PageHeaderRowProps extends PageHeaderProps {
|
|
397
|
+
children?: ReactNode;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* The face, and whatever sits beside it. For the profile that puts its counts
|
|
402
|
+
* next to the picture rather than under the name.
|
|
403
|
+
*/
|
|
404
|
+
const PageHeaderRow = forwardRef<View, PageHeaderRowProps>(
|
|
405
|
+
({ className, children, ...props }, ref) => {
|
|
406
|
+
const { variant, align } = usePageHeader('PageHeader.Row');
|
|
407
|
+
return (
|
|
408
|
+
<View
|
|
409
|
+
{...props}
|
|
410
|
+
ref={ref}
|
|
411
|
+
className={pageHeaderVariants({ variant, align }).row({ className })}
|
|
412
|
+
>
|
|
413
|
+
<PageHeaderRowContext.Provider value>{children}</PageHeaderRowContext.Provider>
|
|
414
|
+
</View>
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
);
|
|
418
|
+
PageHeaderRow.displayName = 'PageHeader.Row';
|
|
419
|
+
|
|
420
|
+
export interface PageHeaderContentProps extends PageHeaderProps {
|
|
421
|
+
children?: ReactNode;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** The text block: the name, the handle, and anything under them. */
|
|
425
|
+
const PageHeaderContent = forwardRef<View, PageHeaderContentProps>(
|
|
426
|
+
({ className, children, ...props }, ref) => {
|
|
427
|
+
const { variant, align } = usePageHeader('PageHeader.Content');
|
|
428
|
+
return (
|
|
429
|
+
<View
|
|
430
|
+
{...props}
|
|
431
|
+
ref={ref}
|
|
432
|
+
className={pageHeaderVariants({ variant, align }).content({ className })}
|
|
433
|
+
>
|
|
434
|
+
{textChildren(children)}
|
|
435
|
+
</View>
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
);
|
|
439
|
+
PageHeaderContent.displayName = 'PageHeader.Content';
|
|
440
|
+
|
|
441
|
+
/** Whose page it is. Announces itself as a heading. */
|
|
442
|
+
const PageHeaderTitle = forwardRef<RNText, TextProps>(({ className, ...props }, ref) => (
|
|
443
|
+
<Text
|
|
444
|
+
{...props}
|
|
445
|
+
ref={ref}
|
|
446
|
+
size="xl"
|
|
447
|
+
weight="semibold"
|
|
448
|
+
accessibilityRole="header"
|
|
449
|
+
className={cn('text-foreground', className)}
|
|
450
|
+
/>
|
|
451
|
+
));
|
|
452
|
+
PageHeaderTitle.displayName = 'PageHeader.Title';
|
|
453
|
+
|
|
454
|
+
/** The handle, the email, the bio — the quiet line under the name. */
|
|
455
|
+
const PageHeaderDescription = forwardRef<RNText, TextProps>(({ className, ...props }, ref) => (
|
|
456
|
+
<Text {...props} ref={ref} size="sm" muted className={className} />
|
|
457
|
+
));
|
|
458
|
+
PageHeaderDescription.displayName = 'PageHeader.Description';
|
|
459
|
+
|
|
460
|
+
export interface PageHeaderMetaProps extends PageHeaderProps {
|
|
461
|
+
/** A glyph before the line. Takes the muted colour without being told. */
|
|
462
|
+
icon?: ReactNode;
|
|
463
|
+
children?: ReactNode;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* One fact about the account — a link, a location, the month it was opened.
|
|
468
|
+
*/
|
|
469
|
+
const PageHeaderMeta = forwardRef<View, PageHeaderMetaProps>(
|
|
470
|
+
({ className, icon, children, ...props }, ref) => {
|
|
471
|
+
const { variant, align } = usePageHeader('PageHeader.Meta');
|
|
472
|
+
const muted = useCSSVariable('--color-muted-foreground');
|
|
473
|
+
return (
|
|
474
|
+
<View
|
|
475
|
+
{...props}
|
|
476
|
+
ref={ref}
|
|
477
|
+
className={pageHeaderVariants({ variant, align }).meta({ className })}
|
|
478
|
+
>
|
|
479
|
+
{icon ? (
|
|
480
|
+
<IconColorProvider color={typeof muted === 'string' ? muted : undefined}>
|
|
481
|
+
{icon}
|
|
482
|
+
</IconColorProvider>
|
|
483
|
+
) : null}
|
|
484
|
+
{textChildren(children, (text) => (
|
|
485
|
+
<Text size="sm" muted>
|
|
486
|
+
{text}
|
|
487
|
+
</Text>
|
|
488
|
+
))}
|
|
489
|
+
</View>
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
);
|
|
493
|
+
PageHeaderMeta.displayName = 'PageHeader.Meta';
|
|
494
|
+
|
|
495
|
+
/** How a stats row reads its numbers out. */
|
|
496
|
+
export type PageHeaderStatsLayout = 'stacked' | 'inline';
|
|
497
|
+
|
|
498
|
+
export interface PageHeaderStatsProps extends PageHeaderProps {
|
|
499
|
+
/**
|
|
500
|
+
* `stacked` puts the label under the figure, for a row of counts read as a
|
|
501
|
+
* set. `inline` runs them together — "533 Followers" — for counts read as
|
|
502
|
+
* part of a sentence.
|
|
503
|
+
*/
|
|
504
|
+
layout?: PageHeaderStatsLayout;
|
|
505
|
+
/** Rule between each count and the next. */
|
|
506
|
+
divided?: boolean;
|
|
507
|
+
children?: ReactNode;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* The row of counts.
|
|
512
|
+
*
|
|
513
|
+
* Centred and stacked, the counts take equal widths across the full row. Left
|
|
514
|
+
* to their content they are centred as a block but not as figures: "Followers"
|
|
515
|
+
* is twice the width of "Posts", so the middle count sits off the centre line
|
|
516
|
+
* the name and the buttons are on, and the row reads as very slightly wrong
|
|
517
|
+
* without it being obvious why.
|
|
518
|
+
*/
|
|
519
|
+
const PageHeaderStats = forwardRef<View, PageHeaderStatsProps>(
|
|
520
|
+
({ className, layout = 'stacked', divided, children, ...props }, ref) => {
|
|
521
|
+
const { variant, align } = usePageHeader('PageHeader.Stats');
|
|
522
|
+
const stacked = layout === 'stacked';
|
|
523
|
+
const measured = stacked && align === 'center';
|
|
524
|
+
|
|
525
|
+
// React Native has no `:first-child`, so the rule between two counts
|
|
526
|
+
// cannot be a style rule. Place it on every count but the first.
|
|
527
|
+
const items =
|
|
528
|
+
divided || measured
|
|
529
|
+
? Children.map(children, (child, index) => {
|
|
530
|
+
if (!isValidElement<PageHeaderStatProps>(child)) return child;
|
|
531
|
+
if (child.type !== PageHeaderStat) return child;
|
|
532
|
+
const next: Partial<PageHeaderStatProps> = {};
|
|
533
|
+
if (divided && child.props.divided === undefined) {
|
|
534
|
+
next.divided = index > 0;
|
|
535
|
+
}
|
|
536
|
+
if (measured) {
|
|
537
|
+
next.className = cn('flex-1', child.props.className);
|
|
538
|
+
}
|
|
539
|
+
return cloneElement(child, next);
|
|
540
|
+
})
|
|
541
|
+
: children;
|
|
542
|
+
|
|
543
|
+
return (
|
|
544
|
+
<View
|
|
545
|
+
{...props}
|
|
546
|
+
ref={ref}
|
|
547
|
+
className={pageHeaderVariants({ variant, align }).stats({
|
|
548
|
+
className: cn(
|
|
549
|
+
stacked ? 'gap-6' : 'gap-4',
|
|
550
|
+
measured && 'w-full',
|
|
551
|
+
className
|
|
552
|
+
),
|
|
553
|
+
})}
|
|
554
|
+
>
|
|
555
|
+
<PageHeaderStatsContext.Provider value={layout}>
|
|
556
|
+
{items}
|
|
557
|
+
</PageHeaderStatsContext.Provider>
|
|
558
|
+
</View>
|
|
559
|
+
);
|
|
560
|
+
}
|
|
561
|
+
);
|
|
562
|
+
PageHeaderStats.displayName = 'PageHeader.Stats';
|
|
563
|
+
|
|
564
|
+
export interface PageHeaderStatProps extends Omit<PressableProps, 'children'> {
|
|
565
|
+
className?: string;
|
|
566
|
+
/** The figure. */
|
|
567
|
+
value: ReactNode;
|
|
568
|
+
/** What it counts. */
|
|
569
|
+
label: ReactNode;
|
|
570
|
+
/** Rule before this count. `PageHeader.Stats` sets it; pass it to override. */
|
|
571
|
+
divided?: boolean;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* One count. Announced as a single thing — "533 Followers" — because a figure
|
|
576
|
+
* and its label read apart are two pieces of nothing.
|
|
577
|
+
*/
|
|
578
|
+
const PageHeaderStat = forwardRef<View, PageHeaderStatProps>(
|
|
579
|
+
({ className, value, label, divided, onPress, accessibilityLabel, ...props }, ref) => {
|
|
580
|
+
const layout = useContext(PageHeaderStatsContext);
|
|
581
|
+
const inline = layout === 'inline';
|
|
582
|
+
|
|
583
|
+
const spoken =
|
|
584
|
+
accessibilityLabel ??
|
|
585
|
+
(isSpeakable(value) && isSpeakable(label) ? `${value} ${label}` : undefined);
|
|
586
|
+
|
|
587
|
+
const classes = cn(
|
|
588
|
+
inline ? 'flex-row items-center gap-1' : 'items-center gap-0.5',
|
|
589
|
+
divided && 'border-s border-border ps-4',
|
|
590
|
+
className
|
|
591
|
+
);
|
|
592
|
+
|
|
593
|
+
const body = (
|
|
594
|
+
<>
|
|
595
|
+
<Text size="sm" weight="semibold">
|
|
596
|
+
{value}
|
|
597
|
+
</Text>
|
|
598
|
+
<Text size="sm" muted>
|
|
599
|
+
{label}
|
|
600
|
+
</Text>
|
|
601
|
+
</>
|
|
602
|
+
);
|
|
603
|
+
|
|
604
|
+
if (!onPress) {
|
|
605
|
+
return (
|
|
606
|
+
<View
|
|
607
|
+
{...(props as ViewProps)}
|
|
608
|
+
ref={ref}
|
|
609
|
+
accessible
|
|
610
|
+
accessibilityLabel={spoken}
|
|
611
|
+
className={classes}
|
|
612
|
+
>
|
|
613
|
+
{body}
|
|
614
|
+
</View>
|
|
615
|
+
);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
return (
|
|
619
|
+
<AnimatedPressable
|
|
620
|
+
{...props}
|
|
621
|
+
ref={ref}
|
|
622
|
+
accessibilityRole="button"
|
|
623
|
+
accessibilityLabel={spoken}
|
|
624
|
+
onPress={onPress}
|
|
625
|
+
className={classes}
|
|
626
|
+
>
|
|
627
|
+
{body}
|
|
628
|
+
</AnimatedPressable>
|
|
629
|
+
);
|
|
630
|
+
}
|
|
631
|
+
);
|
|
632
|
+
PageHeaderStat.displayName = 'PageHeader.Stat';
|
|
633
|
+
|
|
634
|
+
/** Whether a node can be read into an accessibility label as it stands. */
|
|
635
|
+
function isSpeakable(node: ReactNode): node is string | number {
|
|
636
|
+
return typeof node === 'string' || typeof node === 'number';
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
export interface PageHeaderActionsProps extends PageHeaderProps {
|
|
640
|
+
children?: ReactNode;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* What you can do about the account. Give the buttons `className="flex-1"` for
|
|
645
|
+
* the pair that splits the width evenly.
|
|
646
|
+
*/
|
|
647
|
+
const PageHeaderActions = forwardRef<View, PageHeaderActionsProps>(
|
|
648
|
+
({ className, children, ...props }, ref) => {
|
|
649
|
+
const { variant, align } = usePageHeader('PageHeader.Actions');
|
|
650
|
+
return (
|
|
651
|
+
<View
|
|
652
|
+
{...props}
|
|
653
|
+
ref={ref}
|
|
654
|
+
className={pageHeaderVariants({ variant, align }).actions({ className })}
|
|
655
|
+
>
|
|
656
|
+
{textChildren(children)}
|
|
657
|
+
</View>
|
|
658
|
+
);
|
|
659
|
+
}
|
|
660
|
+
);
|
|
661
|
+
PageHeaderActions.displayName = 'PageHeader.Actions';
|
|
662
|
+
|
|
663
|
+
export const PageHeader = Object.assign(PageHeaderRoot, {
|
|
664
|
+
Cover: PageHeaderCover,
|
|
665
|
+
Avatar: PageHeaderAvatar,
|
|
666
|
+
Row: PageHeaderRow,
|
|
667
|
+
Content: PageHeaderContent,
|
|
668
|
+
Title: PageHeaderTitle,
|
|
669
|
+
Description: PageHeaderDescription,
|
|
670
|
+
Meta: PageHeaderMeta,
|
|
671
|
+
Stats: PageHeaderStats,
|
|
672
|
+
Stat: PageHeaderStat,
|
|
673
|
+
Actions: PageHeaderActions,
|
|
674
|
+
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { memo, useEffect } from 'react';
|
|
2
|
+
import type { ViewProps } from 'react-native';
|
|
2
3
|
import Animated, {
|
|
3
4
|
cancelAnimation,
|
|
4
5
|
useAnimatedStyle,
|
|
@@ -21,6 +22,14 @@ const RESTING_OPACITY = 0.7;
|
|
|
21
22
|
|
|
22
23
|
export interface SkeletonProps {
|
|
23
24
|
className?: string;
|
|
25
|
+
/**
|
|
26
|
+
* View style for the placeholder — a dimension computed at runtime, or one no
|
|
27
|
+
* utility class expresses. Ordinary sizing belongs in `className`, and a
|
|
28
|
+
* value here wins over the class that sets the same property.
|
|
29
|
+
*
|
|
30
|
+
* The pulse owns `opacity`; setting it here has no effect.
|
|
31
|
+
*/
|
|
32
|
+
style?: ViewProps['style'];
|
|
24
33
|
/**
|
|
25
34
|
* What is loading, for a screen reader. Setting it makes this skeleton
|
|
26
35
|
* announce as a busy status; leaving it unset keeps the placeholder out of
|
|
@@ -49,6 +58,7 @@ export interface SkeletonProps {
|
|
|
49
58
|
*/
|
|
50
59
|
export const Skeleton = memo(function Skeleton({
|
|
51
60
|
className,
|
|
61
|
+
style,
|
|
52
62
|
label,
|
|
53
63
|
}: SkeletonProps) {
|
|
54
64
|
const reducedMotion = useReducedMotion();
|
|
@@ -81,7 +91,7 @@ export const Skeleton = memo(function Skeleton({
|
|
|
81
91
|
accessibilityState={announced ? { busy: true } : undefined}
|
|
82
92
|
accessibilityElementsHidden={!announced}
|
|
83
93
|
importantForAccessibility={announced ? 'auto' : 'no-hide-descendants'}
|
|
84
|
-
style={animatedStyle}
|
|
94
|
+
style={[style, animatedStyle]}
|
|
85
95
|
className={cn('rounded-md bg-skeleton', className)}
|
|
86
96
|
/>
|
|
87
97
|
);
|