panelui-native 0.88.1 → 0.89.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.
Files changed (29) hide show
  1. package/lib/module/components/animated-badge/index.js +10 -2
  2. package/lib/module/components/animated-badge/index.js.map +1 -1
  3. package/lib/module/components/avatar/index.js +10 -1
  4. package/lib/module/components/avatar/index.js.map +1 -1
  5. package/lib/module/components/feedback/index.js +31 -4
  6. package/lib/module/components/feedback/index.js.map +1 -1
  7. package/lib/module/components/search-bar/index.js +35 -7
  8. package/lib/module/components/search-bar/index.js.map +1 -1
  9. package/lib/module/components/selection-mode/index.js +33 -3
  10. package/lib/module/components/selection-mode/index.js.map +1 -1
  11. package/lib/module/hooks/use-keyboard-avoidance.js +33 -8
  12. package/lib/module/hooks/use-keyboard-avoidance.js.map +1 -1
  13. package/lib/typescript/src/components/animated-badge/index.d.ts +19 -0
  14. package/lib/typescript/src/components/animated-badge/index.d.ts.map +1 -1
  15. package/lib/typescript/src/components/avatar/index.d.ts.map +1 -1
  16. package/lib/typescript/src/components/feedback/index.d.ts +3 -0
  17. package/lib/typescript/src/components/feedback/index.d.ts.map +1 -1
  18. package/lib/typescript/src/components/search-bar/index.d.ts +4 -1
  19. package/lib/typescript/src/components/search-bar/index.d.ts.map +1 -1
  20. package/lib/typescript/src/components/selection-mode/index.d.ts +63 -0
  21. package/lib/typescript/src/components/selection-mode/index.d.ts.map +1 -1
  22. package/lib/typescript/src/hooks/use-keyboard-avoidance.d.ts.map +1 -1
  23. package/package.json +1 -1
  24. package/src/components/animated-badge/index.tsx +24 -2
  25. package/src/components/avatar/index.tsx +10 -1
  26. package/src/components/feedback/index.tsx +26 -1
  27. package/src/components/search-bar/index.tsx +33 -8
  28. package/src/components/selection-mode/index.tsx +30 -3
  29. package/src/hooks/use-keyboard-avoidance.ts +33 -8
@@ -127,11 +127,11 @@ import {
127
127
  import {
128
128
  ScrollView,
129
129
  StyleSheet,
130
+ TextInput,
130
131
  View,
131
132
  useWindowDimensions,
132
133
  type LayoutChangeEvent,
133
134
  type NativeSyntheticEvent,
134
- type TextInput,
135
135
  type TextInputKeyPressEventData,
136
136
  type TextInputSubmitEditingEventData,
137
137
  type ViewProps,
@@ -287,6 +287,15 @@ const searchBarVariants = tv({
287
287
  panelList: 'p-1.5',
288
288
  /** The hairline between the results and the field. */
289
289
  panelDivider: 'w-full bg-border',
290
+ /*
291
+ * A gap between the rows, because a selected one is drawn as a filled
292
+ * shape. Stacked flush, its fill runs into the row underneath and the two
293
+ * read as one block with a coloured top half — a row's rounded corners are
294
+ * only visible when there is background either side of them to round
295
+ * against. Two points separates the fills and still reads as a list rather
296
+ * than a set of cards.
297
+ */
298
+ section: 'gap-0.5',
290
299
  sectionLabel: 'px-3 pb-1 pt-2 text-sm text-muted-foreground',
291
300
  item: 'flex-row items-center gap-3 rounded-lg px-3 py-2.5',
292
301
  itemLabel: 'flex-1 text-base text-foreground',
@@ -689,9 +698,24 @@ const SearchBarRoot = forwardRef<TextInput, SearchBarProps>(
689
698
  clearCloseTimer();
690
699
  closeTimer.current = setTimeout(() => {
691
700
  closeTimer.current = null;
692
- if (!ending.current && keyboardUp.current) {
693
- // The keyboard never went down, so the search is still on screen
694
- // and something inside it has taken the focus. Give it back.
701
+ /*
702
+ * A keyboard that never went down is not on its own proof that the
703
+ * focus is still inside this search.
704
+ *
705
+ * Put two search bars on one screen and they hand the keyboard
706
+ * between them: the first blurs, the keyboard stays up because the
707
+ * second now has it, and the first takes it straight back — then the
708
+ * second does the same, and both draw themselves as the field being
709
+ * typed into. A screen showing sizes or variants is four of them in a
710
+ * column, so this is the arrangement, not a corner case.
711
+ *
712
+ * So ask who actually holds it. Another field means somebody moved
713
+ * on deliberately; a focus that has landed nowhere is one of this
714
+ * search's own buttons taking it, which is what the recovery is for.
715
+ */
716
+ const holder = TextInput.State.currentlyFocusedInput();
717
+ const elsewhere = holder != null && holder !== inputRef.current;
718
+ if (!ending.current && keyboardUp.current && !elsewhere) {
695
719
  inputRef.current?.focus();
696
720
  return;
697
721
  }
@@ -1121,9 +1145,9 @@ export interface SearchBarSectionProps extends ViewProps {
1121
1145
 
1122
1146
  /** A labelled run of rows inside the panel. */
1123
1147
  function SearchBarSection({ className, label, children, ...props }: SearchBarSectionProps) {
1124
- const { sectionLabel } = searchBarVariants();
1148
+ const { section, sectionLabel } = searchBarVariants();
1125
1149
  return (
1126
- <View {...props} className={className}>
1150
+ <View {...props} className={section({ className })}>
1127
1151
  {label ? (
1128
1152
  <Text accessibilityRole="header" className={sectionLabel()}>
1129
1153
  {label}
@@ -1183,8 +1207,9 @@ function SearchBarItem({
1183
1207
  // a card rather than a line in a list. The dim is the whole feedback.
1184
1208
  pressScale={1}
1185
1209
  pressOpacity={0.6}
1186
- // The rows sit flush against each other, so the points either side of the
1187
- // gap between two of them would otherwise belong to neither.
1210
+ // The two points of gap the section leaves between rows belong to
1211
+ // neither of them otherwise, and a dead line across a list of targets is
1212
+ // a tap that does nothing.
1188
1213
  hitSlop={{ top: 2, bottom: 2 }}
1189
1214
  {...props}
1190
1215
  className={item({ className })}
@@ -134,7 +134,7 @@ const selectionVariants = tv({
134
134
  header: 'h-14 flex-row items-center gap-3 border-b border-border px-4',
135
135
  title: 'flex-1 text-center text-base font-semibold text-foreground',
136
136
  close: 'h-10 w-10 items-center justify-center rounded-full bg-muted',
137
- group: 'overflow-hidden rounded-2xl bg-card',
137
+ group: 'overflow-hidden rounded-2xl',
138
138
  ring: 'rounded-full border-2 border-transparent p-0.5',
139
139
  bar: 'flex-row items-stretch',
140
140
  action: 'flex-1 items-center justify-center gap-1.5 px-2 py-3',
@@ -162,6 +162,23 @@ const selectionVariants = tv({
162
162
  disabled: {
163
163
  true: { action: 'opacity-[0.44]' },
164
164
  },
165
+ /**
166
+ * The group's surface, which depends on what is behind it.
167
+ *
168
+ * `card` is a step *down* from the background on a screen, which is what a
169
+ * grouped list wants there. A sheet is already `popover`, and popover sits
170
+ * above card in every dark theme — so the same card inside a sheet is
171
+ * darker than the sheet around it and reads as a hole rather than a raised
172
+ * set of rows.
173
+ *
174
+ * `muted` is an alpha tint, so it steps up from whatever it is drawn on and
175
+ * is correct against either. It is only used in the sheet because the
176
+ * screen's card is right where it is.
177
+ */
178
+ surface: {
179
+ screen: { group: 'bg-card' },
180
+ sheet: { group: 'bg-muted' },
181
+ },
165
182
  /**
166
183
  * Flush to the bottom edge, or lifted off it.
167
184
  *
@@ -177,6 +194,7 @@ const selectionVariants = tv({
177
194
  },
178
195
  defaultVariants: {
179
196
  placement: 'bar',
197
+ surface: 'screen',
180
198
  },
181
199
  });
182
200
 
@@ -665,7 +683,10 @@ function SelectionModeGroup({
665
683
  ...props
666
684
  }: SelectionModeGroupProps) {
667
685
  const items = Children.toArray(children).filter(Boolean);
668
- const slots = selectionVariants({});
686
+ // A sheet's own surface already sits above a card's, so which fill the group
687
+ // takes depends on where it is. Every other part reads the context too.
688
+ const { sheet } = useSelectionMode();
689
+ const slots = selectionVariants({ surface: sheet ? 'sheet' : 'screen' });
669
690
 
670
691
  /**
671
692
  * The caption, and the wrapper that carries it. A group with no `label` is
@@ -1133,7 +1154,13 @@ function SelectionModeSheet({
1133
1154
  * screen but in the wrong branch of the tree.
1134
1155
  */}
1135
1156
  <SelectionModeContext.Provider value={context}>
1136
- <BottomSheet.Header>
1157
+ {/*
1158
+ * `pe-0` gives the trailing edge back. `BottomSheet.Header` reserves
1159
+ * room there for the close button, and this sheet draws none — left
1160
+ * alone, the reserved 48 points push the centred title off the middle
1161
+ * of the sheet and hold "All" that far in from the corner.
1162
+ */}
1163
+ <BottomSheet.Header className="pe-0">
1137
1164
  {/* The sheet already draws the rule and the padding. */}
1138
1165
  <SelectionModeHeader
1139
1166
  title={title}
@@ -44,7 +44,7 @@
44
44
  * </Animated.View>
45
45
  * ```
46
46
  */
47
- import { useCallback, useEffect, useState } from 'react';
47
+ import { useCallback, useEffect, useLayoutEffect, useState } from 'react';
48
48
  import {
49
49
  TurboModuleRegistry,
50
50
  useWindowDimensions,
@@ -201,7 +201,8 @@ export function useKeyboardAvoidance({
201
201
  const translation = useSharedValue(0);
202
202
 
203
203
  /*
204
- * Whether the element has been laid out at least once and is still mounted.
204
+ * Whether the element can be measured: laid out at least once, and still
205
+ * mounted.
205
206
  *
206
207
  * `measure` warns when it is handed a view the layout engine has no metrics
207
208
  * for — every frame between a mount and its first layout, and every frame
@@ -210,11 +211,26 @@ export function useKeyboardAvoidance({
210
211
  * the warning is printed before that null comes back. Running once a frame,
211
212
  * one of these fills the log on its own.
212
213
  *
213
- * So the layout pass is what says the view can be measured, and the unmount
214
- * is what takes it back.
214
+ * The layout pass arms it. Two things disarm it, because one is not enough:
215
+ *
216
+ * - the teardown, in a *layout* effect. A plain effect's cleanup is written
217
+ * from the JS thread after the commit, which is one or more frames after
218
+ * the view it is protecting has gone — long enough for the loop to measure
219
+ * a detached view and log. This is the case an overlay hits every time:
220
+ * a dialog whose content unmounts on close takes the element with it while
221
+ * the keyboard is still on its way down.
222
+ * - the worklet itself, whenever a measurement comes back null. That write
223
+ * belongs to the UI thread, so it lands whatever order the teardown
224
+ * arrives in, and a view that has gone costs one warning rather than one
225
+ * per frame.
226
+ *
227
+ * A disarm that was only a race is put back by the next layout pass, or by
228
+ * the keyboard opening again — `everLaidOut` is what makes the second one
229
+ * safe, since arming before the first layout is the other half of the bug.
215
230
  */
216
231
  const laidOut = useSharedValue(false);
217
- useEffect(
232
+ const everLaidOut = useSharedValue(false);
233
+ useLayoutEffect(
218
234
  () => () => {
219
235
  laidOut.value = false;
220
236
  },
@@ -250,7 +266,11 @@ export function useKeyboardAvoidance({
250
266
  if (!laidOut.value) return;
251
267
 
252
268
  const frame = measure(ref);
253
- if (!frame || frame.height <= 0) return;
269
+ if (!frame) {
270
+ laidOut.value = false;
271
+ return;
272
+ }
273
+ if (frame.height <= 0) return;
254
274
 
255
275
  const restingBottom = frame.pageY + frame.height - translation.value;
256
276
  const keyboardTop = screenHeight - keyboardHeight;
@@ -272,6 +292,10 @@ export function useKeyboardAvoidance({
272
292
  if (shouldTrack === wasTracking) return;
273
293
  runOnJS(setTracking)(shouldTrack);
274
294
 
295
+ // A keyboard opening is the other place the guard is put back, for the
296
+ // element that was disarmed by a measurement that raced a re-layout.
297
+ if (shouldTrack && everLaidOut.value) laidOut.value = true;
298
+
275
299
  // Moving straight from one field to another never closes the keyboard,
276
300
  // so the field being left has nothing to follow back down — it is sent
277
301
  // home explicitly, or it stays hanging where the keyboard left it.
@@ -282,7 +306,7 @@ export function useKeyboardAvoidance({
282
306
  );
283
307
 
284
308
  const { setActive } = track;
285
- useEffect(() => {
309
+ useLayoutEffect(() => {
286
310
  setActive(tracking && mode === 'lift');
287
311
  return () => setActive(false);
288
312
  }, [tracking, mode, setActive]);
@@ -290,12 +314,13 @@ export function useKeyboardAvoidance({
290
314
  const onLayout = useCallback(
291
315
  (_event: LayoutChangeEvent) => {
292
316
  laidOut.value = true;
317
+ everLaidOut.value = true;
293
318
  // A layout pass while the element is at rest means its slot moved for
294
319
  // some reason other than the keyboard. Anything left over from the last
295
320
  // lift belongs to the old slot.
296
321
  if (!tracking && translation.value !== 0) translation.value = 0;
297
322
  },
298
- [laidOut, tracking, translation]
323
+ [everLaidOut, laidOut, tracking, translation]
299
324
  );
300
325
 
301
326
  const animatedStyle = useAnimatedStyle(() => {