@delacour/react-native-ui 0.5.0-alpha.20261022200024 → 0.5.0-alpha.20261023075140

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@delacour/react-native-ui",
3
- "version": "0.5.0-alpha.20261022200024",
3
+ "version": "0.5.0-alpha.20261023075140",
4
4
  "description": "React Native UI components — Uniwind, Reanimated, Gesture Handler",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -182,8 +182,8 @@
182
182
  "expo-blur": ">=15",
183
183
  "@maplibre/maplibre-react-native": "^11.5.0",
184
184
  "@central-icons-react-native/round-outlined-radius-1-stroke-1.5": "^1.1",
185
- "@delacour/react-native-bottom-sheet": ">=0.2.0",
186
- "@delacour/react-native-charts": "0.2.0-alpha.20261022200024",
185
+ "@delacour/react-native-bottom-sheet": "0.3.0-alpha.20261023075140",
186
+ "@delacour/react-native-charts": "0.2.0-alpha.20261023075140",
187
187
  "@legendapp/list": ">=3.3",
188
188
  "react-native-skia": ">=3.7.3",
189
189
  "expo-linear-gradient": ">=15",
@@ -84,8 +84,11 @@ they are unit-testable.
84
84
  - `bun test packages/react-native-ui/src/components/direction` — the resolvers.
85
85
  <!-- /registry:omit -->
86
86
  - Simulator, 2026-10-05, via warden + agent-device on the toggle demo: before the toggle,
87
- `dir-step-one` at x 37; after, at x 261 and `dir-step-three` at x 37. The nested LTR row
88
- (`dir-fixed-1..3`) kept x 37 / 149 / 261 in both states.
87
+ `dir-step-one` at x 37; after, at x 261 and `dir-step-three` at x 37. The demo then also held a
88
+ nested LTR number row that kept x 37 / 149 / 261 in both states; it was dropped from `toggle`
89
+ (2026-10-22) because a row that stays put while the card flips reads as broken in the clip.
90
+ Nesting is shown by `nested-override`, and the number row (`dir-number-1..3`) now mirrors with
91
+ the card.
89
92
  - Previews captured 2026-10-06 on an iPhone 17 Pro dev client (`--dev`): `toggle` is the hero clip
90
93
  (flow `direction/toggle`, flips to RTL and back so it loops); `mirrored-rows`, `nested-override` and
91
94
  `uniwind-variants` are stills. All four are `align: "stretch"`.
@@ -119,6 +119,13 @@ or drops its focus.
119
119
  - **A block makes the box a floor, not a height.** With one present the box
120
120
  takes the multiline branch (min height + vertical padding) whatever the field
121
121
  is; the row keeps its own alignment, so a single-line field stays centred.
122
+ - **A block bleeds through the box's vertical padding on its outer edge.** That
123
+ padding is the field's. Stacked on the block's own `py-*`, it set a toolbar
124
+ half again as far from the border as from its separator and left the same
125
+ slack under a footer's counter. The block takes a `-mt-*` / `-mb-*` equal to
126
+ the multiline `py-*` (the `blockPosition` compounds), the way a horizontal
127
+ `Separator` bleeds through the horizontal inset, so its own padding is all
128
+ there is on either side.
122
129
  - **A `Block` draws no border.** Divide it from the row with a `Separator`, or
123
130
  leave it flush. Bare text and `Icon`s in it take the decorator treatment.
124
131
  - **`Separator` has no `orientation` prop.** `InputGroupPlacementProvider` tells
@@ -38,10 +38,10 @@ export type InputGroupBlockProps = {
38
38
  * </Input.Group.Block>
39
39
  * </Input.Group>
40
40
  */
41
- export function InputGroupBlock({ className, children }: InputGroupBlockProps): ReactElement {
41
+ export function InputGroupBlock({ position, className, children }: InputGroupBlockProps): ReactElement {
42
42
  const { isInvalid, size } = useInputGroupPart("Input.Group.Block");
43
43
  const placement = useInputGroupPlacement();
44
- const slots = inputVariants({ isInvalid, size });
44
+ const slots = inputVariants({ blockPosition: position, isInvalid, size });
45
45
 
46
46
  if (process.env.NODE_ENV !== "production" && placement === "row") {
47
47
  console.warn(
@@ -496,6 +496,21 @@ describe("a block", () => {
496
496
  expect(inputVariants().block()).toContain("flex-row");
497
497
  });
498
498
 
499
+ // The box's vertical padding is the field's. Stacked on a block's own, it
500
+ // left a toolbar half again as far from the border as from its separator,
501
+ // and the same slack under a footer. A block bleeds out by exactly that
502
+ // padding on its outer edge, so its own padding is all there is either side.
503
+ test("bleeds out through the box's vertical padding on its outer edge", () => {
504
+ for (const size of INPUT_SIZES) {
505
+ const padding = resolveInputGroupClass({ hasBlocks: true, size }).match(/\bpy-([\d.]+)\b/)?.[1];
506
+ expect(padding).toBeDefined();
507
+ expect(inputVariants({ blockPosition: "start", size }).block()).toMatch(new RegExp(`(?:^|\\s)-mt-${padding}\\b`));
508
+ expect(inputVariants({ blockPosition: "end", size }).block()).toMatch(new RegExp(`(?:^|\\s)-mb-${padding}\\b`));
509
+ expect(inputVariants({ blockPosition: "start", size }).block()).not.toMatch(/-mb-/);
510
+ expect(inputVariants({ blockPosition: "end", size }).block()).not.toMatch(/-mt-/);
511
+ }
512
+ });
513
+
499
514
  test("carries no raw palette colour and no chrome", () => {
500
515
  for (const state of everyBoxState()) {
501
516
  const block = inputVariants(state).block();
@@ -140,6 +140,11 @@ export const inputVariants = tv({
140
140
  row: { separator: "my-2 w-px self-stretch" },
141
141
  box: { separator: "h-px self-stretch" },
142
142
  },
143
+ /**
144
+ * Which edge of the box a block sits on. `none` is everything that is
145
+ * not a block.
146
+ */
147
+ blockPosition: { none: {}, start: {}, end: {} },
143
148
  isDisabled: { true: { root: "opacity-50" }, false: {} },
144
149
  isMultiline: { true: {}, false: {} },
145
150
  // A grouped field shares its row, so it stretches — and it clears React
@@ -190,6 +195,18 @@ export const inputVariants = tv({
190
195
  { placement: "box", size: "sm", class: { separator: "-mx-2.5" } },
191
196
  { placement: "box", size: "md", class: { separator: "-mx-3" } },
192
197
  { placement: "box", size: "lg", class: { separator: "-mx-3.5" } },
198
+ // A block bleeds out through the box's vertical padding on its outer
199
+ // edge, the way a horizontal hairline bleeds through the horizontal. That
200
+ // padding is the field's breathing room; stacked on the block's own it
201
+ // set a toolbar half again as far from the border as from its separator,
202
+ // and left the same slack under a footer. Each value is the multiline
203
+ // `py-*` above, so the block's own padding is all there is either side.
204
+ { blockPosition: "start", size: "sm", class: { block: "-mt-2" } },
205
+ { blockPosition: "start", size: "md", class: { block: "-mt-2.5" } },
206
+ { blockPosition: "start", size: "lg", class: { block: "-mt-3" } },
207
+ { blockPosition: "end", size: "sm", class: { block: "-mb-2" } },
208
+ { blockPosition: "end", size: "md", class: { block: "-mb-2.5" } },
209
+ { blockPosition: "end", size: "lg", class: { block: "-mb-3" } },
193
210
  // The corner a lone field draws: the generic ramp, deliberately not the
194
211
  // button's, because a field and the button beside it are meant to be
195
212
  // retunable apart. `sm` steps down a notch; the other two share `lg`.
@@ -288,6 +305,7 @@ export const inputVariants = tv({
288
305
  isMultiline: false,
289
306
  isGrouped: false,
290
307
  placement: "row",
308
+ blockPosition: "none",
291
309
  orientation: "horizontal",
292
310
  groupPosition: "none",
293
311
  isSeamed: false,
@@ -99,7 +99,11 @@ parts.
99
99
  on `bg-card` and its `secondary` filled `bg-secondary`, and its `etched` the same
100
100
  card fill with the highlight set into its edge; the preview draws the same
101
101
  three — a test reads `inputVariants` and fails if they part —
102
- so flipping modes never changes what kind of box is on screen. On the
102
+ so flipping modes never changes what kind of box is on screen. Its corners
103
+ are the write pane's too — read from `inputVariants` per size — and it hands
104
+ its box to `resolveMarkdownPaneClass` as `base`, merged before the `bar`'s
105
+ `rounded-t-none`; passed as a className, its own `rounded-lg` once won and
106
+ the preview grew round top corners the write pane did not have. On the
103
107
  filled one an inline code run lifts to `bg-card`, or `Text.Code`'s muted run
104
108
  would vanish into it. Floored at the write pane's height
105
109
  (`resolveTextareaRowsHeight`) so flipping does not jump the screen. It is a
@@ -39,13 +39,14 @@ export function MarkdownEditorPreview({
39
39
 
40
40
  if (editor.mode !== "preview") return null;
41
41
 
42
- const slots = markdownEditorVariants({ variant: editor.variant });
42
+ const slots = markdownEditorVariants({ size: editor.size, variant: editor.variant });
43
43
 
44
44
  return (
45
45
  <Animated.View
46
46
  accessibilityLabel="Preview"
47
47
  className={resolveMarkdownPaneClass({
48
- className: [slots.preview(), className].join(" "),
48
+ base: slots.preview(),
49
+ className,
49
50
  isAttached: editor.toolbarVariant === "bar",
50
51
  })}
51
52
  entering={isCalm ? undefined : FadeIn.duration(PREVIEW_FADE_MS)}
@@ -143,6 +143,23 @@ describe("panes", () => {
143
143
  expect(resolveMarkdownPaneClass({ className: "rounded-t-xl", isAttached: true })).not.toContain("rounded-t-none");
144
144
  });
145
145
 
146
+ test("the preview's corners are the write pane's at every size", () => {
147
+ const radius = (cls: string | undefined): string[] =>
148
+ (cls ?? "").split(/\s+/).filter((c) => c.startsWith("rounded"));
149
+ for (const size of MARKDOWN_EDITOR_SIZES) {
150
+ const field = radius(inputVariants({ isMultiline: true, size }).root());
151
+ expect(radius(markdownEditorVariants({ size }).preview())).toEqual(field);
152
+ }
153
+ });
154
+
155
+ test("a preview under a bar toolbar loses its top corners too", () => {
156
+ for (const size of MARKDOWN_EDITOR_SIZES) {
157
+ const base = markdownEditorVariants({ size }).preview();
158
+ expect(resolveMarkdownPaneClass({ base, isAttached: true })).toContain("rounded-t-none");
159
+ expect(resolveMarkdownPaneClass({ base, isAttached: false })).not.toContain("rounded-t-none");
160
+ }
161
+ });
162
+
146
163
  test("the write pane is monospaced", () => {
147
164
  expect(markdownEditorVariants().input()).toContain("font-mono");
148
165
  });
@@ -25,6 +25,18 @@ function inputSurface(variant: InputVariant): string {
25
25
  .join(" ");
26
26
  }
27
27
 
28
+ /**
29
+ * The corner utilities a multiline Input draws at a size, read from
30
+ * `inputVariants` itself so the preview's corners follow the write pane's.
31
+ */
32
+ function inputRadius(size: InputSize): string {
33
+ return inputVariants({ isMultiline: true, size })
34
+ .root()
35
+ .split(/\s+/)
36
+ .filter((cls) => cls.startsWith("rounded"))
37
+ .join(" ");
38
+ }
39
+
28
40
  export const MARKDOWN_EDITOR_VARIANTS = INPUT_VARIANTS;
29
41
  export const MARKDOWN_TOOLBAR_VARIANTS = ["bar", "pill"] as const;
30
42
 
@@ -85,6 +97,10 @@ export const MARKDOWN_EDITOR_TABS_SIZE: Record<MarkdownEditorSize, TabsSize> = {
85
97
  * flipping modes does not change what kind of box is on screen: outlined on
86
98
  * `bg-card` for `primary`, filled `bg-secondary` for `secondary`.
87
99
  *
100
+ * Its corners are the write pane's too, per size, and it takes its box as
101
+ * `base` in {@link resolveMarkdownPaneClass} rather than as a className, so a
102
+ * `bar` toolbar's square top corners are not overridden by its own radius.
103
+ *
88
104
  * `inlineCode` lifts `Text.Code`'s muted run to `bg-card` on the filled
89
105
  * `secondary` preview, where muted on secondary would draw no run at all.
90
106
  *
@@ -107,7 +123,7 @@ export const markdownEditorVariants = tv({
107
123
  actionRow: "flex-row items-center",
108
124
  input: "font-mono",
109
125
  pane: "",
110
- preview: "gap-3 rounded-lg px-3 py-3",
126
+ preview: "gap-3 px-3 py-3",
111
127
  quote: "gap-2 border-l-2 border-border pl-3",
112
128
  codeBlock: "rounded-md border border-border bg-card p-3",
113
129
  listItem: "flex-row gap-2",
@@ -117,9 +133,9 @@ export const markdownEditorVariants = tv({
117
133
  },
118
134
  variants: {
119
135
  size: {
120
- sm: { modeSwitch: "w-40" },
121
- md: { modeSwitch: "w-52" },
122
- lg: { modeSwitch: "w-60" },
136
+ sm: { modeSwitch: "w-40", preview: inputRadius("sm") },
137
+ md: { modeSwitch: "w-52", preview: inputRadius("md") },
138
+ lg: { modeSwitch: "w-60", preview: inputRadius("lg") },
123
139
  },
124
140
  toolbar: {
125
141
  bar: {
@@ -153,16 +169,20 @@ export type MarkdownEditorVariantProps = VariantProps<typeof markdownEditorVaria
153
169
  * The class for a pane — the write field or the preview box.
154
170
  *
155
171
  * Under a `bar` toolbar the pane drops its top corners so the two read as one
156
- * box, the bar its lid. A caller's className is merged last and still wins.
172
+ * box, the bar its lid. `base` is the pane's own box, merged first so its
173
+ * radius cannot win over the lid's square corners. A caller's className is
174
+ * merged last and still wins.
157
175
  */
158
176
  export function resolveMarkdownPaneClass({
159
177
  isAttached,
178
+ base,
160
179
  className,
161
180
  }: {
162
181
  isAttached: boolean;
182
+ base?: string;
163
183
  className?: string;
164
184
  }): string {
165
- return markdownEditorVariants().pane({ class: [isAttached ? "rounded-t-none" : undefined, className] }) ?? "";
185
+ return markdownEditorVariants().pane({ class: [base, isAttached ? "rounded-t-none" : undefined, className] }) ?? "";
166
186
  }
167
187
 
168
188
  /**
@@ -82,14 +82,16 @@ engine. An app that imports no picker still never resolves it.
82
82
  the sheet `maxDynamicContentSize` — the lower of 85% of the window and the room
83
83
  below the top inset less a 24pt gap — and the list scrolls inside it. Without
84
84
  the cap two hundred options measured taller than the screen and the sheet rose
85
- under the status bar with nothing of the page left above it. An open
86
- keyboard comes off the cap too — the engine lifts the sheet above it, and a
87
- full list plus the keyboard reached the top of the screen. The height arrives
88
- on the JS thread from `keyboardWillShow` while the lift runs on the UI thread,
89
- so for about a quarter of a second the sheet still overshoots before settling;
90
- removing that needs the engine to clamp its keyboard lift to
91
- `maxDynamicContentSize`. `snapPoints` bypasses dynamic sizing, and the cap
92
- with it.
85
+ under the status bar with nothing of the page left above it.
86
+ - **The keyboard never lifts the sheet past that cap.** The root passes
87
+ `keyboardLiftLimit="maxDynamicContentSize"`, and the engine clamps its lift
88
+ on the UI thread: a short list rides the keyboard up until its top reaches
89
+ the cap, and a full one holds still while the keyboard covers its bottom and
90
+ the list shrinks by it. It used to shrink the cap itself from
91
+ `keyboardWillShow` — a JS event that lands after the UI-thread lift has
92
+ started — so a full list shot to the top of the screen and settled back
93
+ about a quarter of a second later. `snapPoints` bypasses dynamic sizing, and
94
+ the cap and the limit with it.
93
95
  - **The row floor is ListGroup's**, `min-h-12/14/16`, pinned by the test, so a
94
96
  row in the sheet sits level with a `ListGroup` row on the screen behind it. It
95
97
  is a floor, never a height, so a description can wrap.
@@ -1,6 +1,5 @@
1
1
  import { type ReactElement, type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
2
2
  import { useWindowDimensions, View } from "react-native";
3
- import { KeyboardEvents } from "react-native-keyboard-controller";
4
3
  import { useSafeAreaInsets } from "react-native-safe-area-context";
5
4
  import { useControllableState } from "../../hooks/use-controllable-state";
6
5
  import { IconMagnifyingGlass } from "../../icons/central";
@@ -103,31 +102,6 @@ export type SelectProps = SelectSingleProps | SelectMultipleProps;
103
102
 
104
103
  const NO_VALUES: readonly string[] = [];
105
104
 
106
- /**
107
- * The keyboard's height while `isActive`, else zero.
108
- *
109
- * Read on the JS thread from the will-show and will-hide events, so the sheet's
110
- * cap shrinks as the keyboard starts to rise rather than after it lands.
111
- */
112
- function useKeyboardHeight(isActive: boolean): number {
113
- const [height, setHeight] = useState(0);
114
-
115
- useEffect(() => {
116
- if (!isActive) {
117
- setHeight(0);
118
- return;
119
- }
120
- const show = KeyboardEvents.addListener("keyboardWillShow", (event) => setHeight(event.height));
121
- const hide = KeyboardEvents.addListener("keyboardWillHide", () => setHeight(0));
122
- return () => {
123
- show.remove();
124
- hide.remove();
125
- };
126
- }, [isActive]);
127
-
128
- return height;
129
- }
130
-
131
105
  function SelectRoot(props: SelectProps): ReactElement {
132
106
  const {
133
107
  options,
@@ -180,7 +154,6 @@ function SelectRoot(props: SelectProps): ReactElement {
180
154
 
181
155
  const [isOpen, setOpen] = useState(false);
182
156
  const [query, setQuery] = useState("");
183
- const keyboardHeight = useKeyboardHeight(isOpen && isSearchable);
184
157
  const canOpen = !resolvedIsDisabled && !isReadOnly;
185
158
 
186
159
  const open = useCallback(() => {
@@ -322,7 +295,8 @@ function SelectRoot(props: SelectProps): ReactElement {
322
295
  <BottomSheet
323
296
  dynamicSizing={snapPoints === undefined}
324
297
  isOpen={isOpen}
325
- maxDynamicContentSize={resolveSelectSheetMaxHeight({ keyboardHeight, topInset, windowHeight })}
298
+ keyboardLiftLimit="maxDynamicContentSize"
299
+ maxDynamicContentSize={resolveSelectSheetMaxHeight({ topInset, windowHeight })}
326
300
  onOpenChange={handleOpenChange}
327
301
  snapPoints={snapPoints}
328
302
  >
@@ -551,17 +551,6 @@ describe("resolveSelectSheetMaxHeight", () => {
551
551
  expect(Number.isInteger(resolveSelectSheetMaxHeight({ topInset: 59, windowHeight: 874 }))).toBe(true);
552
552
  });
553
553
 
554
- test("leaves room for the keyboard, which the sheet is lifted above", () => {
555
- const open = resolveSelectSheetMaxHeight({ keyboardHeight: 336, topInset: 62, windowHeight: 874 });
556
- expect(open).toBe(874 - 62 - SELECT_SHEET_TOP_GAP - 336);
557
- });
558
-
559
- test("an absent keyboard is a zero-height one", () => {
560
- expect(resolveSelectSheetMaxHeight({ keyboardHeight: 0, topInset: 62, windowHeight: 874 })).toBe(
561
- resolveSelectSheetMaxHeight({ topInset: 62, windowHeight: 874 })
562
- );
563
- });
564
-
565
554
  test("never goes negative on a degenerate window", () => {
566
555
  expect(resolveSelectSheetMaxHeight({ topInset: 50, windowHeight: 20 })).toBe(0);
567
556
  });
@@ -49,22 +49,20 @@ export const SELECT_SHEET_TOP_GAP = 24;
49
49
  *
50
50
  * Without one, two hundred options measure taller than the screen and the sheet
51
51
  * rises under the status bar with nothing of the page left above it. The lower
52
- * of a fraction of the window and the room below the top inset wins, and an
53
- * open keyboard comes off the second: the sheet rides above it, and a full list
54
- * plus the keyboard would otherwise reach the top of the screen.
52
+ * of a fraction of the window and the room below the top inset wins. The
53
+ * keyboard does not come off it here: the sheet opts into
54
+ * `keyboardLiftLimit="maxDynamicContentSize"`, so the engine stops its lift at
55
+ * this cap on the UI thread, in the keyboard's first frame.
55
56
  */
56
57
  export function resolveSelectSheetMaxHeight({
57
58
  windowHeight,
58
59
  topInset,
59
- keyboardHeight = 0,
60
60
  }: {
61
61
  windowHeight: number;
62
62
  topInset: number;
63
- /** The sheet is lifted above an open keyboard, so the room it may take shrinks by the keyboard's height. */
64
- keyboardHeight?: number;
65
63
  }): number {
66
64
  const byFraction = windowHeight * SELECT_SHEET_MAX_FRACTION;
67
- const belowInset = windowHeight - topInset - SELECT_SHEET_TOP_GAP - keyboardHeight;
65
+ const belowInset = windowHeight - topInset - SELECT_SHEET_TOP_GAP;
68
66
  return Math.max(0, Math.floor(Math.min(byFraction, belowInset)));
69
67
  }
70
68