@lotics/ui 37.0.0 → 38.0.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/MIGRATION.md +28 -0
- package/docs/catalog.md +41 -9
- package/package.json +6 -1
- package/src/avatar_size.ts +23 -8
- package/src/file_preview.web.tsx +10 -8
- package/src/media_player.tsx +36 -0
- package/src/media_player.web.tsx +76 -0
- package/src/media_player_types.ts +13 -0
- package/src/page_header.tsx +51 -10
package/MIGRATION.md
CHANGED
|
@@ -4,6 +4,34 @@ Breaking changes, newest first — normally per major, plus the rare minor that
|
|
|
4
4
|
anyway (recorded under its exact version). The current contract lives in `AGENTS.md` + `docs/`;
|
|
5
5
|
this file exists only to move an app from one release to the next.
|
|
6
6
|
|
|
7
|
+
## 38.0.0 — the `lg` avatar is a control (40), not a size (48)
|
|
8
|
+
|
|
9
|
+
`AVATAR_PX.lg` was 48 and `AVATAR_TEXT.lg` was `lg` type. They are now **40** and `md`
|
|
10
|
+
type. Every `size="lg"` avatar — `Avatar`, `MemberChip`, `GroupAvatar` — renders 8px
|
|
11
|
+
smaller. Nothing to pass; there is no way to ask for 48.
|
|
12
|
+
|
|
13
|
+
**Why.** The scale's whole argument is that a rung quotes a number the system already
|
|
14
|
+
owns, which is why `md` is `CONTROL_CONTENT_HEIGHT` (28). `lg` quoted nothing. It is
|
|
15
|
+
the rung for an avatar that stands in a band **on its own** — an account menu beside a
|
|
16
|
+
row of icon buttons — and those are `CONTROL_HEIGHT`, so at 48 the avatar was the one
|
|
17
|
+
item in the row that visibly got out. 40 is that number.
|
|
18
|
+
|
|
19
|
+
**If a surface genuinely needs a bigger person figure**, it is not in a control band,
|
|
20
|
+
and `xl` (72) is the rung for it. If neither fits, the surface is setting its own row
|
|
21
|
+
height and should say so rather than reaching for an avatar rung to do it.
|
|
22
|
+
|
|
23
|
+
### `PageHeader` gained `leading`, and its title now wraps instead of overflowing
|
|
24
|
+
|
|
25
|
+
`leading` is additive — a control rendered immediately BEFORE the title, for something
|
|
26
|
+
that changes what is AROUND the content (a side-panel toggle, a back arrow), as opposed
|
|
27
|
+
to `actions`, which act on the content itself.
|
|
28
|
+
|
|
29
|
+
The behaviour change to check: a title too long for the row used to keep its intrinsic
|
|
30
|
+
width and push `actions` clean off the row — measured at 417px outside a 480px header.
|
|
31
|
+
It now wraps, and `actions` hold their place. **If you worked around this** by shortening
|
|
32
|
+
a title, hiding the CTA at narrow widths, or capping the header's width, that workaround
|
|
33
|
+
is now what is distorting the layout — drop it.
|
|
34
|
+
|
|
7
35
|
## 37.0.0 — `PageContent`'s side gutter is a token, and responsive
|
|
8
36
|
|
|
9
37
|
`PageContent` padded `16` at every width. It now uses `pagePad` from
|
package/docs/catalog.md
CHANGED
|
@@ -76,20 +76,23 @@ inline: a picker option, an assignee, a `select_member` value. Pure: resolve the
|
|
|
76
76
|
your directory and pass `name` / `image`; never hand-roll `Avatar` + `Text`. (`MemberSelect`
|
|
77
77
|
renders these per option.)
|
|
78
78
|
|
|
79
|
-
**Size is a RUNG, never a pixel count** — `sm | md | lg | xl` = 24 / 28 /
|
|
79
|
+
**Size is a RUNG, never a pixel count** — `sm | md | lg | xl` = 24 / 28 / 40 / 72
|
|
80
80
|
(`@lotics/ui/avatar_size`, shared by `Avatar`, `MemberChip` and `GroupAvatar`). Default `md`.
|
|
81
81
|
The number was removed because the same concept had drifted to eleven different values across
|
|
82
82
|
the product and its apps, so one person looked different on every screen. Pick by density:
|
|
83
83
|
`sm` for a dense row or inline cell, `md` for a control band, register or list (the default),
|
|
84
84
|
`lg` for a prominent row or the account menu, `xl` for a profile's own header.
|
|
85
85
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
86
|
+
**Both middle rungs quote `control_surface`, and which one you want depends on whether the
|
|
87
|
+
avatar is IN a control or IS one.** `md` is `CONTROL_CONTENT_HEIGHT` (28) — the largest rung a
|
|
88
|
+
40px control band seats without growing, and the height `InlineButton` and a `md` `IconButton`
|
|
89
|
+
already used to sit inside a field. An avatar is the tallest thing in whatever row holds it, so
|
|
90
|
+
a rung that overshoots doesn't look slightly big, it RESIZES the control: at the old 36 the same
|
|
91
|
+
member chip measured 46 in a `Select` trigger, 50 in an inline editor and 54 in an `InlineSlot`,
|
|
92
|
+
because each surface had picked its own inset with no number to pick it from. Put nothing taller
|
|
93
|
+
than `md` in a control. `lg` is `CONTROL_HEIGHT` (40) — the avatar that stands in a band on its
|
|
94
|
+
own, an account menu beside a row of icon buttons, so it measures what they do. Anything bigger
|
|
95
|
+
belongs in a row that sets its own height.
|
|
93
96
|
|
|
94
97
|
**`sm` shows ONE initial; every larger rung shows two** — two letters are not legible at 24px.
|
|
95
98
|
That is the practical reason not to reach for `sm` by reflex: on a real roster a single letter
|
|
@@ -517,7 +520,14 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
517
520
|
useSeparator` inserts and what menus/popovers put between option groups.
|
|
518
521
|
- **`container`** — `Container`: centers content at a max width (`ContainerSize` sm|md|lg,
|
|
519
522
|
`CONTAINER_SIZES`).
|
|
520
|
-
- **`page_header`** — `PageHeader`: the page's title band
|
|
523
|
+
- **`page_header`** — `PageHeader`: the page's title band. `actions` puts page-level CTAs on
|
|
524
|
+
the title row (right-aligned) and `leading` puts a control immediately BEFORE the title;
|
|
525
|
+
`left`/`right` form a separate nav row above. Split `leading` from `actions` by what the
|
|
526
|
+
control acts on: `actions` do something to the page's CONTENT (create, sort, export),
|
|
527
|
+
`leading` changes what is AROUND it (a side-panel toggle, a back arrow). A panel toggle
|
|
528
|
+
filed under `actions` reads as a peer of "create one of these". Under a title too long
|
|
529
|
+
for the row the TITLE gives way and wraps while `actions` keep their width — a wrapped
|
|
530
|
+
title is merely taller, a CTA pushed off the row is unreachable.
|
|
521
531
|
- **`page_content`** — `PageContent` + `PAGE_SIZES`: the page's padded, width-capped content
|
|
522
532
|
region — a centred column with optional `title`/`titleRight`/`description`, `header`/`footer`
|
|
523
533
|
slots and `fullscreen`. **Reach for it before hand-rolling a screen shell**: a scroller, a
|
|
@@ -1519,6 +1529,28 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
1519
1529
|
the sandboxed app iframe.
|
|
1520
1530
|
- **`file_preview_types`** — `PreviewLabels` / `FilePreviewProps` / `GalleryLabels` — the
|
|
1521
1531
|
shared label + prop contracts of the file-preview family; types only.
|
|
1532
|
+
- **`media_player`** — `MediaPlayer`: the ONE audio/video playback surface (`FilePreview`
|
|
1533
|
+
composes it; so does the chat's recording message). Transport is the PLATFORM element's
|
|
1534
|
+
— `controls preload="metadata"` — deliberately, because a hand-rolled scrub bar has to
|
|
1535
|
+
re-earn keyboard access, screen-reader semantics, volume, speed, PiP, fullscreen and
|
|
1536
|
+
captions that `<video>`/`<audio>` already get right. What it owns is what the element
|
|
1537
|
+
does not: `kind` picks the element (never inferred from MIME — a screen recording holds
|
|
1538
|
+
both streams and the CALLER decides whether this surface shows a picture), a REQUIRED
|
|
1539
|
+
`accessibilityLabel`, and `objectFit: contain` so a capture letterboxes rather than
|
|
1540
|
+
cropping evidence. It **fills its parent and carries no size of its own** — a lightbox
|
|
1541
|
+
wants the modal's height, a chat bubble wants a fixed 16:9 box that will not resize when
|
|
1542
|
+
metadata arrives, and a `fill`/`compact` prop to switch between them is exactly the
|
|
1543
|
+
branching the kit forbids. Compose the box. Filling is done OUT OF FLOW (the video sits
|
|
1544
|
+
absolutely inside a positioning wrapper the component owns) because a `<video>` is a
|
|
1545
|
+
replaced element: once metadata loads it reports the source's intrinsic size, and any
|
|
1546
|
+
ancestor sized by its content grows to match — with RN-web's `flexShrink: 0` default,
|
|
1547
|
+
nothing pushes back, so a 720p capture measurably widened a 342px chat column to 367px
|
|
1548
|
+
the moment it loaded. **Do not add your own fullscreen/expand control**: the native
|
|
1549
|
+
transport already carries fullscreen, picture-in-picture and download, and a second
|
|
1550
|
+
affordance beside them is both redundant and a claim about where it leads. Native renders
|
|
1551
|
+
the `notAvailable` placeholder — playback there needs a native media dep the package does
|
|
1552
|
+
not carry.
|
|
1553
|
+
- **`media_player_types`** — `MediaPlayerProps`; types only.
|
|
1522
1554
|
- **`file_gallery_modal`** — `FileGalleryModal`: the FULL-SCREEN viewer — toolbar (filename
|
|
1523
1555
|
, counter, download, optional `onOpenExternal`/`onRemove`, close-✕), prev/next, ESC,
|
|
1524
1556
|
rotate (the 90° controls FLOAT as a pill on the image); on a phone the actions collapse
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lotics/ui",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "38.0.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
"./vite": {
|
|
@@ -39,6 +39,11 @@
|
|
|
39
39
|
"default": "./src/file_preview.web.tsx"
|
|
40
40
|
},
|
|
41
41
|
"./file_preview_types": "./src/file_preview_types.ts",
|
|
42
|
+
"./media_player": {
|
|
43
|
+
"react-native": "./src/media_player.tsx",
|
|
44
|
+
"default": "./src/media_player.web.tsx"
|
|
45
|
+
},
|
|
46
|
+
"./media_player_types": "./src/media_player_types.ts",
|
|
42
47
|
"./file_gallery_modal": "./src/file_gallery_modal.tsx",
|
|
43
48
|
"./image_gallery": "./src/image_gallery.tsx",
|
|
44
49
|
"./rotatable_image": "./src/rotatable_image.tsx",
|
package/src/avatar_size.ts
CHANGED
|
@@ -28,6 +28,13 @@ export type AvatarSize = "sm" | "md" | "lg" | "xl";
|
|
|
28
28
|
* trigger, 50 in an inline editor and 54 in an `InlineSlot` — three heights for
|
|
29
29
|
* one control band, because each surface had picked its own inset and none of
|
|
30
30
|
* them had a number to pick it FROM.
|
|
31
|
+
*
|
|
32
|
+
* The same argument fixes the rung ABOVE it. An avatar that stands on its own in
|
|
33
|
+
* a band — an account menu next to a row of icon buttons — is not a child of a
|
|
34
|
+
* control, it IS one, so its number is `CONTROL_HEIGHT` (40), not a size picked
|
|
35
|
+
* for looking prominent. Both live rungs therefore quote `control_surface`; only
|
|
36
|
+
* `sm` (too small for two letters) and `xl` (a profile header, in no band at all)
|
|
37
|
+
* are free.
|
|
31
38
|
*/
|
|
32
39
|
export const AVATAR_PX: Record<AvatarSize, number> = {
|
|
33
40
|
/** Dense rows and inline chips — one initial, because two do not fit. */
|
|
@@ -35,8 +42,12 @@ export const AVATAR_PX: Record<AvatarSize, number> = {
|
|
|
35
42
|
/** The default: a 40px control band, a register, a list — anywhere a person is
|
|
36
43
|
* a row. Equals `CONTROL_CONTENT_HEIGHT` (`control_surface`). */
|
|
37
44
|
md: 28,
|
|
38
|
-
/**
|
|
39
|
-
|
|
45
|
+
/** An avatar that IS a control rather than one sitting inside a control — the
|
|
46
|
+
* account-menu trigger in a top bar, a prominent row's leading figure. Equals
|
|
47
|
+
* `CONTROL_HEIGHT` (`control_surface`), so it lines up with the icon buttons
|
|
48
|
+
* beside it. It was 48, which anchored to nothing: the bar's other controls
|
|
49
|
+
* are 40, and a 48px circle among them reads as the one thing that got out. */
|
|
50
|
+
lg: 40,
|
|
40
51
|
/** A profile's own header. */
|
|
41
52
|
xl: 72,
|
|
42
53
|
};
|
|
@@ -51,13 +62,17 @@ export const AVATAR_PX: Record<AvatarSize, number> = {
|
|
|
51
62
|
*
|
|
52
63
|
* sm 12 on 24 = 50%
|
|
53
64
|
* md 12 on 28 = 43%
|
|
54
|
-
* lg
|
|
65
|
+
* lg 16 on 40 = 40%
|
|
55
66
|
* xl 32 on 72 = 44%
|
|
56
67
|
*
|
|
57
|
-
* The two-letter rungs sit within
|
|
58
|
-
* same at every size rather than growing emptier as it grows — `lg` was on
|
|
59
|
-
* type (16 on 48 = 33%) and was visibly the thin one. `sm` runs richer by
|
|
60
|
-
* one letter in a small circle needs the weight.
|
|
68
|
+
* The two-letter rungs sit within 4 points of each other, so an avatar reads the
|
|
69
|
+
* same at every size rather than growing emptier as it grows — `lg` at 48 was on
|
|
70
|
+
* `md` type (16 on 48 = 33%) and was visibly the thin one. `sm` runs richer by
|
|
71
|
+
* design: one letter in a small circle needs the weight.
|
|
72
|
+
*
|
|
73
|
+
* `lg` keeps `md` type now that the circle is 40, and the next rung up would be
|
|
74
|
+
* worse in the other direction: `lg` type is 20, which on 40 is 50% — the fattest
|
|
75
|
+
* of the two-letter rungs, and the same defect this table was written to fix.
|
|
61
76
|
*
|
|
62
77
|
* `md` shares `sm`'s rung and is NOT the odd one out for it: the same 12px reads
|
|
63
78
|
* as 50% of a 24px circle holding one letter and 43% of a 28px circle holding
|
|
@@ -68,7 +83,7 @@ export const AVATAR_PX: Record<AvatarSize, number> = {
|
|
|
68
83
|
export const AVATAR_TEXT: Record<AvatarSize, TextSize> = {
|
|
69
84
|
sm: "xs", // 12 on 24 — one letter, so it carries a larger share of the circle
|
|
70
85
|
md: "xs", // 12 on 28
|
|
71
|
-
lg: "
|
|
86
|
+
lg: "md", // 16 on 40
|
|
72
87
|
xl: "xxl", // 32 on 72
|
|
73
88
|
};
|
|
74
89
|
|
package/src/file_preview.web.tsx
CHANGED
|
@@ -17,6 +17,7 @@ import { type FilePreviewProps, type PreviewLabels } from "./file_preview_types"
|
|
|
17
17
|
import { useLoticsLocale } from "./locale";
|
|
18
18
|
import { SpreadsheetView } from "./spreadsheet_view";
|
|
19
19
|
import { RotatableImage } from "./rotatable_image";
|
|
20
|
+
import { MediaPlayer } from "./media_player.web";
|
|
20
21
|
import { downloadFileFromUrl } from "./download";
|
|
21
22
|
import { colors } from "./colors";
|
|
22
23
|
|
|
@@ -56,13 +57,19 @@ export function FilePreview({ file, labels, onError, rotation, credentials }: Fi
|
|
|
56
57
|
return <WordPreview file={file} labels={l} onError={onError} credentials={credentials} />;
|
|
57
58
|
}
|
|
58
59
|
if (isVideoMimeType(file.mimeType)) {
|
|
59
|
-
return
|
|
60
|
+
return (
|
|
61
|
+
<div style={videoFillStyle}>
|
|
62
|
+
<MediaPlayer src={file.url} kind="video" accessibilityLabel={file.filename} />
|
|
63
|
+
</div>
|
|
64
|
+
);
|
|
60
65
|
}
|
|
61
66
|
if (isAudioMimeType(file.mimeType)) {
|
|
62
67
|
return (
|
|
63
68
|
<div style={audioContainerStyle}>
|
|
64
69
|
<MediaCard mimeType={file.mimeType} filename={file.filename} icon="music" size={160} />
|
|
65
|
-
<
|
|
70
|
+
<div style={audioElementStyle}>
|
|
71
|
+
<MediaPlayer src={file.url} kind="audio" accessibilityLabel={file.filename} />
|
|
72
|
+
</div>
|
|
66
73
|
</div>
|
|
67
74
|
);
|
|
68
75
|
}
|
|
@@ -505,12 +512,7 @@ const pdfOverlayStyle: React.CSSProperties = {
|
|
|
505
512
|
justifyContent: "center",
|
|
506
513
|
pointerEvents: "none",
|
|
507
514
|
};
|
|
508
|
-
const
|
|
509
|
-
width: "100%",
|
|
510
|
-
height: "100%",
|
|
511
|
-
backgroundColor: "rgba(10, 10, 10, 0.8)",
|
|
512
|
-
objectFit: "contain",
|
|
513
|
-
};
|
|
515
|
+
const videoFillStyle: React.CSSProperties = { width: "100%", height: "100%" };
|
|
514
516
|
const audioContainerStyle: React.CSSProperties = {
|
|
515
517
|
display: "flex",
|
|
516
518
|
flexDirection: "column",
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { StyleSheet, View } from "react-native";
|
|
2
|
+
import { Text } from "./text";
|
|
3
|
+
import { Icon } from "./icon";
|
|
4
|
+
import { colors } from "./colors";
|
|
5
|
+
import { useLoticsLocale } from "./locale";
|
|
6
|
+
import type { MediaPlayerProps } from "./media_player_types";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Native fallback. Playback needs a native media dependency this package does
|
|
10
|
+
* not carry, so it says so rather than rendering an inert box — the same choice
|
|
11
|
+
* `FilePreview` makes for the formats only its web variant can draw.
|
|
12
|
+
*/
|
|
13
|
+
export function MediaPlayer(props: MediaPlayerProps) {
|
|
14
|
+
const { kind, accessibilityLabel, testID } = props;
|
|
15
|
+
const l = useLoticsLocale().gallery;
|
|
16
|
+
return (
|
|
17
|
+
<View testID={testID} accessibilityLabel={accessibilityLabel} style={styles.placeholder}>
|
|
18
|
+
<Icon name={kind === "video" ? "monitor" : "music"} size={20} color={colors.zinc["400"]} />
|
|
19
|
+
<Text size="sm" color="muted">
|
|
20
|
+
{l.notAvailable}
|
|
21
|
+
</Text>
|
|
22
|
+
</View>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const styles = StyleSheet.create({
|
|
27
|
+
placeholder: {
|
|
28
|
+
flexDirection: "row",
|
|
29
|
+
alignItems: "center",
|
|
30
|
+
justifyContent: "center",
|
|
31
|
+
gap: 8,
|
|
32
|
+
padding: 16,
|
|
33
|
+
borderRadius: 8,
|
|
34
|
+
backgroundColor: colors.zinc["100"],
|
|
35
|
+
},
|
|
36
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { colors } from "./colors";
|
|
2
|
+
import type { MediaPlayerProps } from "./media_player_types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Playback surface for one audio or video source.
|
|
6
|
+
*
|
|
7
|
+
* Transport is the PLATFORM's, deliberately. A hand-rolled play/pause/scrub bar
|
|
8
|
+
* would have to re-earn keyboard access, screen-reader semantics, volume, speed,
|
|
9
|
+
* picture-in-picture, fullscreen, download and captions — all of which the
|
|
10
|
+
* native element already does correctly, and which callers should NOT duplicate
|
|
11
|
+
* with a control of their own.
|
|
12
|
+
*
|
|
13
|
+
* It FILLS its parent rather than carrying a size. A lightbox wants the video to
|
|
14
|
+
* take the modal's height; a chat bubble wants a 16:9 box that does not resize
|
|
15
|
+
* when metadata arrives. Those are the caller's boxes, and a `fill`/`compact`
|
|
16
|
+
* prop to switch between them is the branching this codebase forbids — so the
|
|
17
|
+
* caller composes the box and this draws inside it.
|
|
18
|
+
*/
|
|
19
|
+
export function MediaPlayer(props: MediaPlayerProps) {
|
|
20
|
+
const { src, kind, accessibilityLabel, testID } = props;
|
|
21
|
+
|
|
22
|
+
if (kind === "audio") {
|
|
23
|
+
return (
|
|
24
|
+
<audio
|
|
25
|
+
data-testid={testID}
|
|
26
|
+
aria-label={accessibilityLabel}
|
|
27
|
+
controls
|
|
28
|
+
preload="metadata"
|
|
29
|
+
src={src}
|
|
30
|
+
style={audioStyle}
|
|
31
|
+
/>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// A `<video>` is a REPLACED element: once metadata loads it reports the
|
|
36
|
+
// source's intrinsic size as its content size, and any ancestor sized by its
|
|
37
|
+
// content grows to match. React-native-web views default to `flexShrink: 0`,
|
|
38
|
+
// so nothing pushes back — a 1280x720 capture measurably widened a 342px chat
|
|
39
|
+
// column to 367px the moment it loaded, breaking the very "does not resize"
|
|
40
|
+
// promise above. Taking the media OUT OF FLOW is what makes filling safe, and
|
|
41
|
+
// it lives here rather than in each caller because a caller cannot see the
|
|
42
|
+
// intrinsic size that causes it. The wrapper carries the positioning context
|
|
43
|
+
// so the media cannot escape to some unrelated positioned ancestor.
|
|
44
|
+
return (
|
|
45
|
+
<div style={videoWrapperStyle}>
|
|
46
|
+
<video
|
|
47
|
+
data-testid={testID}
|
|
48
|
+
aria-label={accessibilityLabel}
|
|
49
|
+
controls
|
|
50
|
+
preload="metadata"
|
|
51
|
+
src={src}
|
|
52
|
+
style={videoStyle}
|
|
53
|
+
/>
|
|
54
|
+
</div>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Inline rather than StyleSheet: these land on DOM elements, not RN views.
|
|
59
|
+
const audioStyle = { width: "100%" } as const;
|
|
60
|
+
const videoWrapperStyle = {
|
|
61
|
+
position: "relative",
|
|
62
|
+
width: "100%",
|
|
63
|
+
height: "100%",
|
|
64
|
+
overflow: "hidden",
|
|
65
|
+
} as const;
|
|
66
|
+
const videoStyle = {
|
|
67
|
+
position: "absolute",
|
|
68
|
+
inset: 0,
|
|
69
|
+
width: "100%",
|
|
70
|
+
height: "100%",
|
|
71
|
+
display: "block",
|
|
72
|
+
// Letterbox rather than crop or stretch: a screen capture is evidence, and a
|
|
73
|
+
// cropped one silently hides part of what was on screen.
|
|
74
|
+
objectFit: "contain",
|
|
75
|
+
backgroundColor: colors.zinc["900"],
|
|
76
|
+
} as const;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface MediaPlayerProps {
|
|
2
|
+
/** Already-served URL for the media — signing/auth is the host's problem. */
|
|
3
|
+
src: string;
|
|
4
|
+
/**
|
|
5
|
+
* Which element to render. Not inferred from the MIME type on purpose: a
|
|
6
|
+
* screen recording carries both streams in one file, and the caller — not the
|
|
7
|
+
* container format — decides whether this surface is showing a picture.
|
|
8
|
+
*/
|
|
9
|
+
kind: "audio" | "video";
|
|
10
|
+
/** Accessible name for the player. Required: an unlabelled one is a blank control. */
|
|
11
|
+
accessibilityLabel: string;
|
|
12
|
+
testID?: string;
|
|
13
|
+
}
|
package/src/page_header.tsx
CHANGED
|
@@ -5,29 +5,70 @@ import { ReactNode } from "react";
|
|
|
5
5
|
interface PageHeaderProps {
|
|
6
6
|
title: string;
|
|
7
7
|
description?: string | null;
|
|
8
|
+
/** The nav row ABOVE the title — breadcrumbs, a back control, row-level status. */
|
|
8
9
|
left?: ReactNode;
|
|
10
|
+
/** The nav row ABOVE the title, right-aligned. */
|
|
9
11
|
right?: ReactNode;
|
|
12
|
+
/**
|
|
13
|
+
* A control that belongs to the TITLE, rendered immediately before it on the
|
|
14
|
+
* title row — a panel toggle, a back arrow into the page you came from.
|
|
15
|
+
*
|
|
16
|
+
* The distinction from `actions` is what the control acts ON, not where it
|
|
17
|
+
* looks best. `actions` are things you do to the page's CONTENT (create, sort,
|
|
18
|
+
* export) and collect at the right, where a scanning eye reaches them last;
|
|
19
|
+
* `leading` changes what is AROUND the content, and belongs against the title
|
|
20
|
+
* it frames. Putting a panel toggle in `actions` files it with the CTAs and
|
|
21
|
+
* makes the reader parse "open a side panel" as a peer of "create one of
|
|
22
|
+
* these".
|
|
23
|
+
*/
|
|
24
|
+
leading?: ReactNode;
|
|
10
25
|
/** Page-level actions (CTAs), rendered on the title row, right-aligned —
|
|
11
26
|
* distinct from `left`/`right`, which form a separate nav row above. */
|
|
12
27
|
actions?: ReactNode;
|
|
13
28
|
}
|
|
14
29
|
|
|
15
30
|
export function PageHeader(props: PageHeaderProps) {
|
|
16
|
-
const { title, description, left, right, actions } = props;
|
|
31
|
+
const { title, description, left, right, leading, actions } = props;
|
|
17
32
|
|
|
18
33
|
const hasNav = !!left || !!right;
|
|
19
34
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
35
|
+
// ONE shape, whichever slots are filled. Branching the row on `leading` gave
|
|
36
|
+
// the title two different behaviours under a long string — shrinking when a
|
|
37
|
+
// panel toggle happened to be present and overflowing when it was not — which
|
|
38
|
+
// is a layout law decided by an unrelated prop.
|
|
39
|
+
//
|
|
40
|
+
// The title is what gives way; `actions` keep their width, because a CTA
|
|
41
|
+
// pushed off the row is unreachable while a wrapped title is merely taller.
|
|
42
|
+
// Both halves of the pair are required: a React Native `View`/`Text` does NOT
|
|
43
|
+
// shrink by default (`flexShrink: 0`, unlike the web), and `flexShrink` alone
|
|
44
|
+
// then floors the item at its MIN-CONTENT width — the longest word — so
|
|
45
|
+
// `minWidth: 0` is what lets it actually wrap. `DetailRow`'s spread value is
|
|
46
|
+
// the same pair for the same reason.
|
|
47
|
+
const titleRow = (
|
|
48
|
+
<View
|
|
49
|
+
style={{
|
|
50
|
+
flexDirection: "row",
|
|
51
|
+
alignItems: "center",
|
|
52
|
+
justifyContent: "space-between",
|
|
53
|
+
gap: 12,
|
|
54
|
+
}}
|
|
55
|
+
>
|
|
56
|
+
<View
|
|
57
|
+
style={{
|
|
58
|
+
flexDirection: "row",
|
|
59
|
+
alignItems: "center",
|
|
60
|
+
gap: 8,
|
|
61
|
+
flexShrink: 1,
|
|
62
|
+
minWidth: 0,
|
|
63
|
+
}}
|
|
64
|
+
>
|
|
65
|
+
{leading}
|
|
66
|
+
<Text size="xxl" weight="semibold" style={{ flexShrink: 1, minWidth: 0 }}>
|
|
67
|
+
{title}
|
|
68
|
+
</Text>
|
|
69
|
+
</View>
|
|
25
70
|
{actions}
|
|
26
71
|
</View>
|
|
27
|
-
) : (
|
|
28
|
-
<Text size="xxl" weight="semibold">
|
|
29
|
-
{title}
|
|
30
|
-
</Text>
|
|
31
72
|
);
|
|
32
73
|
|
|
33
74
|
return (
|