panelui-native 0.79.1 → 0.80.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 (30) hide show
  1. package/lib/module/components/bottom-sheet/index.js +9 -2
  2. package/lib/module/components/bottom-sheet/index.js.map +1 -1
  3. package/lib/module/components/panelside/index.js +527 -19
  4. package/lib/module/components/panelside/index.js.map +1 -1
  5. package/lib/module/components/qr-code/index.js +84 -15
  6. package/lib/module/components/qr-code/index.js.map +1 -1
  7. package/lib/module/components/qr-code/qr-shapes.js +202 -0
  8. package/lib/module/components/qr-code/qr-shapes.js.map +1 -0
  9. package/lib/module/components/timeline/index.js +145 -18
  10. package/lib/module/components/timeline/index.js.map +1 -1
  11. package/lib/module/index.js.map +1 -1
  12. package/lib/typescript/src/components/bottom-sheet/index.d.ts +16 -0
  13. package/lib/typescript/src/components/bottom-sheet/index.d.ts.map +1 -1
  14. package/lib/typescript/src/components/panelside/index.d.ts +242 -5
  15. package/lib/typescript/src/components/panelside/index.d.ts.map +1 -1
  16. package/lib/typescript/src/components/qr-code/index.d.ts +42 -3
  17. package/lib/typescript/src/components/qr-code/index.d.ts.map +1 -1
  18. package/lib/typescript/src/components/qr-code/qr-shapes.d.ts +64 -0
  19. package/lib/typescript/src/components/qr-code/qr-shapes.d.ts.map +1 -0
  20. package/lib/typescript/src/components/timeline/index.d.ts +33 -0
  21. package/lib/typescript/src/components/timeline/index.d.ts.map +1 -1
  22. package/lib/typescript/src/index.d.ts +1 -1
  23. package/lib/typescript/src/index.d.ts.map +1 -1
  24. package/package.json +1 -1
  25. package/src/components/bottom-sheet/index.tsx +9 -2
  26. package/src/components/panelside/index.tsx +750 -28
  27. package/src/components/qr-code/index.tsx +124 -18
  28. package/src/components/qr-code/qr-shapes.ts +234 -0
  29. package/src/components/timeline/index.tsx +165 -14
  30. package/src/index.ts +3 -0
@@ -84,6 +84,7 @@ import {
84
84
  useContext,
85
85
  useEffect,
86
86
  useMemo,
87
+ useRef,
87
88
  useState,
88
89
  type ReactElement,
89
90
  type ReactNode,
@@ -116,9 +117,18 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
116
117
  import { tv } from 'tailwind-variants';
117
118
  import { useCSSVariable } from 'uniwind';
118
119
  import { LinearGradient } from 'expo-linear-gradient';
119
- import { EllipsisIcon, IconColorProvider, MenuIcon, SearchIcon } from '../../icons';
120
+ import { EllipsisIcon, IconColorProvider, MenuIcon, SearchIcon, XIcon } from '../../icons';
121
+ import {
122
+ BottomSheet,
123
+ bottomSheetDetentHeight,
124
+ type BottomSheetBodyProps,
125
+ type BottomSheetProps,
126
+ } from '../bottom-sheet';
120
127
  import { Button } from '../button';
128
+ import { Tabs } from '../tabs';
129
+ import { getNativeUI } from '../../native';
121
130
  import { AnimatedPressable } from '../../primitives/animated-pressable';
131
+ import { KeyboardAvoider } from '../../primitives/keyboard-avoider';
122
132
  import { Text, textChildren, type TextProps } from '../../primitives/text';
123
133
  import { useBackHandler } from '../../hooks/use-back-handler';
124
134
  import { useDirectionSign } from '../../hooks/use-direction';
@@ -236,6 +246,20 @@ const DOCK_WIDTH_MAX = 320;
236
246
  /** How far above the floating footer the list starts dissolving into it. */
237
247
  const FOOTER_FADE = 28;
238
248
 
249
+ /**
250
+ * What `BottomSheet.Content` leaves below its last child, maxed against the
251
+ * home indicator. Mirrored here so the search field can subtract the strip it
252
+ * already sits above before it travels with the keyboard.
253
+ */
254
+ const SHEET_BOTTOM_PADDING = 16;
255
+
256
+ /**
257
+ * The top padding the search surface asks `BottomSheet.Content` for, and takes
258
+ * back off the column's height. Smaller than the sheet's own default, because
259
+ * this surface leads with a round button rather than with a title.
260
+ */
261
+ const SHEET_TOP_PADDING = 12;
262
+
239
263
  /** Progress past which a layer is treated as fully hidden for accessibility. */
240
264
  const HIDDEN_EPSILON = 0.05;
241
265
 
@@ -248,6 +272,15 @@ export type PanelsideMode = 'push' | 'overlay';
248
272
  export type PanelsideSwipeFrom = 'anywhere' | 'edge';
249
273
  export type PanelsideItemSize = 'default' | 'sm';
250
274
  export type PanelsideCtaSize = 'default' | 'lg';
275
+ /**
276
+ * What a header or a footer paints behind itself.
277
+ *
278
+ * `transparent` paints nothing, and the list runs the full height of the panel
279
+ * underneath it. `fade` dissolves the list into the panel background over the
280
+ * strip above the controls. `solid` is a band with an edge on it, for a footer
281
+ * that is a row of the layout rather than something floating over one.
282
+ */
283
+ export type PanelsideSurface = 'transparent' | 'fade' | 'solid';
251
284
 
252
285
  const itemVariants = tv({
253
286
  // No width: in a group it stretches on its own, and pinning it to full width
@@ -272,9 +305,14 @@ const itemVariants = tv({
272
305
  const ctaVariants = tv({
273
306
  // Taller and wider than a list row's control. It is the one thing in the
274
307
  // panel you are meant to reach for without reading, so it should not be the
275
- // same size as the eight chat titles above it — but it shares its footer row
276
- // with an account button, and a pill that stands a whole step above that row
277
- // makes the footer taller than anything in the panel needs it to be.
308
+ // same size as the eight chat titles above it.
309
+ //
310
+ // It used to be 40pt, chosen to sit level with the account button beside it.
311
+ // That is the wrong thing to size it against: the account button is a target
312
+ // you find once, and the compose pill is the one pressed every session — at
313
+ // matching heights the two read as a pair of equals and the pill stopped
314
+ // being the thing the footer is for. Four points is enough to separate them
315
+ // without making the footer taller than the panel needs.
278
316
  base: 'shrink flex-row items-center justify-center rounded-full',
279
317
  variants: {
280
318
  variant: {
@@ -282,8 +320,8 @@ const ctaVariants = tv({
282
320
  secondary: 'bg-secondary',
283
321
  },
284
322
  size: {
285
- default: 'h-10 gap-2 px-5',
286
- lg: 'h-12 gap-2 px-6',
323
+ default: 'h-11 gap-2 px-6',
324
+ lg: 'h-13 gap-2.5 px-7',
287
325
  },
288
326
  },
289
327
  defaultVariants: {
@@ -308,6 +346,16 @@ interface PanelsideContextValue {
308
346
  scale?: number;
309
347
  radius?: number;
310
348
  dim?: number;
349
+ /**
350
+ * Whether the search surface is up.
351
+ *
352
+ * It lives on the root rather than on the sheet because the two halves of
353
+ * search are in different subtrees: the button is in the header and the
354
+ * sheet is a sibling of the panel. Anything else means every app wiring one
355
+ * `useState` through both.
356
+ */
357
+ searchOpen: boolean;
358
+ setSearchOpen: (open: boolean) => void;
311
359
  }
312
360
 
313
361
  const PanelsideContext = createContext<PanelsideContextValue | null>(null);
@@ -332,6 +380,9 @@ export interface UsePanelsideResult {
332
380
  progress: SharedValue<number>;
333
381
  /** True while the panel is docked open beside the scene. */
334
382
  docked: boolean;
383
+ /** Whether the search surface is up. `Panelside.SearchTrigger` sets it. */
384
+ searchOpen: boolean;
385
+ setSearchOpen: (open: boolean) => void;
335
386
  }
336
387
 
337
388
  /**
@@ -340,8 +391,9 @@ export interface UsePanelsideResult {
340
391
  * lives.
341
392
  */
342
393
  export function usePanelside(): UsePanelsideResult {
343
- const { open, setOpen, toggle, progress, docked } = usePanelsideContext('usePanelside');
344
- return { open, setOpen, toggle, progress, docked };
394
+ const { open, setOpen, toggle, progress, docked, searchOpen, setSearchOpen } =
395
+ usePanelsideContext('usePanelside');
396
+ return { open, setOpen, toggle, progress, docked, searchOpen, setSearchOpen };
345
397
  }
346
398
 
347
399
  /**
@@ -464,6 +516,7 @@ function PanelsideRoot({
464
516
  const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
465
517
  const controlled = controlledOpen !== undefined;
466
518
  const open = controlled ? controlledOpen : uncontrolledOpen;
519
+ const [searchOpen, setSearchOpen] = useState(false);
467
520
 
468
521
  /*
469
522
  * Measured rather than taken from the window, because Panelside does not
@@ -640,8 +693,23 @@ function PanelsideRoot({
640
693
  scale,
641
694
  radius,
642
695
  dim,
696
+ searchOpen,
697
+ setSearchOpen,
643
698
  }),
644
- [dim, dismissible, docked, mode, open, progress, radius, scale, setOpen, toggle, width]
699
+ [
700
+ dim,
701
+ dismissible,
702
+ docked,
703
+ mode,
704
+ open,
705
+ progress,
706
+ radius,
707
+ scale,
708
+ searchOpen,
709
+ setOpen,
710
+ toggle,
711
+ width,
712
+ ]
645
713
  );
646
714
 
647
715
  return (
@@ -726,6 +794,20 @@ export interface PanelsideHeaderProps extends ViewProps {
726
794
  title?: string;
727
795
  /** A single element pinned to the trailing end of the title row. */
728
796
  action?: ReactNode;
797
+ /**
798
+ * What the header paints behind itself.
799
+ *
800
+ * `transparent` is the default and paints nothing, so the header is the
801
+ * panel's own surface with a title on it rather than a bar sitting on top of
802
+ * one. In the panel's normal stacking that is the whole story — the header
803
+ * takes a row and the list starts below it.
804
+ *
805
+ * `fade` and `solid` are for a header the caller has lifted out of that
806
+ * stack — `className="absolute start-0 end-0 top-0"` — so the list runs
807
+ * underneath it. They are the two shapes `Panelside.Footer` offers, drawn
808
+ * the other way up.
809
+ */
810
+ surface?: PanelsideSurface;
729
811
  /** Anything below the title row — a search field, a workspace switcher. */
730
812
  children?: ReactNode;
731
813
  }
@@ -734,11 +816,14 @@ function PanelsideHeader({
734
816
  className,
735
817
  title,
736
818
  action,
819
+ surface = 'transparent',
737
820
  children,
738
821
  style,
739
822
  ...props
740
823
  }: PanelsideHeaderProps) {
741
824
  const insets = useSafeAreaInsets();
825
+ const background = useCSSVariable('--color-background');
826
+ const solid = typeof background === 'string' ? background : '#000000';
742
827
 
743
828
  return (
744
829
  <View
@@ -750,9 +835,32 @@ function PanelsideHeader({
750
835
  // `px-3` matches the scroller below it, so the search field and the rows
751
836
  // share one edge. A header inset further would leave the field floating
752
837
  // a few points inside the list it filters.
753
- className={cn('gap-3 px-3 pb-3', className)}
838
+ className={cn(
839
+ 'gap-3 px-3 pb-3',
840
+ surface === 'solid' && 'border-b border-border bg-background',
841
+ className
842
+ )}
754
843
  {...props}
755
844
  >
845
+ {/* The footer's fade, upside down: opaque under the title and clearing to
846
+ nothing at the bottom edge, so a row scrolled up into the header
847
+ dissolves rather than sliding out from under a line. */}
848
+ {surface === 'fade' ? (
849
+ <>
850
+ <View
851
+ pointerEvents="none"
852
+ className="absolute end-0 start-0 top-0 bg-background"
853
+ style={{ bottom: FOOTER_FADE }}
854
+ />
855
+ <LinearGradient
856
+ colors={[solid, `${solid}00`]}
857
+ start={{ x: 0, y: 0 }}
858
+ end={{ x: 0, y: 1 }}
859
+ pointerEvents="none"
860
+ style={[styles.rise, { height: FOOTER_FADE }]}
861
+ />
862
+ </>
863
+ ) : null}
756
864
  {(title || action) && (
757
865
  <View className="h-9 flex-row items-center justify-between gap-2">
758
866
  {title ? (
@@ -1153,22 +1261,43 @@ export interface PanelsideFooterProps extends ViewProps {
1153
1261
  * leaves exactly this footer's height of room at the end.
1154
1262
  */
1155
1263
  floating?: boolean;
1264
+ /**
1265
+ * What the footer paints behind its controls.
1266
+ *
1267
+ * `transparent` is the default and paints nothing: the list runs under the
1268
+ * controls, which is how the panel reads as one surface with two things
1269
+ * floating on it rather than as a list with a bar bolted to the bottom.
1270
+ *
1271
+ * `fade` dissolves the list into the panel background over the strip above
1272
+ * the controls. It costs a band of the panel, and buys a compose button that
1273
+ * never has a chat title running through its label — worth turning on for a
1274
+ * panel whose history is long enough that something is always underneath.
1275
+ *
1276
+ * `solid` is a band with a hairline over it, for a footer that is a row of
1277
+ * the layout. Implied by `floating={false}`, which has no list to float over.
1278
+ */
1279
+ surface?: PanelsideSurface;
1156
1280
  children?: ReactNode;
1157
1281
  }
1158
1282
 
1159
1283
  function PanelsideFooter({
1160
1284
  className,
1161
1285
  floating = true,
1286
+ surface = 'transparent',
1162
1287
  children,
1163
1288
  style,
1164
1289
  ...props
1165
1290
  }: PanelsideFooterProps) {
1166
1291
  const insets = useSafeAreaInsets();
1167
- const surface = useContext(PanelsideSurfaceContext);
1168
- const setFooterHeight = surface?.setFooterHeight;
1292
+ const panel = useContext(PanelsideSurfaceContext);
1293
+ const setFooterHeight = panel?.setFooterHeight;
1169
1294
  const background = useCSSVariable('--color-background');
1170
1295
  const solid = typeof background === 'string' ? background : '#000000';
1171
1296
 
1297
+ // A footer in the flow has nothing to float over, so the only thing it can
1298
+ // be is the band — whatever was asked for.
1299
+ const paint = floating ? surface : 'solid';
1300
+
1172
1301
  const onLayout = useCallback(
1173
1302
  (event: LayoutChangeEvent) => {
1174
1303
  setFooterHeight?.(event.nativeEvent.layout.height);
@@ -1181,7 +1310,7 @@ function PanelsideFooter({
1181
1310
  onLayout={floating ? onLayout : undefined}
1182
1311
  style={[
1183
1312
  { paddingBottom: Math.max(insets.bottom, 12) },
1184
- floating ? { paddingTop: FOOTER_FADE } : null,
1313
+ floating ? { paddingTop: paint === 'fade' ? FOOTER_FADE : 12 } : null,
1185
1314
  style,
1186
1315
  ]}
1187
1316
  className={cn(
@@ -1196,22 +1325,16 @@ function PanelsideFooter({
1196
1325
  {...props}
1197
1326
  >
1198
1327
  {/*
1199
- A floating footer has no edge and no bar. A solid one cuts a strip out
1200
- of the bottom of the list; a transparent one lets rows slide under the
1201
- controls and show through the labels. So it is neither: the top
1202
- `FOOTER_FADE` points are a gradient the list dissolves into, and
1203
- everything below that — the band the controls actually sit in — is
1204
- plain background.
1205
-
1206
- The fade has to finish *above* the first control, not run through it.
1207
- Two layers rather than one gradient across the whole box, because a
1208
- gradient sized to the box puts its midpoint wherever the box happens to
1209
- be tall, which is exactly where the labels are.
1328
+ The fade is two layers rather than one gradient across the whole box.
1329
+ A gradient sized to the box puts its midpoint wherever the box happens
1330
+ to be tall, which is exactly where the labels are — so the top
1331
+ `FOOTER_FADE` points are the gradient and everything below it, the band
1332
+ the controls actually sit in, is plain background.
1210
1333
 
1211
1334
  Both are inside the footer's own bounds, so neither depends on a parent
1212
1335
  that does not clip its children.
1213
1336
  */}
1214
- {floating ? (
1337
+ {paint === 'fade' ? (
1215
1338
  <>
1216
1339
  <LinearGradient
1217
1340
  colors={[`${solid}00`, solid]}
@@ -1227,6 +1350,12 @@ function PanelsideFooter({
1227
1350
  />
1228
1351
  </>
1229
1352
  ) : null}
1353
+ {paint === 'solid' && floating ? (
1354
+ <View
1355
+ pointerEvents="none"
1356
+ className="absolute bottom-0 end-0 start-0 top-0 border-t border-border bg-background"
1357
+ />
1358
+ ) : null}
1230
1359
  {textChildren(children)}
1231
1360
  </View>
1232
1361
  );
@@ -1234,6 +1363,7 @@ function PanelsideFooter({
1234
1363
 
1235
1364
  const styles = StyleSheet.create({
1236
1365
  fade: { position: 'absolute', top: 0, left: 0, right: 0 },
1366
+ rise: { position: 'absolute', bottom: 0, left: 0, right: 0 },
1237
1367
  });
1238
1368
 
1239
1369
  export interface PanelsideCtaProps extends Omit<PressableProps, 'children'> {
@@ -1245,9 +1375,10 @@ export interface PanelsideCtaProps extends Omit<PressableProps, 'children'> {
1245
1375
  /** `primary` is the filled accent pill; `secondary` is the quiet one. */
1246
1376
  variant?: 'primary' | 'secondary';
1247
1377
  /**
1248
- * How tall the pill is. `default` is 40pt, which sits level with the account
1249
- * button beside it in the footer; `lg` is the 48pt pill, for a panel where
1250
- * the call to action is the only thing in the row.
1378
+ * How tall the pill is. `default` is 44pt — a step above the account button
1379
+ * beside it, so the footer reads as one primary control and one secondary
1380
+ * one. `lg` is 52pt, for a panel where the call to action is the only thing
1381
+ * in the row.
1251
1382
  *
1252
1383
  * Ignored under `native` — the platform sizes its own button, and asks for a
1253
1384
  * control size rather than a height.
@@ -1582,6 +1713,583 @@ function PanelsideTrigger({
1582
1713
  );
1583
1714
  }
1584
1715
 
1716
+ /* ------------------------------------------------------------------ *
1717
+ * Search.
1718
+ *
1719
+ * A field in the header is the obvious way to put search in a navigation
1720
+ * panel, and it is the wrong one on a phone. The panel is 80% of the screen
1721
+ * and the field is 40 points of it, so a search that returns anything has to
1722
+ * push the history down the screen it is already filling — and the field is at
1723
+ * the top, which is the far end of the screen from the keyboard that has just
1724
+ * opened under it.
1725
+ *
1726
+ * So search is a surface rather than a row. A round button in the header opens
1727
+ * a sheet that is the whole screen; the tabs across the top narrow what is
1728
+ * being searched; the results fill the middle; and the field is at the bottom,
1729
+ * where the thumb already is, riding the keyboard rather than hiding behind
1730
+ * it.
1731
+ *
1732
+ * `Panelside.Search` — the inline field — is still exported, and is still
1733
+ * right for a docked panel on a tablet, where there is width for a field and
1734
+ * no keyboard covering half the screen.
1735
+ * ------------------------------------------------------------------ */
1736
+
1737
+ export interface PanelsideSearchTriggerProps extends Omit<PressableProps, 'children'> {
1738
+ className?: string;
1739
+ /** What a screen reader announces. */
1740
+ label?: string;
1741
+ /** Replaces the default magnifier. */
1742
+ children?: ReactNode;
1743
+ /**
1744
+ * Render the platform's own button instead of the circle. Requires the
1745
+ * optional `@expo/ui` package; without it this prop does nothing.
1746
+ */
1747
+ native?: boolean;
1748
+ /**
1749
+ * Draw the native button in the platform's Liquid Glass material. Requires
1750
+ * `native`, and iOS 26 or later; ignored anywhere else.
1751
+ */
1752
+ glass?: boolean;
1753
+ }
1754
+
1755
+ /**
1756
+ * The button that opens the search surface. Goes in `Panelside.Header`'s
1757
+ * `action` slot.
1758
+ *
1759
+ * It toggles the root's `searchOpen`, which `Panelside.SearchSheet` reads —
1760
+ * so the two need nothing wired between them.
1761
+ */
1762
+ function PanelsideSearchTrigger({
1763
+ className,
1764
+ label = 'Search',
1765
+ children,
1766
+ native = false,
1767
+ glass = false,
1768
+ onPress,
1769
+ ...props
1770
+ }: PanelsideSearchTriggerProps) {
1771
+ const { setSearchOpen } = usePanelsideContext('Panelside.SearchTrigger');
1772
+ const tint = useCSSVariable('--color-foreground');
1773
+ const color = typeof tint === 'string' ? tint : undefined;
1774
+
1775
+ const open = useCallback(
1776
+ (event: Parameters<NonNullable<PressableProps['onPress']>>[0]) => {
1777
+ onPress?.(event);
1778
+ setSearchOpen(true);
1779
+ },
1780
+ [onPress, setSearchOpen]
1781
+ );
1782
+
1783
+ const glyph = children ?? <SearchIcon size={18} color={native ? color : undefined} />;
1784
+
1785
+ if (native) {
1786
+ return (
1787
+ <Button
1788
+ native
1789
+ glass={glass}
1790
+ size="icon"
1791
+ variant="ghost"
1792
+ accessibilityLabel={label}
1793
+ onPress={open}
1794
+ >
1795
+ {glyph}
1796
+ </Button>
1797
+ );
1798
+ }
1799
+
1800
+ return (
1801
+ <AnimatedPressable
1802
+ {...props}
1803
+ onPress={open}
1804
+ className={cn(
1805
+ // Filled rather than outlined. It sits on the panel's own surface with
1806
+ // nothing else on that row, so an outline at this size reads as an
1807
+ // empty circle before it reads as a control.
1808
+ 'h-10 w-10 items-center justify-center rounded-full bg-secondary',
1809
+ className
1810
+ )}
1811
+ accessibilityRole="button"
1812
+ accessibilityLabel={label}
1813
+ >
1814
+ <IconColorProvider color={color}>{glyph}</IconColorProvider>
1815
+ </AnimatedPressable>
1816
+ );
1817
+ }
1818
+
1819
+ interface PanelsideSearchSheetContextValue {
1820
+ /**
1821
+ * Whether the sheet is up.
1822
+ *
1823
+ * The field needs it, and cannot read it from being mounted: under `native`
1824
+ * the platform owns presentation, so the content stays mounted for the life
1825
+ * of the screen and only `isPresented` changes. An `autoFocus` on a field
1826
+ * inside it would fire at app start, opening the keyboard over a sheet
1827
+ * nobody has asked for.
1828
+ */
1829
+ open: boolean;
1830
+ query: string;
1831
+ setQuery: (query: string) => void;
1832
+ tab: string;
1833
+ setTab: (tab: string) => void;
1834
+ close: () => void;
1835
+ /** The inset the field already sits above, so docking does not travel it twice. */
1836
+ bottomInset: number;
1837
+ }
1838
+
1839
+ const PanelsideSearchSheetContext = createContext<PanelsideSearchSheetContextValue | null>(
1840
+ null
1841
+ );
1842
+
1843
+ function usePanelsideSearchSheet(part: string): PanelsideSearchSheetContextValue {
1844
+ const value = useContext(PanelsideSearchSheetContext);
1845
+ if (!value) throw new Error(`${part} must be used inside a <Panelside.SearchSheet>.`);
1846
+ return value;
1847
+ }
1848
+
1849
+ export interface PanelsideSearchSheetProps {
1850
+ className?: string;
1851
+ /** Controlled. Omit it and the sheet follows the root's `searchOpen`. */
1852
+ open?: boolean;
1853
+ onOpenChange?: (open: boolean) => void;
1854
+ /** The query. Controlled; pair it with `onValueChange`. */
1855
+ value?: string;
1856
+ defaultValue?: string;
1857
+ onValueChange?: (value: string) => void;
1858
+ /** Which tab is selected — the `value` of a `Panelside.SearchTab`. */
1859
+ tab?: string;
1860
+ /**
1861
+ * Which tab starts selected, when the sheet is not controlling `tab`. Set it
1862
+ * to the first tab's `value`: the expanding row shows the selected tab open
1863
+ * and the rest as their icons, so with nothing selected every tab is closed
1864
+ * and the row is a line of unlabelled glyphs.
1865
+ */
1866
+ defaultTab?: string;
1867
+ onTabChange?: (tab: string) => void;
1868
+ /**
1869
+ * Present the platform's own sheet. Default true, because this one is the
1870
+ * whole screen and the system's presentation, detents and dismiss gesture
1871
+ * are the ones people already know. Requires the optional `@expo/ui`
1872
+ * package; without it the styled sheet renders instead.
1873
+ */
1874
+ native?: boolean;
1875
+ /** Heights the sheet can rest at. Defaults to the tall one. */
1876
+ snapPoints?: BottomSheetProps['snapPoints'];
1877
+ /** Gap between the field and the top of the keyboard. */
1878
+ keyboardGap?: number;
1879
+ /**
1880
+ * Draw the round dismiss button at the leading edge of the top row. On by
1881
+ * default — it is the way out of a surface that covers the screen, and it
1882
+ * belongs where a thumb reaching across arrives rather than in the corner
1883
+ * furthest from one.
1884
+ */
1885
+ showClose?: boolean;
1886
+ /** What a screen reader announces for that button. */
1887
+ closeLabel?: string;
1888
+ children?: ReactNode;
1889
+ }
1890
+
1891
+ /**
1892
+ * The search surface: tabs, results, and a field at the bottom.
1893
+ *
1894
+ * ```tsx
1895
+ * <Panelside.SearchSheet value={query} onValueChange={setQuery} tab={tab} onTabChange={setTab}>
1896
+ * <Panelside.SearchTabs>
1897
+ * <Panelside.SearchTab value="all" icon={<SparklesIcon size={16} />}>All</Panelside.SearchTab>
1898
+ * <Panelside.SearchTab value="chats" icon={<MessageCircleIcon size={16} />}>Chats</Panelside.SearchTab>
1899
+ * </Panelside.SearchTabs>
1900
+ *
1901
+ * <Panelside.SearchResults>
1902
+ * {hits.map((hit) => (
1903
+ * <Panelside.SearchResult key={hit.id} title={hit.title} description={hit.kind} />
1904
+ * ))}
1905
+ * </Panelside.SearchResults>
1906
+ *
1907
+ * <Panelside.SearchField placeholder="Search chats" />
1908
+ * </Panelside.SearchSheet>
1909
+ * ```
1910
+ *
1911
+ * Put it under `<Panelside>` and outside `Panelside.Panel` — a sheet is
1912
+ * presented over the whole app, and the panel is a layer that slides.
1913
+ *
1914
+ * It reports what was typed and which tab is selected. What counts as a match,
1915
+ * and what a result is, are yours: a search that only read chat titles would
1916
+ * be wrong for the first app that indexes message bodies.
1917
+ */
1918
+ function PanelsideSearchSheet({
1919
+ className,
1920
+ open: openProp,
1921
+ onOpenChange,
1922
+ value,
1923
+ defaultValue = '',
1924
+ onValueChange,
1925
+ tab,
1926
+ defaultTab = '',
1927
+ onTabChange,
1928
+ native = true,
1929
+ snapPoints,
1930
+ keyboardGap = 10,
1931
+ showClose = true,
1932
+ closeLabel = 'Close search',
1933
+ children,
1934
+ }: PanelsideSearchSheetProps) {
1935
+ const { searchOpen, setSearchOpen } = usePanelsideContext('Panelside.SearchSheet');
1936
+ const insets = useSafeAreaInsets();
1937
+ const { height: screenHeight } = useWindowDimensions();
1938
+ const tint = useCSSVariable('--color-foreground');
1939
+ const glyph = typeof tint === 'string' ? tint : undefined;
1940
+
1941
+ const [uncontrolledQuery, setUncontrolledQuery] = useState(defaultValue);
1942
+ const [uncontrolledTab, setUncontrolledTab] = useState(defaultTab);
1943
+
1944
+ const open = openProp ?? searchOpen;
1945
+ const query = value ?? uncontrolledQuery;
1946
+ const activeTab = tab ?? uncontrolledTab;
1947
+
1948
+ const setOpen = useCallback(
1949
+ (next: boolean) => {
1950
+ if (openProp === undefined) setSearchOpen(next);
1951
+ onOpenChange?.(next);
1952
+ },
1953
+ [onOpenChange, openProp, setSearchOpen]
1954
+ );
1955
+
1956
+ const setQuery = useCallback(
1957
+ (next: string) => {
1958
+ if (value === undefined) setUncontrolledQuery(next);
1959
+ onValueChange?.(next);
1960
+ },
1961
+ [onValueChange, value]
1962
+ );
1963
+
1964
+ const setTab = useCallback(
1965
+ (next: string) => {
1966
+ if (tab === undefined) setUncontrolledTab(next);
1967
+ onTabChange?.(next);
1968
+ },
1969
+ [onTabChange, tab]
1970
+ );
1971
+
1972
+ const close = useCallback(() => setOpen(false), [setOpen]);
1973
+
1974
+ const detents = snapPoints ?? (['full'] as const satisfies BottomSheetProps['snapPoints']);
1975
+
1976
+ /*
1977
+ * What `BottomSheet.Content` pads the bottom of the sheet by. The field
1978
+ * already sits that far above the screen edge, and docking travels by the
1979
+ * keyboard's height less whatever the element has already cleared — so
1980
+ * getting this wrong is a gap under the field, or the field over the
1981
+ * keyboard's top row.
1982
+ */
1983
+ const bottomInset = Math.max(insets.bottom, SHEET_BOTTOM_PADDING) - keyboardGap;
1984
+
1985
+ /*
1986
+ * A definite height for the column, not `flex-1` against the sheet's own.
1987
+ *
1988
+ * The platform sheet gives its hosted content a *minimum* height, and a
1989
+ * minimum is not something `flex-1` can divide: the results list sizes to
1990
+ * its own rows instead, grows past the sheet, and pushes the field off the
1991
+ * bottom — which is a search surface with no way to type in it. So the
1992
+ * column is told exactly how tall it is, and the list gets the room left
1993
+ * between the tabs and the field.
1994
+ *
1995
+ * Only under the platform sheet. The styled one is laid out by us and has a
1996
+ * real height already, so `flex-1` resolves there and a second opinion about
1997
+ * how tall the sheet is would only be a chance to disagree with it.
1998
+ */
1999
+ const hosted = native && getNativeUI() !== null;
2000
+ const columnHeight = hosted
2001
+ ? (bottomSheetDetentHeight(detents as BottomSheetProps['snapPoints'], screenHeight) ??
2002
+ screenHeight * 0.9) -
2003
+ SHEET_TOP_PADDING -
2004
+ Math.max(insets.bottom, SHEET_BOTTOM_PADDING)
2005
+ : undefined;
2006
+
2007
+ const context = useMemo<PanelsideSearchSheetContextValue>(
2008
+ () => ({ open, query, setQuery, tab: activeTab, setTab, close, bottomInset }),
2009
+ [activeTab, bottomInset, close, open, query, setQuery, setTab]
2010
+ );
2011
+
2012
+ return (
2013
+ <BottomSheet
2014
+ native={native}
2015
+ open={open}
2016
+ onOpenChange={setOpen}
2017
+ snapPoints={detents as BottomSheetProps['snapPoints']}
2018
+ >
2019
+ {/*
2020
+ `showClose` off on the sheet itself: this surface draws its own, at the
2021
+ leading edge of the top row, where the reference for this pattern puts
2022
+ it and where a thumb reaching across the screen arrives.
2023
+ */}
2024
+ <BottomSheet.Content size="full" showClose={false} className="gap-0 px-0 pt-3">
2025
+ {/*
2026
+ The provider is *inside* Content, not around the sheet.
2027
+
2028
+ The styled sheet mounts its content through a portal, under the
2029
+ portal host and outside this component's subtree — so a provider
2030
+ wrapped around the sheet is a provider the children never see, and
2031
+ every part below throws about not being inside a `SearchSheet`. Only
2032
+ the native path happened to work, because the platform hosts the
2033
+ content in place.
2034
+ */}
2035
+ <PanelsideSearchSheetContext.Provider value={context}>
2036
+ <View
2037
+ className={cn('gap-2', columnHeight === undefined && 'flex-1', className)}
2038
+ style={columnHeight === undefined ? undefined : { height: columnHeight }}
2039
+ >
2040
+ {showClose ? (
2041
+ <View className="flex-row px-4 pb-1">
2042
+ <AnimatedPressable
2043
+ onPress={close}
2044
+ accessibilityRole="button"
2045
+ accessibilityLabel={closeLabel}
2046
+ className="h-9 w-9 items-center justify-center rounded-full bg-secondary"
2047
+ >
2048
+ <XIcon size={17} color={glyph} />
2049
+ </AnimatedPressable>
2050
+ </View>
2051
+ ) : null}
2052
+ {children}
2053
+ </View>
2054
+ </PanelsideSearchSheetContext.Provider>
2055
+ </BottomSheet.Content>
2056
+ </BottomSheet>
2057
+ );
2058
+ }
2059
+
2060
+ export interface PanelsideSearchTabsProps {
2061
+ className?: string;
2062
+ children?: ReactNode;
2063
+ }
2064
+
2065
+ /**
2066
+ * The row across the top of the search sheet, narrowing what is searched.
2067
+ *
2068
+ * The expanding variant: only the selected tab is open, and the rest are their
2069
+ * icons. A row of four full labels takes the whole width to say four words
2070
+ * nobody rereads, and this row has to leave the results the screen.
2071
+ *
2072
+ * Every `Panelside.SearchTab` therefore needs an `icon` — a closed tab has
2073
+ * nothing else to be.
2074
+ */
2075
+ function PanelsideSearchTabs({ className, children }: PanelsideSearchTabsProps) {
2076
+ const { tab, setTab } = usePanelsideSearchSheet('Panelside.SearchTabs');
2077
+
2078
+ return (
2079
+ <Tabs
2080
+ variant="expanding"
2081
+ value={tab}
2082
+ onValueChange={setTab}
2083
+ defaultValue={tab}
2084
+ className={cn('px-4 pt-1', className)}
2085
+ >
2086
+ <Tabs.List>{children}</Tabs.List>
2087
+ </Tabs>
2088
+ );
2089
+ }
2090
+
2091
+ export interface PanelsideSearchTabProps {
2092
+ className?: string;
2093
+ /** What selecting this tab reports as the sheet's `tab`. */
2094
+ value: string;
2095
+ /** Required: a closed tab is its icon and nothing else. */
2096
+ icon: ReactNode;
2097
+ children?: ReactNode;
2098
+ }
2099
+
2100
+ function PanelsideSearchTab({ className, value, icon, children }: PanelsideSearchTabProps) {
2101
+ return (
2102
+ <Tabs.Trigger value={value} icon={icon} className={className}>
2103
+ {children}
2104
+ </Tabs.Trigger>
2105
+ );
2106
+ }
2107
+
2108
+ export interface PanelsideSearchResultsProps extends BottomSheetBodyProps {
2109
+ className?: string;
2110
+ contentContainerClassName?: string;
2111
+ children?: ReactNode;
2112
+ }
2113
+
2114
+ /**
2115
+ * The scrolling middle of the search sheet.
2116
+ *
2117
+ * `BottomSheet.Body` rather than a `ScrollView`, so the list's scroll and the
2118
+ * sheet's dismiss drag agree on which of them a downward pull belongs to.
2119
+ */
2120
+ function PanelsideSearchResults({
2121
+ className,
2122
+ contentContainerClassName,
2123
+ children,
2124
+ ...props
2125
+ }: PanelsideSearchResultsProps) {
2126
+ usePanelsideSearchSheet('Panelside.SearchResults');
2127
+
2128
+ return (
2129
+ <BottomSheet.Body
2130
+ className={cn('flex-1', className)}
2131
+ contentContainerClassName={cn('gap-1 px-4 pb-4 pt-1', contentContainerClassName)}
2132
+ keyboardShouldPersistTaps="handled"
2133
+ keyboardDismissMode="interactive"
2134
+ {...props}
2135
+ >
2136
+ {textChildren(children)}
2137
+ </BottomSheet.Body>
2138
+ );
2139
+ }
2140
+
2141
+ export interface PanelsideSearchResultProps extends Omit<PressableProps, 'children'> {
2142
+ className?: string;
2143
+ /** Leading element — a thumbnail, an icon, an avatar. */
2144
+ media?: ReactNode;
2145
+ /** The result's name, truncated to one line. */
2146
+ title?: string;
2147
+ /** What kind of thing it is, or where it was found. */
2148
+ description?: string;
2149
+ children?: ReactNode;
2150
+ }
2151
+
2152
+ /**
2153
+ * One hit. A leading thumbnail, a title, and a line saying what it is.
2154
+ *
2155
+ * Taller than a `Panelside.Item`, and deliberately: a navigation row is a
2156
+ * place you already know the name of, and a result is a thing you are deciding
2157
+ * about — the second line is what the decision is made on.
2158
+ */
2159
+ function PanelsideSearchResult({
2160
+ className,
2161
+ media,
2162
+ title,
2163
+ description,
2164
+ children,
2165
+ ...props
2166
+ }: PanelsideSearchResultProps) {
2167
+ const tint = useCSSVariable('--color-muted-foreground');
2168
+ const color = typeof tint === 'string' ? tint : undefined;
2169
+
2170
+ return (
2171
+ <AnimatedPressable
2172
+ className={cn('flex-row items-center gap-3 rounded-xl px-2 py-2', className)}
2173
+ accessibilityRole="button"
2174
+ accessibilityLabel={title}
2175
+ pressScale={0.985}
2176
+ {...props}
2177
+ >
2178
+ {media ? (
2179
+ <View className="h-11 w-11 items-center justify-center overflow-hidden rounded-xl bg-secondary">
2180
+ <IconColorProvider color={color}>{media}</IconColorProvider>
2181
+ </View>
2182
+ ) : null}
2183
+ {title !== undefined || description !== undefined ? (
2184
+ <View className="flex-1 gap-0.5">
2185
+ {title !== undefined ? (
2186
+ <Text size="base" weight="medium" numberOfLines={1}>
2187
+ {title}
2188
+ </Text>
2189
+ ) : null}
2190
+ {description !== undefined ? (
2191
+ <Text size="sm" muted numberOfLines={1}>
2192
+ {description}
2193
+ </Text>
2194
+ ) : null}
2195
+ </View>
2196
+ ) : null}
2197
+ {children}
2198
+ </AnimatedPressable>
2199
+ );
2200
+ }
2201
+
2202
+ export interface PanelsideSearchFieldProps extends TextInputProps {
2203
+ className?: string;
2204
+ containerClassName?: string;
2205
+ }
2206
+
2207
+ /**
2208
+ * The field, at the bottom of the sheet, riding the keyboard.
2209
+ *
2210
+ * At the bottom because that is where the thumb is and where the keyboard
2211
+ * comes up: a field at the top of a full-height sheet is at the far end of the
2212
+ * screen from both, and every character typed into it is read at the other
2213
+ * end of a list that is moving.
2214
+ *
2215
+ * The way out of the surface is not here — `Panelside.SearchSheet` draws it at
2216
+ * the leading edge of the top row, so the dismiss control does not move with
2217
+ * the keyboard and is not one mis-tap away from the field.
2218
+ */
2219
+ function PanelsideSearchField({
2220
+ className,
2221
+ containerClassName,
2222
+ placeholder = 'Search',
2223
+ value,
2224
+ onChangeText,
2225
+ ...props
2226
+ }: PanelsideSearchFieldProps) {
2227
+ const { open, query, setQuery, bottomInset } = usePanelsideSearchSheet(
2228
+ 'Panelside.SearchField'
2229
+ );
2230
+ const placeholderTint = useCSSVariable('--color-muted-foreground');
2231
+ const textTint = useCSSVariable('--color-foreground');
2232
+ const muted = typeof placeholderTint === 'string' ? placeholderTint : undefined;
2233
+ const field = useRef<TextInput>(null);
2234
+
2235
+ const text = value ?? query;
2236
+ const change = onChangeText ?? setQuery;
2237
+
2238
+ /*
2239
+ * Focus on the transition rather than with `autoFocus`. Under `native` the
2240
+ * sheet's content is mounted for the life of the screen and only
2241
+ * `isPresented` changes, so `autoFocus` fires once — at startup, on a sheet
2242
+ * that is not up — and the keyboard opens over whatever is.
2243
+ */
2244
+ useEffect(() => {
2245
+ if (open) field.current?.focus();
2246
+ else field.current?.blur();
2247
+ }, [open]);
2248
+
2249
+ return (
2250
+ <KeyboardAvoider
2251
+ mode="dock"
2252
+ active={open}
2253
+ bottomInset={bottomInset}
2254
+ className="w-full px-4 pb-1"
2255
+ >
2256
+ <View
2257
+ className={cn(
2258
+ 'h-12 w-full flex-row items-center gap-2 rounded-full bg-secondary px-4',
2259
+ containerClassName
2260
+ )}
2261
+ >
2262
+ <SearchIcon size={17} color={muted} />
2263
+ <TextInput
2264
+ ref={field}
2265
+ value={text}
2266
+ onChangeText={change}
2267
+ placeholder={placeholder}
2268
+ placeholderTextColor={muted}
2269
+ /* `text-[16px]` rather than a `text-*` step — see Panelside.Search. */
2270
+ className={cn('h-full flex-1 font-normal text-[16px] text-foreground', className)}
2271
+ style={typeof textTint === 'string' ? { color: textTint } : undefined}
2272
+ accessibilityRole="search"
2273
+ returnKeyType="search"
2274
+ {...props}
2275
+ />
2276
+ {text.length > 0 ? (
2277
+ <AnimatedPressable
2278
+ onPress={() => change('')}
2279
+ hitSlop={8}
2280
+ accessibilityRole="button"
2281
+ accessibilityLabel="Clear search"
2282
+ className="h-5 w-5 items-center justify-center rounded-full bg-muted"
2283
+ >
2284
+ <XIcon size={12} color={muted} />
2285
+ </AnimatedPressable>
2286
+ ) : null}
2287
+ </View>
2288
+
2289
+ </KeyboardAvoider>
2290
+ );
2291
+ }
2292
+
1585
2293
  PanelsidePanel.displayName = 'Panelside.Panel';
1586
2294
  PanelsideHeader.displayName = 'Panelside.Header';
1587
2295
  PanelsideSearch.displayName = 'Panelside.Search';
@@ -1597,6 +2305,13 @@ PanelsideFooter.displayName = 'Panelside.Footer';
1597
2305
  PanelsideCta.displayName = 'Panelside.Cta';
1598
2306
  PanelsideScene.displayName = 'Panelside.Scene';
1599
2307
  PanelsideTrigger.displayName = 'Panelside.Trigger';
2308
+ PanelsideSearchTrigger.displayName = 'Panelside.SearchTrigger';
2309
+ PanelsideSearchSheet.displayName = 'Panelside.SearchSheet';
2310
+ PanelsideSearchTabs.displayName = 'Panelside.SearchTabs';
2311
+ PanelsideSearchTab.displayName = 'Panelside.SearchTab';
2312
+ PanelsideSearchResults.displayName = 'Panelside.SearchResults';
2313
+ PanelsideSearchResult.displayName = 'Panelside.SearchResult';
2314
+ PanelsideSearchField.displayName = 'Panelside.SearchField';
1600
2315
 
1601
2316
  export const Panelside = Object.assign(PanelsideRoot, {
1602
2317
  Panel: PanelsidePanel,
@@ -1614,4 +2329,11 @@ export const Panelside = Object.assign(PanelsideRoot, {
1614
2329
  Cta: PanelsideCta,
1615
2330
  Scene: PanelsideScene,
1616
2331
  Trigger: PanelsideTrigger,
2332
+ SearchTrigger: PanelsideSearchTrigger,
2333
+ SearchSheet: PanelsideSearchSheet,
2334
+ SearchTabs: PanelsideSearchTabs,
2335
+ SearchTab: PanelsideSearchTab,
2336
+ SearchResults: PanelsideSearchResults,
2337
+ SearchResult: PanelsideSearchResult,
2338
+ SearchField: PanelsideSearchField,
1617
2339
  });