@lotics/ui 39.0.0 → 40.2.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/AGENTS.md +5 -1
- package/MIGRATION.md +29 -0
- package/docs/catalog.md +23 -5
- package/package.json +1 -1
- package/src/locale.tsx +8 -0
- package/src/media_player.web.tsx +90 -1
- package/src/media_player_types.ts +22 -0
- package/src/popover.tsx +18 -3
package/AGENTS.md
CHANGED
|
@@ -30,7 +30,11 @@ CURRENT major only — upgrading an app across majors is `MIGRATION.md`.
|
|
|
30
30
|
- **Responsive layout measures the CONTAINER, not the screen.** `useContainerSize()` reports the
|
|
31
31
|
nearest `SizeBoundary`; wrap one around any region whose width stops tracking its parent's, and
|
|
32
32
|
`Dialog`/`Drawer`/popover bodies already are boundaries so their contents get the panel
|
|
33
|
-
rather than the region they were opened from.
|
|
33
|
+
rather than the region they were opened from. The overlay's OWN presentation is not one of
|
|
34
|
+
these questions and is not yours to answer: `PopoverContent` reads the screen itself and takes
|
|
35
|
+
no prop, because it fills the viewport whatever box opened it — handing it a container width is
|
|
36
|
+
how a narrow column on a wide display opened a full-bleed phone sheet.
|
|
37
|
+
`useScreenSize()` answers a question about the DEVICE — is there a keyboard
|
|
34
38
|
worth a shortcut hint, are these touch targets — and reaching for it to decide SPACE is how a
|
|
35
39
|
panel a few hundred pixels wide lays its contents out for the whole display. (A component that
|
|
36
40
|
needs a number rather than a bucket — how many columns fit — measures its own box; `Table`
|
package/MIGRATION.md
CHANGED
|
@@ -4,6 +4,35 @@ 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
|
+
## 40.0.0 — `PopoverContent` decides its own presentation; `small` is gone
|
|
8
|
+
|
|
9
|
+
`PopoverContent` took a `small` prop meaning "render as a bottom sheet". It now asks
|
|
10
|
+
`useScreenSize()` itself. **Delete the prop** — passing it is a type error.
|
|
11
|
+
|
|
12
|
+
```diff
|
|
13
|
+
- <PopoverContent small={small}>
|
|
14
|
+
+ <PopoverContent>
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
**Why the prop had to go rather than be defaulted.** A popover is an overlay: it fills the
|
|
18
|
+
viewport regardless of which box it was opened from, so the only input is how wide that
|
|
19
|
+
viewport is — and there is exactly one right answer per viewport. Every caller had to
|
|
20
|
+
supply it by hand, and the two ways to get it wrong both shipped: one passed
|
|
21
|
+
`useContainerSize()` — the width of the *column* holding the trigger — so a narrow chat
|
|
22
|
+
column on a 1728px display opened a full-bleed modal sheet; another passed nothing and so
|
|
23
|
+
never became a sheet on a real phone. Leaving the prop as an override would have kept both
|
|
24
|
+
mistakes writable.
|
|
25
|
+
|
|
26
|
+
**What changes for a NARROW-VIEWPORT host.** The rule is the viewport, not the device, so a
|
|
27
|
+
surface that is a few hundred pixels wide on a desktop — a browser side panel, an embedded
|
|
28
|
+
frame — now gets the bottom sheet where a popover that passed nothing was previously always
|
|
29
|
+
anchored. If that is wrong for your surface, argue it as a change to the threshold or to what
|
|
30
|
+
the rule reads, for every consumer at once; do not reach for a per-instance override.
|
|
31
|
+
|
|
32
|
+
If you were computing `small` only to feed this prop, drop the hook call too. `useScreenSize`
|
|
33
|
+
remains exported for its real uses — deciding whether a surface is a `Dialog` or a `Popover`
|
|
34
|
+
at all, sizing touch targets, showing a keyboard shortcut.
|
|
35
|
+
|
|
7
36
|
## 39.0.0 — `PageHeader.leading` is `trailing`, and it renders AFTER the title
|
|
8
37
|
|
|
9
38
|
38.0.0 shipped `leading`, a slot before the title. It is now **`trailing`**, in the same
|
package/docs/catalog.md
CHANGED
|
@@ -1247,8 +1247,13 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
1247
1247
|
control both dismisses this popover AND activates that control in one click; clicking outside,
|
|
1248
1248
|
scrolling an ancestor, or Escape dismisses. Interactions inside a layer the popover opened
|
|
1249
1249
|
from within (a nested popover, an `Alert` confirm, a full-screen Modal like the file preview)
|
|
1250
|
-
never dismiss it — clicks, scrolls, and Escape belong to that layer until it closes.
|
|
1251
|
-
`small
|
|
1250
|
+
never dismiss it — clicks, scrolls, and Escape belong to that layer until it closes. In a
|
|
1251
|
+
NARROW VIEWPORT (`small`, under 768) it presents instead as a modal bottom sheet (scrim, close
|
|
1252
|
+
button, slide-up) — decided by `PopoverContent` itself off the screen size, with nothing to pass
|
|
1253
|
+
and no way to answer it differently: an overlay fills the viewport whatever box it was opened
|
|
1254
|
+
from, so the width of the column holding the trigger is never the question. The viewport, not
|
|
1255
|
+
the device — a few-hundred-pixel side panel on a desktop gets the sheet too.
|
|
1256
|
+
`PopoverContent` already insets its
|
|
1252
1257
|
body 12px — put content directly in it, NEVER add your own padding `View` (that double-pads);
|
|
1253
1258
|
title/actions go in `PopoverHeader` / `PopoverFooter`. **A fixed width is the `width` PROP,
|
|
1254
1259
|
never `style`**: `style` reaches only the BODY, and the panel is `max-content`, so a width set
|
|
@@ -1564,7 +1569,18 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
1564
1569
|
nothing pushes back, so a 720p capture measurably widened a 342px chat column to 367px
|
|
1565
1570
|
the moment it loaded. **Do not add your own fullscreen/expand control**: the native
|
|
1566
1571
|
transport already carries fullscreen, picture-in-picture and download, and a second
|
|
1567
|
-
affordance beside them is both redundant and a claim about where it leads.
|
|
1572
|
+
affordance beside them is both redundant and a claim about where it leads. A source it
|
|
1573
|
+
cannot load is surfaced, never silent — a media element that fails paints an EMPTY BOX and
|
|
1574
|
+
reports nothing, so an expired serving token is indistinguishable from a feature that never
|
|
1575
|
+
produced a file; on error it renders a retryable notice filling the same box instead
|
|
1576
|
+
(`mediaPlayer` locale slice, worded agnostically because the kit does not know whether it is
|
|
1577
|
+
showing a recording, a clip or an attachment). Failure is remembered per SOURCE, so swapping
|
|
1578
|
+
`src` presents the new one rather than inheriting the old one's error, and the retry remounts
|
|
1579
|
+
the element — a re-render alone does not re-request the source. **`onError` reports the failure
|
|
1580
|
+
with the HTTP STATUS**, not just the `MediaError`: a lapsed token, a missing file and an
|
|
1581
|
+
unplayable codec all arrive as code 4, so the code alone cannot say which happened. The player
|
|
1582
|
+
resolves the status by re-requesting the source, and only when an `onError` is supplied — the
|
|
1583
|
+
kit carries no analytics, so the host decides whether the answer is worth a request. Native renders
|
|
1568
1584
|
the `notAvailable` placeholder — playback there needs a native media dep the package does
|
|
1569
1585
|
not carry.
|
|
1570
1586
|
- **`media_player_types`** — `MediaPlayerProps`; types only.
|
|
@@ -1786,8 +1802,10 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
1786
1802
|
width crosses a threshold — a height-only change (the mobile soft keyboard) and a resize
|
|
1787
1803
|
inside one bucket both return the identical value. Correct only for
|
|
1788
1804
|
questions about the device rather than the space: is there a keyboard to show a shortcut
|
|
1789
|
-
for, are these touch-sized targets, and
|
|
1790
|
-
|
|
1805
|
+
for, are these touch-sized targets, and whether a surface is a `Dialog` on a phone and a
|
|
1806
|
+
`Popover` otherwise. A popover's own anchored-vs-sheet presentation is NOT one of them —
|
|
1807
|
+
`PopoverContent` reads this itself and takes no prop. Everything spatial wants
|
|
1808
|
+
`useContainerSize`.
|
|
1791
1809
|
- **`use_async_fn`** — `useAsyncFn`: wrap an async function into a manual-trigger mutation —
|
|
1792
1810
|
`[run, {loading, data, error}]`, unmount-safe, the error lands in state AND rethrows; the
|
|
1793
1811
|
pending-state engine for a submit/download/upload action.
|
package/package.json
CHANGED
package/src/locale.tsx
CHANGED
|
@@ -114,6 +114,12 @@ export interface LoticsLocale {
|
|
|
114
114
|
* (open/download/remove + the confirm), the gallery chrome (close, prev/next,
|
|
115
115
|
* rotate), and the preview captions (not-available / load-failed / password). */
|
|
116
116
|
gallery: GalleryLabels;
|
|
117
|
+
/** `MediaPlayer`: the caption when a source will not load. Its own slice
|
|
118
|
+
* rather than the `gallery` one, whose wording is "preview" — true of
|
|
119
|
+
* `FilePreview` and wrong everywhere else the player is used, and a single
|
|
120
|
+
* string cannot mean both. Agnostic on purpose: the kit does not know
|
|
121
|
+
* whether it is showing a recording, a clip or an attachment. */
|
|
122
|
+
mediaPlayer: { loadFailed: string };
|
|
117
123
|
/** `Avatar`: the fallback name (initials + a11y label) shown when no `name`. */
|
|
118
124
|
avatar: { unknown: string };
|
|
119
125
|
/** `BackButton` + `PopoverNavHeader`: the back-chevron's a11y name. */
|
|
@@ -306,6 +312,7 @@ export const en: LoticsLocale = {
|
|
|
306
312
|
selectDateRange: "Select date range", selectDate: "Select date",
|
|
307
313
|
clear: "Clear", done: "Done", placeholder: "All time",
|
|
308
314
|
},
|
|
315
|
+
mediaPlayer: { loadFailed: "Failed to load media" },
|
|
309
316
|
gallery: {
|
|
310
317
|
close: "Close",
|
|
311
318
|
previous: "Previous",
|
|
@@ -494,6 +501,7 @@ export const vi: LoticsLocale = {
|
|
|
494
501
|
download: "Tải xuống",
|
|
495
502
|
passwordProtected: "Tệp có mật khẩu — không xem trước được",
|
|
496
503
|
},
|
|
504
|
+
mediaPlayer: { loadFailed: "Không tải được nội dung" },
|
|
497
505
|
avatar: { unknown: "Không rõ" },
|
|
498
506
|
nav: { back: "Quay lại" },
|
|
499
507
|
chart: { noData: "Không có dữ liệu", total: "Tổng" },
|
package/src/media_player.web.tsx
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { StyleSheet, View } from "react-native";
|
|
3
|
+
import { Button } from "./button";
|
|
4
|
+
import { Icon } from "./icon";
|
|
5
|
+
import { Text } from "./text";
|
|
1
6
|
import { colors } from "./colors";
|
|
7
|
+
import { useLoticsLocale } from "./locale";
|
|
2
8
|
import type { MediaPlayerProps } from "./media_player_types";
|
|
3
9
|
|
|
4
10
|
/**
|
|
@@ -17,16 +23,68 @@ import type { MediaPlayerProps } from "./media_player_types";
|
|
|
17
23
|
* caller composes the box and this draws inside it.
|
|
18
24
|
*/
|
|
19
25
|
export function MediaPlayer(props: MediaPlayerProps) {
|
|
20
|
-
const { src, kind, accessibilityLabel, testID } = props;
|
|
26
|
+
const { src, kind, accessibilityLabel, onError, testID } = props;
|
|
27
|
+
const l = useLoticsLocale();
|
|
28
|
+
// Bumped to REMOUNT the element on retry. Clearing the flag alone leaves the
|
|
29
|
+
// failed element in place, and a media element does not re-request its source
|
|
30
|
+
// just because it re-rendered.
|
|
31
|
+
const [attempt, setAttempt] = useState(0);
|
|
32
|
+
// The failed SOURCE, not a boolean: this component keeps its position when a
|
|
33
|
+
// caller swaps `src` (a gallery stepping to the next clip), and a boolean
|
|
34
|
+
// would carry the previous file's failure onto a file that was never tried.
|
|
35
|
+
const [failedSrc, setFailedSrc] = useState<string | null>(null);
|
|
36
|
+
const failed = failedSrc === src;
|
|
37
|
+
|
|
38
|
+
const handleError = (element: HTMLMediaElement) => {
|
|
39
|
+
setFailedSrc(src);
|
|
40
|
+
// Only probe when someone is listening — the host pays a request for the
|
|
41
|
+
// answer, so it is not spent when nothing reads it.
|
|
42
|
+
if (!onError) return;
|
|
43
|
+
const code = element.error?.code ?? null;
|
|
44
|
+
void resolveStatus(src).then((status) => onError({ status, code }));
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A media element that cannot load its source renders an EMPTY BOX and says
|
|
49
|
+
* nothing — no pixel changes, no message, nothing to act on. An expired
|
|
50
|
+
* file-serving token was indistinguishable from "the feature never recorded
|
|
51
|
+
* anything", for a recording that was intact on disk the whole time. Whatever
|
|
52
|
+
* the cause, a failed load has to be visible and retryable.
|
|
53
|
+
*/
|
|
54
|
+
if (failed) {
|
|
55
|
+
const notice = (
|
|
56
|
+
<View testID={testID ? `${testID}-error` : undefined} style={styles.error}>
|
|
57
|
+
<Icon name="circle-alert" size={20} color={colors.zinc["400"]} />
|
|
58
|
+
<Text size="sm" color="muted">
|
|
59
|
+
{l.mediaPlayer.loadFailed}
|
|
60
|
+
</Text>
|
|
61
|
+
<Button
|
|
62
|
+
title={l.errorState.retry}
|
|
63
|
+
color="secondary"
|
|
64
|
+
onPress={() => {
|
|
65
|
+
setFailedSrc(null);
|
|
66
|
+
setAttempt((n) => n + 1);
|
|
67
|
+
}}
|
|
68
|
+
/>
|
|
69
|
+
</View>
|
|
70
|
+
);
|
|
71
|
+
// Video fills a box the caller sized, so the notice has to fill it too or
|
|
72
|
+
// the frame half-empties on failure. The wrapper is a flex container for
|
|
73
|
+
// exactly that reason — measured, `flex: 1` inside the default block box
|
|
74
|
+
// left the notice 50px tall in a 206px frame.
|
|
75
|
+
return kind === "video" ? <div style={videoWrapperStyle}>{notice}</div> : notice;
|
|
76
|
+
}
|
|
21
77
|
|
|
22
78
|
if (kind === "audio") {
|
|
23
79
|
return (
|
|
24
80
|
<audio
|
|
81
|
+
key={attempt}
|
|
25
82
|
data-testid={testID}
|
|
26
83
|
aria-label={accessibilityLabel}
|
|
27
84
|
controls
|
|
28
85
|
preload="metadata"
|
|
29
86
|
src={src}
|
|
87
|
+
onError={(e) => handleError(e.currentTarget)}
|
|
30
88
|
style={audioStyle}
|
|
31
89
|
/>
|
|
32
90
|
);
|
|
@@ -44,21 +102,52 @@ export function MediaPlayer(props: MediaPlayerProps) {
|
|
|
44
102
|
return (
|
|
45
103
|
<div style={videoWrapperStyle}>
|
|
46
104
|
<video
|
|
105
|
+
key={attempt}
|
|
47
106
|
data-testid={testID}
|
|
48
107
|
aria-label={accessibilityLabel}
|
|
49
108
|
controls
|
|
50
109
|
preload="metadata"
|
|
51
110
|
src={src}
|
|
111
|
+
onError={(e) => handleError(e.currentTarget)}
|
|
52
112
|
style={videoStyle}
|
|
53
113
|
/>
|
|
54
114
|
</div>
|
|
55
115
|
);
|
|
56
116
|
}
|
|
57
117
|
|
|
118
|
+
const styles = StyleSheet.create({
|
|
119
|
+
error: {
|
|
120
|
+
flex: 1,
|
|
121
|
+
alignItems: "center",
|
|
122
|
+
justifyContent: "center",
|
|
123
|
+
gap: 8,
|
|
124
|
+
padding: 16,
|
|
125
|
+
borderRadius: 8,
|
|
126
|
+
backgroundColor: colors.zinc["100"],
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The status behind a failed load. A media element never exposes it — every
|
|
132
|
+
* transport failure arrives as the same opaque `MediaError` — so the only way to
|
|
133
|
+
* tell an expired token from a broken file is to ask again and read the code.
|
|
134
|
+
* Null means even that could not be determined (offline, or a source this
|
|
135
|
+
* origin may not read), which is itself the useful answer.
|
|
136
|
+
*/
|
|
137
|
+
async function resolveStatus(src: string): Promise<number | null> {
|
|
138
|
+
try {
|
|
139
|
+
const res = await fetch(src, { method: "HEAD", credentials: "include" });
|
|
140
|
+
return res.status;
|
|
141
|
+
} catch {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
58
146
|
// Inline rather than StyleSheet: these land on DOM elements, not RN views.
|
|
59
147
|
const audioStyle = { width: "100%" } as const;
|
|
60
148
|
const videoWrapperStyle = {
|
|
61
149
|
position: "relative",
|
|
150
|
+
display: "flex",
|
|
62
151
|
width: "100%",
|
|
63
152
|
height: "100%",
|
|
64
153
|
overflow: "hidden",
|
|
@@ -1,3 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Why a source would not play.
|
|
3
|
+
*
|
|
4
|
+
* The media element's own `MediaError` is nearly useless on its own: a 401, a
|
|
5
|
+
* 404 and a genuinely unplayable codec all surface as code 4
|
|
6
|
+
* (`SRC_NOT_SUPPORTED`). The STATUS is what separates "the session lapsed" from
|
|
7
|
+
* "this file is broken", so the player resolves it before reporting.
|
|
8
|
+
*/
|
|
9
|
+
export interface MediaLoadFailure {
|
|
10
|
+
/** HTTP status of the source, or null when it could not be determined. */
|
|
11
|
+
status: number | null;
|
|
12
|
+
/** `MediaError.code` — 1 aborted, 2 network, 3 decode, 4 unsupported. */
|
|
13
|
+
code: number | null;
|
|
14
|
+
}
|
|
15
|
+
|
|
1
16
|
export interface MediaPlayerProps {
|
|
2
17
|
/** Already-served URL for the media — signing/auth is the host's problem. */
|
|
3
18
|
src: string;
|
|
@@ -9,5 +24,12 @@ export interface MediaPlayerProps {
|
|
|
9
24
|
kind: "audio" | "video";
|
|
10
25
|
/** Accessible name for the player. Required: an unlabelled one is a blank control. */
|
|
11
26
|
accessibilityLabel: string;
|
|
27
|
+
/**
|
|
28
|
+
* Called once when a source fails to load. The player already SHOWS the
|
|
29
|
+
* failure; this is for the host to report it, because the kit carries no
|
|
30
|
+
* analytics of its own. Omit it and no status probe is performed — the work
|
|
31
|
+
* only happens when someone is listening.
|
|
32
|
+
*/
|
|
33
|
+
onError?: (failure: MediaLoadFailure) => void;
|
|
12
34
|
testID?: string;
|
|
13
35
|
}
|
package/src/popover.tsx
CHANGED
|
@@ -13,6 +13,7 @@ import { MIN_CONTROL_WIDTH } from "./control_surface";
|
|
|
13
13
|
import { IconButton } from "./icon_button";
|
|
14
14
|
import { Portal } from "./portal";
|
|
15
15
|
import { SizeBoundary } from "./size_boundary";
|
|
16
|
+
import { useScreenSize } from "./use_screen_size";
|
|
16
17
|
import { Divider } from "./divider";
|
|
17
18
|
import { useOverlayScope } from "./overlay_scope";
|
|
18
19
|
import {
|
|
@@ -209,8 +210,6 @@ export interface PopoverContentProps {
|
|
|
209
210
|
* both.
|
|
210
211
|
*/
|
|
211
212
|
width?: number;
|
|
212
|
-
/** When true, renders as bottom sheet on small screens (close button, slide-up animation) */
|
|
213
|
-
small?: boolean;
|
|
214
213
|
/** Accessible name for the bottom-sheet close button. Defaults to the locale's `overlay.close` ("Close" / "Đóng"). */
|
|
215
214
|
closeLabel?: string;
|
|
216
215
|
/** When true (default), the popover moves focus into its content on open and
|
|
@@ -230,10 +229,26 @@ export function PopoverContent(props: PopoverContentProps) {
|
|
|
230
229
|
contentContainerStyle,
|
|
231
230
|
disableBodyScroll,
|
|
232
231
|
width,
|
|
233
|
-
small = false,
|
|
234
232
|
closeLabel = locale.overlay.close,
|
|
235
233
|
manageFocus = true,
|
|
236
234
|
} = props;
|
|
235
|
+
// The VIEWPORT, never the container, and never a caller's opinion of either.
|
|
236
|
+
// A popover is an overlay: it fills the viewport regardless of the box it was
|
|
237
|
+
// opened from, so the only input to "anchored or bottom sheet" is how wide
|
|
238
|
+
// that viewport is — under `small` (<768) the sheet wins.
|
|
239
|
+
//
|
|
240
|
+
// This was a `small` prop and every caller answered it by hand. One answered
|
|
241
|
+
// with `useContainerSize()`, the width of whatever column held the trigger, so
|
|
242
|
+
// a narrow chat column on a desktop opened a full-bleed sheet; another passed
|
|
243
|
+
// nothing and never became a sheet on a real phone. Reading it here is the
|
|
244
|
+
// only way neither can be written again.
|
|
245
|
+
//
|
|
246
|
+
// Note this is the viewport and NOT "is this a phone": a browser side panel is
|
|
247
|
+
// a few hundred pixels of a desktop and gets the sheet too. That is the rule
|
|
248
|
+
// behaving as defined rather than a bug, but it is the reason a surface that
|
|
249
|
+
// wants a different presentation must change the RULE here, for everyone, and
|
|
250
|
+
// never re-introduce a per-caller override.
|
|
251
|
+
const { small } = useScreenSize();
|
|
237
252
|
const { open, onOpenChange, triggerRef, side, align, offset, inheritTriggerWidth } =
|
|
238
253
|
usePopoverContext();
|
|
239
254
|
|