@lotics/ui 27.15.2 → 27.16.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 +8 -0
- package/docs/catalog.md +23 -7
- package/examples/tpl_record.tsx +2 -2
- package/package.json +3 -1
- package/src/agent_run_pane.tsx +2 -2
- package/src/animation_fade_in.tsx +18 -5
- package/src/container_size.tsx +63 -0
- package/src/date_filter.tsx +2 -2
- package/src/dialog.tsx +2 -1
- package/src/drawer.tsx +3 -2
- package/src/page_content.tsx +2 -2
- package/src/popover.tsx +6 -2
- package/src/size_boundary.tsx +65 -0
- package/src/text_utils.ts +4 -1
- package/src/use_screen_size.ts +12 -21
package/AGENTS.md
CHANGED
|
@@ -27,6 +27,14 @@ CURRENT major only — upgrading an app across majors is `MIGRATION.md`.
|
|
|
27
27
|
license to hand-roll.
|
|
28
28
|
- **One canonical component per data role** (member → `MemberChip`, select → `OptionBadge`,
|
|
29
29
|
files → `FilePreview` family, …) — the catalog's Reach-by-role outranks neighboring code.
|
|
30
|
+
- **Responsive layout measures the CONTAINER, not the screen.** `useContainerSize()` reports the
|
|
31
|
+
nearest `SizeBoundary`; wrap one around any region whose width stops tracking its parent's, and
|
|
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
|
|
34
|
+
worth a shortcut hint, are these touch targets — and reaching for it to decide SPACE is how a
|
|
35
|
+
panel a few hundred pixels wide lays its contents out for the whole display. (A component that
|
|
36
|
+
needs a number rather than a bucket — how many columns fit — measures its own box; `Table`
|
|
37
|
+
does exactly that.) → [catalog.md §Utility hooks](./docs/catalog.md).
|
|
30
38
|
- **A section's ADD rides its heading row, right edge** — Add files, Add fee, New line: a
|
|
31
39
|
`primary` `Button` beside `SectionHeadingTitle`, rendered empty or full. Under the rows it
|
|
32
40
|
extends, the verb MOVES with the row count and vanishes off-screen on a long list; a heading is
|
package/docs/catalog.md
CHANGED
|
@@ -615,9 +615,10 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
615
615
|
- **`dots_indicator`** — `DotsIndicator`: three looping bouncing dots (`size`/`color`); the
|
|
616
616
|
indeterminate "working/typing" pulse `Loading` composes; use bare beside a caption while
|
|
617
617
|
an agent thinks. Determinate work → `ProgressBar`.
|
|
618
|
-
- **`animation_fade_in`** — `AnimationFadeIn`: the mount transition — children fade
|
|
619
|
-
|
|
620
|
-
|
|
618
|
+
- **`animation_fade_in`** — `AnimationFadeIn`: the mount transition — children fade into
|
|
619
|
+
place on first render, once; the entrance polish Accordion/Timeline/Stepper/AgentRun
|
|
620
|
+
rows use. `translateY` adds the rise that reads as settling into place; `translateX`
|
|
621
|
+
the horizontal travel of a panel arriving from an edge (negative = from the left).
|
|
621
622
|
|
|
622
623
|
### Pickers & selection controls
|
|
623
624
|
|
|
@@ -1604,10 +1605,25 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
1604
1605
|
|
|
1605
1606
|
### Utility hooks & plumbing
|
|
1606
1607
|
|
|
1607
|
-
- **`
|
|
1608
|
-
`
|
|
1609
|
-
|
|
1610
|
-
|
|
1608
|
+
- **`size_boundary`** — `SizeBoundary` / `useContainerSize`: **reach for this, not
|
|
1609
|
+
`useScreenSize`, for anything rendered in flow.** `useContainerSize()` answers "how much
|
|
1610
|
+
room do *I* have" with the same `{ small, medium, large }` booleans, measured from the
|
|
1611
|
+
nearest enclosing `SizeBoundary` — so a table in a 600px panel lays itself out like a
|
|
1612
|
+
phone, because for its purposes it is one. Wrap a `SizeBoundary` around any region whose
|
|
1613
|
+
width stops tracking the window (beside a dockable panel, inside a resizable rail, around
|
|
1614
|
+
an embedded surface); `Dialog`, `Drawer` and a popover's body already are boundaries, so their
|
|
1615
|
+
contents get the panel's width rather than the region they were opened from. With no boundary in the tree the
|
|
1616
|
+
window is the container, which is the right answer for an app inside its own frame.
|
|
1617
|
+
- **`container_size`** — `deriveSizeBuckets` / `ContainerSizeProvider`: the thresholds and
|
|
1618
|
+
the react-native-free core `SizeBoundary` feeds a measured width into. Provide a width
|
|
1619
|
+
directly when you already have one and don't want a measuring wrapper.
|
|
1620
|
+
- **`use_screen_size`** — `useScreenSize` / `getScreenSize` / `calculateScreenSize`: the
|
|
1621
|
+
**window**, same buckets (small < 768 ≤ medium < 1728 ≤ large). Re-renders only when the
|
|
1622
|
+
width crosses a threshold — a height-only change (the mobile soft keyboard) and a resize
|
|
1623
|
+
inside one bucket both return the identical value. Correct only for
|
|
1624
|
+
questions about the device rather than the space: is there a keyboard to show a shortcut
|
|
1625
|
+
for, are these touch-sized targets, and an overlay deciding whether it is a full-screen
|
|
1626
|
+
sheet. Everything spatial wants `useContainerSize`.
|
|
1611
1627
|
- **`use_async_fn`** — `useAsyncFn`: wrap an async function into a manual-trigger mutation —
|
|
1612
1628
|
`[run, {loading, data, error}]`, unmount-safe, the error lands in state AND rethrows; the
|
|
1613
1629
|
pending-state engine for a submit/download/upload action.
|
package/examples/tpl_record.tsx
CHANGED
|
@@ -69,7 +69,7 @@ import { FloatingActionBar } from "@lotics/ui/floating_action_bar";
|
|
|
69
69
|
import { CardSelectItem } from "@lotics/ui/card_select_item";
|
|
70
70
|
import { AgentRun } from "@lotics/ui/agent_run";
|
|
71
71
|
import { FollowScroll } from "@lotics/ui/follow_scroll";
|
|
72
|
-
import {
|
|
72
|
+
import { useContainerSize } from "@lotics/ui/size_boundary";
|
|
73
73
|
import { type SourceRef } from "@lotics/ui/sources";
|
|
74
74
|
import { ChangeValueInput, Change, ChangeField, ChangeFields, ChangeReasoning, ChangeRecord, ChangeReview, ChangeReviewActions, ChangeReviewHeader, type ChangeStatus } from "@lotics/ui/change_review";
|
|
75
75
|
import { CompletionState } from "@lotics/ui/completion_state";
|
|
@@ -895,7 +895,7 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
|
|
|
895
895
|
const isDrawer = chrome === "drawer";
|
|
896
896
|
const id = useRef(1);
|
|
897
897
|
const nextId = (prefix: string) => `${prefix}_${(id.current += 1)}`;
|
|
898
|
-
const { small } =
|
|
898
|
+
const { small } = useContainerSize();
|
|
899
899
|
|
|
900
900
|
// ── first paint = Skeleton MIRRORING the final layout, never a spinner.
|
|
901
901
|
// The 700ms mock stands in for the record query a real app awaits.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lotics/ui",
|
|
3
|
-
"version": "27.
|
|
3
|
+
"version": "27.16.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
"./vite": {
|
|
@@ -158,6 +158,8 @@
|
|
|
158
158
|
"./fonts.css": "./src/fonts.css",
|
|
159
159
|
"./keyboard": "./src/keyboard.ts",
|
|
160
160
|
"./use_screen_size": "./src/use_screen_size.ts",
|
|
161
|
+
"./container_size": "./src/container_size.tsx",
|
|
162
|
+
"./size_boundary": "./src/size_boundary.tsx",
|
|
161
163
|
"./use_auto_grow_height": "./src/use_auto_grow_height.ts",
|
|
162
164
|
"./use_focus_ring": "./src/use_focus_ring.ts",
|
|
163
165
|
"./use_gated_press": "./src/use_gated_press.ts",
|
package/src/agent_run_pane.tsx
CHANGED
|
@@ -5,7 +5,7 @@ import type { AgentUIPart } from "./agent_transform";
|
|
|
5
5
|
import { ClarifyWizard, ClarifyWizardActions, ClarifyWizardScope, type ClarifyWizardAnswer } from "./clarify_wizard";
|
|
6
6
|
import { DialogScrollArea } from "./dialog";
|
|
7
7
|
import { FollowScroll } from "./follow_scroll";
|
|
8
|
-
import {
|
|
8
|
+
import { useContainerSize } from "@lotics/ui/size_boundary";
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* What this pair needs from a run — structurally the shape `useAgentRun()`
|
|
@@ -64,7 +64,7 @@ export interface AgentRunPaneProps {
|
|
|
64
64
|
*/
|
|
65
65
|
export function AgentRunPane(props: AgentRunPaneProps) {
|
|
66
66
|
const { run, labelForCall, renderToolOutput, onCancel } = props;
|
|
67
|
-
const { small } =
|
|
67
|
+
const { small } = useContainerSize();
|
|
68
68
|
const pending = run.pendingChoice;
|
|
69
69
|
|
|
70
70
|
// Mapped once per question set, not per render: the wizard keys its per-step
|
|
@@ -7,10 +7,14 @@ interface AnimationFadeInProps {
|
|
|
7
7
|
/** Rise this many px while fading in — the "appearing into place" gesture.
|
|
8
8
|
* Default 0 (fade only). */
|
|
9
9
|
translateY?: number;
|
|
10
|
+
/** Travel this many px horizontally while fading in — negative enters from the
|
|
11
|
+
* left, positive from the right. The gesture for a panel arriving from an
|
|
12
|
+
* edge rather than settling into place. Default 0. */
|
|
13
|
+
translateX?: number;
|
|
10
14
|
}
|
|
11
15
|
|
|
12
16
|
export function AnimationFadeIn(props: AnimationFadeInProps) {
|
|
13
|
-
const { children, style, translateY = 0 } = props;
|
|
17
|
+
const { children, style, translateY = 0, translateX = 0 } = props;
|
|
14
18
|
const progress = useRef(new Animated.Value(0)).current;
|
|
15
19
|
|
|
16
20
|
useEffect(() => {
|
|
@@ -21,9 +25,18 @@ export function AnimationFadeIn(props: AnimationFadeInProps) {
|
|
|
21
25
|
}).start();
|
|
22
26
|
}, [progress]);
|
|
23
27
|
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
28
|
+
const offset = (from: number) =>
|
|
29
|
+
progress.interpolate({ inputRange: [0, 1], outputRange: [from, 0] });
|
|
30
|
+
const transform = [
|
|
31
|
+
...(translateX ? [{ translateX: offset(translateX) }] : []),
|
|
32
|
+
...(translateY ? [{ translateY: offset(translateY) }] : []),
|
|
33
|
+
];
|
|
27
34
|
|
|
28
|
-
return
|
|
35
|
+
return (
|
|
36
|
+
<Animated.View
|
|
37
|
+
style={[{ opacity: progress, transform: transform.length ? transform : undefined }, style]}
|
|
38
|
+
>
|
|
39
|
+
{children}
|
|
40
|
+
</Animated.View>
|
|
41
|
+
);
|
|
29
42
|
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { createContext, useContext, useMemo, type ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The one set of width thresholds in the kit. `use_screen_size` measures the
|
|
5
|
+
* window against them and a `SizeBoundary` measures a box, so a 400px panel on a
|
|
6
|
+
* 4K display reads exactly like a phone — which is the point.
|
|
7
|
+
*/
|
|
8
|
+
const breakpoints = {
|
|
9
|
+
/** Below this, a box is phone-shaped: stack, and prefer sheets to popovers. */
|
|
10
|
+
small: 768,
|
|
11
|
+
/** At or above this, a box has room to spend on density. */
|
|
12
|
+
large: 1728,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export interface SizeBuckets {
|
|
16
|
+
/** For most mobile phones — or any box that narrow. */
|
|
17
|
+
small: boolean;
|
|
18
|
+
/** iPad Mini through a 16" MacBook Pro — or any box that wide. */
|
|
19
|
+
medium: boolean;
|
|
20
|
+
/** Most external monitors — or any box that wide. */
|
|
21
|
+
large: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function deriveSizeBuckets(width: number): SizeBuckets {
|
|
25
|
+
return {
|
|
26
|
+
small: width < breakpoints.small,
|
|
27
|
+
medium: width >= breakpoints.small && width < breakpoints.large,
|
|
28
|
+
large: width >= breakpoints.large,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Null means no enclosing boundary — the window is the container. */
|
|
33
|
+
export const ContainerSizeContext = createContext<SizeBuckets | null>(null);
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Publishes a measured width to descendants as buckets.
|
|
37
|
+
*
|
|
38
|
+
* The value is memoized on the buckets, not the width, so a resize that does not
|
|
39
|
+
* cross a threshold produces no new value and re-renders no consumer. That
|
|
40
|
+
* matters because a subtree under one boundary can hold dozens of readers, and a
|
|
41
|
+
* drag on a resizable panel emits a measurement per frame.
|
|
42
|
+
*
|
|
43
|
+
* Kept free of `react-native` imports so it is unit-testable; the measuring
|
|
44
|
+
* wrapper that feeds it a width lives in `size_boundary`.
|
|
45
|
+
*/
|
|
46
|
+
export function ContainerSizeProvider(props: { width: number; children: ReactNode }) {
|
|
47
|
+
const { width, children } = props;
|
|
48
|
+
// The two booleans, not the width, are the dependencies — that is what keeps
|
|
49
|
+
// the published value identical across a resize inside one bucket.
|
|
50
|
+
const small = width < breakpoints.small;
|
|
51
|
+
const large = width >= breakpoints.large;
|
|
52
|
+
const value = useMemo<SizeBuckets>(
|
|
53
|
+
() => ({ small, medium: !small && !large, large }),
|
|
54
|
+
[small, large],
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
return <ContainerSizeContext.Provider value={value}>{children}</ContainerSizeContext.Provider>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The nearest boundary's buckets, or null when nothing encloses this component. */
|
|
61
|
+
export function useEnclosingContainerSize(): SizeBuckets | null {
|
|
62
|
+
return useContext(ContainerSizeContext);
|
|
63
|
+
}
|
package/src/date_filter.tsx
CHANGED
|
@@ -6,7 +6,7 @@ import { CONTROL_RADIUS } from "./control_surface";
|
|
|
6
6
|
import { MenuButton } from "./menu_button";
|
|
7
7
|
import { Calendar, CalendarRangeValue, CalendarRef } from "./date_calendar";
|
|
8
8
|
import { TimePicker } from "./time_picker";
|
|
9
|
-
import {
|
|
9
|
+
import { useContainerSize } from "./size_boundary";
|
|
10
10
|
import { SegmentLabels } from "./date_segments";
|
|
11
11
|
import { PresetId, PRESET_IDS, getPresetValue } from "./date_filter_presets";
|
|
12
12
|
import { formatDate } from "./format_date";
|
|
@@ -134,7 +134,7 @@ export function DateFilter(props: DateFilterProps) {
|
|
|
134
134
|
() => ({ ...loc, ...props.labels }),
|
|
135
135
|
[loc, props.labels],
|
|
136
136
|
);
|
|
137
|
-
const screenSize =
|
|
137
|
+
const screenSize = useContainerSize();
|
|
138
138
|
const calendarRef = useRef<CalendarRef>(null);
|
|
139
139
|
|
|
140
140
|
// A half-picked range is held HERE and never emitted — the same rule
|
package/src/dialog.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import React, { createContext, useCallback, useContext, useMemo, useState } from "react";
|
|
2
2
|
import { Animated, Modal, StyleSheet, View, ScrollView } from "react-native";
|
|
3
3
|
import { useScreenSize } from "@lotics/ui/use_screen_size";
|
|
4
|
+
import { SizeBoundary } from "@lotics/ui/size_boundary";
|
|
4
5
|
import { PortalHost } from "@lotics/ui/portal";
|
|
5
6
|
import { colors } from "@lotics/ui/colors";
|
|
6
7
|
import { IconButton } from "@lotics/ui/icon_button";
|
|
@@ -167,7 +168,7 @@ export function Dialog(props: DialogProps) {
|
|
|
167
168
|
<View style={[styles.closeButtonContainer, { paddingHorizontal: screenSize.small ? 16 : 24 }]}>
|
|
168
169
|
<IconButton icon="x" size="lg" accessibilityLabel={locale.overlay.close} onPress={handleClose} />
|
|
169
170
|
</View>
|
|
170
|
-
<
|
|
171
|
+
<SizeBoundary style={styles.container}>{children}</SizeBoundary>
|
|
171
172
|
</View>
|
|
172
173
|
</PortalHost>
|
|
173
174
|
</Animated.View>
|
package/src/drawer.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { ReactNode, useEffect } from "react";
|
|
2
2
|
import { Modal, Pressable, StyleSheet, View } from "react-native";
|
|
3
3
|
import { useScreenSize } from "@lotics/ui/use_screen_size";
|
|
4
|
+
import { SizeBoundary } from "@lotics/ui/size_boundary";
|
|
4
5
|
import { PortalHost } from "@lotics/ui/portal";
|
|
5
6
|
import { colors } from "@lotics/ui/colors";
|
|
6
7
|
import { IconButton } from "@lotics/ui/icon_button";
|
|
@@ -123,9 +124,9 @@ export function Drawer(props: DrawerProps) {
|
|
|
123
124
|
) : null}
|
|
124
125
|
<IconButton icon="x" size="lg" accessibilityLabel={loc.close} onPress={handleClose} />
|
|
125
126
|
</View>
|
|
126
|
-
<
|
|
127
|
+
<SizeBoundary testID={testID} style={styles.body}>
|
|
127
128
|
{children}
|
|
128
|
-
</
|
|
129
|
+
</SizeBoundary>
|
|
129
130
|
</PortalHost>
|
|
130
131
|
</View>
|
|
131
132
|
</View>
|
package/src/page_content.tsx
CHANGED
|
@@ -3,7 +3,7 @@ import { Text } from "@lotics/ui/text";
|
|
|
3
3
|
import { colors } from "@lotics/ui/colors";
|
|
4
4
|
import { Spacer } from "@lotics/ui/spacer";
|
|
5
5
|
import { ReactNode } from "react";
|
|
6
|
-
import {
|
|
6
|
+
import { useContainerSize } from "@lotics/ui/size_boundary";
|
|
7
7
|
|
|
8
8
|
interface PageContentProps {
|
|
9
9
|
children: ReactNode;
|
|
@@ -23,7 +23,7 @@ interface PageContentProps {
|
|
|
23
23
|
*/
|
|
24
24
|
export function PageContent(props: PageContentProps) {
|
|
25
25
|
const { children, title, titleRight, description, header, footer, size, fullscreen } = props;
|
|
26
|
-
const screenSize =
|
|
26
|
+
const screenSize = useContainerSize();
|
|
27
27
|
|
|
28
28
|
const maxWidth = !screenSize.small && size ? PAGE_SIZES[size] : undefined;
|
|
29
29
|
|
package/src/popover.tsx
CHANGED
|
@@ -12,6 +12,7 @@ import { colors } from "./colors";
|
|
|
12
12
|
import { MIN_CONTROL_WIDTH } from "./control_surface";
|
|
13
13
|
import { IconButton } from "./icon_button";
|
|
14
14
|
import { Portal } from "./portal";
|
|
15
|
+
import { SizeBoundary } from "./size_boundary";
|
|
15
16
|
import { Divider } from "./divider";
|
|
16
17
|
import { useOverlayScope } from "./overlay_scope";
|
|
17
18
|
import {
|
|
@@ -714,7 +715,7 @@ export function PopoverContent(props: PopoverContentProps) {
|
|
|
714
715
|
)}
|
|
715
716
|
{header}
|
|
716
717
|
{disableBodyScroll ? (
|
|
717
|
-
<
|
|
718
|
+
<SizeBoundary style={style}>{bodyChildren}</SizeBoundary>
|
|
718
719
|
) : (
|
|
719
720
|
// FULL-BLEED HORIZONTALLY, then re-inset by the same 12. The panel
|
|
720
721
|
// pads all four sides, so a scroller sitting inside that padding
|
|
@@ -731,7 +732,10 @@ export function PopoverContent(props: PopoverContentProps) {
|
|
|
731
732
|
style={[SCROLL_BODY, style]}
|
|
732
733
|
contentContainerStyle={[SCROLL_BODY_CONTENT, contentContainerStyle]}
|
|
733
734
|
>
|
|
734
|
-
{
|
|
735
|
+
{/* A popover is a box of its own — a few hundred px — so its contents
|
|
736
|
+
size to the panel rather than to whatever region it was opened
|
|
737
|
+
from. Both body paths get it; one of them is not a boundary. */}
|
|
738
|
+
<SizeBoundary>{bodyChildren}</SizeBoundary>
|
|
735
739
|
</ScrollView>
|
|
736
740
|
)}
|
|
737
741
|
{footer}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { useCallback, useState, type ReactNode } from "react";
|
|
2
|
+
import { View, type LayoutChangeEvent, type StyleProp, type ViewStyle } from "react-native";
|
|
3
|
+
import { ContainerSizeProvider, useEnclosingContainerSize, type SizeBuckets } from "./container_size";
|
|
4
|
+
import { useScreenSize } from "./use_screen_size";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Declares the box that descendants should size themselves against.
|
|
8
|
+
*
|
|
9
|
+
* Mount one wherever a region's width stops tracking the window — beside a
|
|
10
|
+
* dockable panel, inside a resizable rail, around an embedded surface — and
|
|
11
|
+
* never per component. A component under it asks `useContainerSize()` and gets
|
|
12
|
+
* the box it is actually in, so a table in a 600px column stops laying itself
|
|
13
|
+
* out as though it had the whole 1600px display.
|
|
14
|
+
*
|
|
15
|
+
* Until the first `onLayout` it reports the WINDOW's bucket, so the opening paint
|
|
16
|
+
* matches what the same tree rendered before any boundary existed. Seeding at
|
|
17
|
+
* zero instead would start every mount at "phone" and flash the stacked layout on
|
|
18
|
+
* the way out of it.
|
|
19
|
+
*/
|
|
20
|
+
export function SizeBoundary(props: {
|
|
21
|
+
children: ReactNode;
|
|
22
|
+
style?: StyleProp<ViewStyle>;
|
|
23
|
+
testID?: string;
|
|
24
|
+
}) {
|
|
25
|
+
const { children, style, testID } = props;
|
|
26
|
+
const screen = useScreenSize();
|
|
27
|
+
const [width, setWidth] = useState<number | null>(null);
|
|
28
|
+
|
|
29
|
+
const handleLayout = useCallback((event: LayoutChangeEvent) => {
|
|
30
|
+
setWidth(event.nativeEvent.layout.width);
|
|
31
|
+
}, []);
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<View onLayout={handleLayout} style={style} testID={testID}>
|
|
35
|
+
<ContainerSizeProvider width={width ?? screenSeedWidth(screen)}>
|
|
36
|
+
{children}
|
|
37
|
+
</ContainerSizeProvider>
|
|
38
|
+
</View>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A width that lands in the same bucket the window is in, for the first frame. */
|
|
43
|
+
function screenSeedWidth(screen: SizeBuckets): number {
|
|
44
|
+
if (screen.small) return 0;
|
|
45
|
+
if (screen.large) return 1728;
|
|
46
|
+
return 768;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* How much room this component actually has: the nearest `SizeBoundary`, or the
|
|
51
|
+
* window when nothing encloses it.
|
|
52
|
+
*
|
|
53
|
+
* Reach for this over `useScreenSize` for anything rendered *in flow*. The window
|
|
54
|
+
* is the right question only for chrome that spans it (is this a phone?) and for
|
|
55
|
+
* overlays, which fill the viewport regardless of what they were opened from.
|
|
56
|
+
*
|
|
57
|
+
* Falling back to the window is not a stand-in for a missing value — with no
|
|
58
|
+
* boundary in the tree the window IS the container, which is also why an app
|
|
59
|
+
* embedded in its own frame gets the right answer without mounting anything.
|
|
60
|
+
*/
|
|
61
|
+
export function useContainerSize(): SizeBuckets {
|
|
62
|
+
const enclosing = useEnclosingContainerSize();
|
|
63
|
+
const screen = useScreenSize();
|
|
64
|
+
return enclosing ?? screen;
|
|
65
|
+
}
|
package/src/text_utils.ts
CHANGED
|
@@ -132,7 +132,10 @@ export function getInputTextStyle(): TextStyle {
|
|
|
132
132
|
* Returns the input line height for layout calculations.
|
|
133
133
|
* Use this when you need to calculate heights for multiline inputs.
|
|
134
134
|
*
|
|
135
|
-
* @param isSmallScreen - Whether the
|
|
135
|
+
* @param isSmallScreen - Whether the WINDOW is small (a phone). Input metrics are
|
|
136
|
+
* a touch-target question, so this comes from `useScreenSize().small` — never
|
|
137
|
+
* from the enclosing container, or a text field in a narrow desktop panel would
|
|
138
|
+
* get phone line heights.
|
|
136
139
|
*/
|
|
137
140
|
/** {@link multilineInputHeight} at this platform's input line height. */
|
|
138
141
|
export function getMultilineInputHeight(numberOfLines: number, isSmallScreen: boolean): number {
|
package/src/use_screen_size.ts
CHANGED
|
@@ -1,13 +1,6 @@
|
|
|
1
1
|
import { useEffect, useState } from "react";
|
|
2
2
|
import { Dimensions } from "react-native";
|
|
3
|
-
|
|
4
|
-
const breakpoints = {
|
|
5
|
-
// small (mobile)
|
|
6
|
-
small: 768,
|
|
7
|
-
// medium (tablet and laptop)
|
|
8
|
-
large: 1728,
|
|
9
|
-
// large (external monitor)
|
|
10
|
-
};
|
|
3
|
+
import { deriveSizeBuckets } from "./container_size";
|
|
11
4
|
|
|
12
5
|
export function useScreenSize() {
|
|
13
6
|
// NOTE: DO NOT USE `useWindowDimensions` from `react-native`. On mobile browsers, when the soft keyboard appears, the window.innerHeight or Dimensions can change.
|
|
@@ -28,7 +21,14 @@ export function useScreenSize() {
|
|
|
28
21
|
// This prevents re-renders when keyboards appear/disappear
|
|
29
22
|
if (currentWidth !== lastKnownWidth) {
|
|
30
23
|
lastKnownWidth = currentWidth; // Update the tracked width
|
|
31
|
-
|
|
24
|
+
// ...and only when the width crosses a THRESHOLD. A new object per pixel
|
|
25
|
+
// re-renders every consumer through a whole drag for a value that did not
|
|
26
|
+
// change, and `useContainerSize` calls this on every one of its consumers
|
|
27
|
+
// to cover the no-boundary case — so the waste multiplies.
|
|
28
|
+
setSize((prev) => {
|
|
29
|
+
const next = calculateScreenSize(screen);
|
|
30
|
+
return next.small === prev.small && next.large === prev.large ? prev : next;
|
|
31
|
+
});
|
|
32
32
|
}
|
|
33
33
|
});
|
|
34
34
|
|
|
@@ -41,19 +41,10 @@ export function useScreenSize() {
|
|
|
41
41
|
return size;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
/** The window's buckets. `SizeBoundary` measures a box against the same
|
|
45
|
+
* thresholds — they are defined once, in `container_size`. */
|
|
44
46
|
export function calculateScreenSize(dimensions: { width: number }) {
|
|
45
|
-
|
|
46
|
-
const medium = dimensions.width >= breakpoints.small && dimensions.width < breakpoints.large;
|
|
47
|
-
const large = dimensions.width >= breakpoints.large;
|
|
48
|
-
|
|
49
|
-
return {
|
|
50
|
-
/** For most mobile phones */
|
|
51
|
-
small,
|
|
52
|
-
/** For tablet and laptop displays ranging from iPad Mini to 16' MacBook Pro */
|
|
53
|
-
medium,
|
|
54
|
-
/** For most external monitors */
|
|
55
|
-
large,
|
|
56
|
-
};
|
|
47
|
+
return deriveSizeBuckets(dimensions.width);
|
|
57
48
|
}
|
|
58
49
|
|
|
59
50
|
export function getScreenSize() {
|