@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 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. `useScreenSize()` answers a question about the DEVICE — is there a keyboard
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. Only the
1251
- `small` (bottom-sheet) presentation is modal (scrim). `PopoverContent` already insets its
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. Native renders
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 an overlay deciding whether it is a full-screen
1790
- sheet. Everything spatial wants `useContainerSize`.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "39.0.0",
3
+ "version": "40.2.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./vite": {
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" },
@@ -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