panelui-native 0.80.0 → 0.82.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 (92) hide show
  1. package/lib/module/components/bottom-sheet/index.js +90 -17
  2. package/lib/module/components/bottom-sheet/index.js.map +1 -1
  3. package/lib/module/components/button/index.js +13 -0
  4. package/lib/module/components/button/index.js.map +1 -1
  5. package/lib/module/components/button-group/index.js +3 -0
  6. package/lib/module/components/button-group/index.js.map +1 -1
  7. package/lib/module/components/image-generation/dot-field.js +210 -0
  8. package/lib/module/components/image-generation/dot-field.js.map +1 -0
  9. package/lib/module/components/image-generation/index.js +417 -0
  10. package/lib/module/components/image-generation/index.js.map +1 -0
  11. package/lib/module/components/kpi/index.js +2 -4
  12. package/lib/module/components/kpi/index.js.map +1 -1
  13. package/lib/module/components/menu/index.js +8 -16
  14. package/lib/module/components/menu/index.js.map +1 -1
  15. package/lib/module/components/panelside/index.js +340 -33
  16. package/lib/module/components/panelside/index.js.map +1 -1
  17. package/lib/module/components/planner/index.js +446 -32
  18. package/lib/module/components/planner/index.js.map +1 -1
  19. package/lib/module/components/planner/planner-entries.js +36 -0
  20. package/lib/module/components/planner/planner-entries.js.map +1 -1
  21. package/lib/module/components/planner/planner-grid-navigation.js +34 -2
  22. package/lib/module/components/planner/planner-grid-navigation.js.map +1 -1
  23. package/lib/module/components/planner/planner-weeks.js +95 -0
  24. package/lib/module/components/planner/planner-weeks.js.map +1 -0
  25. package/lib/module/components/select/index.js +127 -23
  26. package/lib/module/components/select/index.js.map +1 -1
  27. package/lib/module/components/slide-button/index.js +610 -0
  28. package/lib/module/components/slide-button/index.js.map +1 -0
  29. package/lib/module/components/slide-button/slide-button-track.js +98 -0
  30. package/lib/module/components/slide-button/slide-button-track.js.map +1 -0
  31. package/lib/module/icons/index.js +553 -1934
  32. package/lib/module/icons/index.js.map +1 -1
  33. package/lib/module/index.js +3 -1
  34. package/lib/module/index.js.map +1 -1
  35. package/lib/module/primitives/portal.js +15 -1
  36. package/lib/module/primitives/portal.js.map +1 -1
  37. package/lib/module/utils/date.js +10 -3
  38. package/lib/module/utils/date.js.map +1 -1
  39. package/lib/typescript/src/components/bottom-sheet/index.d.ts.map +1 -1
  40. package/lib/typescript/src/components/button/index.d.ts +12 -0
  41. package/lib/typescript/src/components/button/index.d.ts.map +1 -1
  42. package/lib/typescript/src/components/button-group/index.d.ts +9 -0
  43. package/lib/typescript/src/components/button-group/index.d.ts.map +1 -1
  44. package/lib/typescript/src/components/image-generation/dot-field.d.ts +61 -0
  45. package/lib/typescript/src/components/image-generation/dot-field.d.ts.map +1 -0
  46. package/lib/typescript/src/components/image-generation/index.d.ts +126 -0
  47. package/lib/typescript/src/components/image-generation/index.d.ts.map +1 -0
  48. package/lib/typescript/src/components/kpi/index.d.ts.map +1 -1
  49. package/lib/typescript/src/components/menu/index.d.ts.map +1 -1
  50. package/lib/typescript/src/components/panelside/index.d.ts +194 -10
  51. package/lib/typescript/src/components/panelside/index.d.ts.map +1 -1
  52. package/lib/typescript/src/components/planner/index.d.ts +65 -4
  53. package/lib/typescript/src/components/planner/index.d.ts.map +1 -1
  54. package/lib/typescript/src/components/planner/planner-entries.d.ts +18 -0
  55. package/lib/typescript/src/components/planner/planner-entries.d.ts.map +1 -1
  56. package/lib/typescript/src/components/planner/planner-grid-navigation.d.ts +7 -1
  57. package/lib/typescript/src/components/planner/planner-grid-navigation.d.ts.map +1 -1
  58. package/lib/typescript/src/components/planner/planner-weeks.d.ts +50 -0
  59. package/lib/typescript/src/components/planner/planner-weeks.d.ts.map +1 -0
  60. package/lib/typescript/src/components/select/index.d.ts +75 -2
  61. package/lib/typescript/src/components/select/index.d.ts.map +1 -1
  62. package/lib/typescript/src/components/slide-button/index.d.ts +317 -0
  63. package/lib/typescript/src/components/slide-button/index.d.ts.map +1 -0
  64. package/lib/typescript/src/components/slide-button/slide-button-track.d.ts +70 -0
  65. package/lib/typescript/src/components/slide-button/slide-button-track.d.ts.map +1 -0
  66. package/lib/typescript/src/icons/index.d.ts +136 -189
  67. package/lib/typescript/src/icons/index.d.ts.map +1 -1
  68. package/lib/typescript/src/index.d.ts +5 -3
  69. package/lib/typescript/src/index.d.ts.map +1 -1
  70. package/lib/typescript/src/primitives/portal.d.ts.map +1 -1
  71. package/lib/typescript/src/utils/date.d.ts +1 -1
  72. package/lib/typescript/src/utils/date.d.ts.map +1 -1
  73. package/package.json +3 -2
  74. package/src/components/bottom-sheet/index.tsx +97 -17
  75. package/src/components/button/index.tsx +10 -2
  76. package/src/components/button-group/index.tsx +1 -0
  77. package/src/components/image-generation/dot-field.ts +226 -0
  78. package/src/components/image-generation/index.tsx +488 -0
  79. package/src/components/kpi/index.tsx +2 -4
  80. package/src/components/menu/index.tsx +10 -15
  81. package/src/components/panelside/index.tsx +498 -33
  82. package/src/components/planner/index.tsx +516 -38
  83. package/src/components/planner/planner-entries.ts +33 -0
  84. package/src/components/planner/planner-grid-navigation.ts +37 -2
  85. package/src/components/planner/planner-weeks.ts +90 -0
  86. package/src/components/select/index.tsx +158 -31
  87. package/src/components/slide-button/index.tsx +681 -0
  88. package/src/components/slide-button/slide-button-track.ts +100 -0
  89. package/src/icons/index.tsx +338 -1327
  90. package/src/index.ts +30 -0
  91. package/src/primitives/portal.tsx +15 -1
  92. package/src/utils/date.ts +13 -3
@@ -77,6 +77,7 @@
77
77
  * rather than travelling.
78
78
  */
79
79
  import {
80
+ Children,
80
81
  cloneElement,
81
82
  createContext,
82
83
  isValidElement,
@@ -90,6 +91,7 @@ import {
90
91
  type ReactNode,
91
92
  } from 'react';
92
93
  import {
94
+ Keyboard,
93
95
  Platform,
94
96
  Pressable,
95
97
  ScrollView,
@@ -117,7 +119,12 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
117
119
  import { tv } from 'tailwind-variants';
118
120
  import { useCSSVariable } from 'uniwind';
119
121
  import { LinearGradient } from 'expo-linear-gradient';
120
- import { EllipsisIcon, IconColorProvider, MenuIcon, SearchIcon, XIcon } from '../../icons';
122
+ import { HugeiconsIcon, type IconSvgElement } from '@hugeicons/react-native';
123
+ import Cancel01Icon from '@hugeicons/core-free-icons/Cancel01Icon';
124
+ import Menu01Icon from '@hugeicons/core-free-icons/Menu01Icon';
125
+ import MoreHorizontalIcon from '@hugeicons/core-free-icons/MoreHorizontalIcon';
126
+ import Search01Icon from '@hugeicons/core-free-icons/Search01Icon';
127
+ import { IconColorProvider, useIconColor } from '../../icons';
121
128
  import {
122
129
  BottomSheet,
123
130
  bottomSheetDetentHeight,
@@ -125,6 +132,7 @@ import {
125
132
  type BottomSheetProps,
126
133
  } from '../bottom-sheet';
127
134
  import { Button } from '../button';
135
+ import { Menu, type MenuContentProps } from '../menu';
128
136
  import { Tabs } from '../tabs';
129
137
  import { getNativeUI } from '../../native';
130
138
  import { AnimatedPressable } from '../../primitives/animated-pressable';
@@ -268,10 +276,64 @@ const clamp = (value: number, min: number, max: number) => {
268
276
  return Math.min(Math.max(value, min), max);
269
277
  };
270
278
 
279
+ /**
280
+ * The stroke weight the panel's glyphs are drawn at.
281
+ *
282
+ * A shade heavier than the icon set's own default. The panel sits over a
283
+ * dimmed screen and its rows are quiet by design, so a hairline glyph goes
284
+ * soft against them at the sizes used here.
285
+ */
286
+ const GLYPH_STROKE = 1.8;
287
+
288
+ /**
289
+ * What a glyph is drawn in when nothing has said. The same neutral the rest of
290
+ * the chrome falls back to — visible against either theme, and never the thing
291
+ * that decides how a panel looks, since every surface here provides a colour.
292
+ */
293
+ const GLYPH_FALLBACK = '#737373';
294
+
295
+ /**
296
+ * Floor for a row's action menu.
297
+ *
298
+ * Wide enough for a verb and its glyph with the label doing the growing. Left
299
+ * to size itself, a menu takes its width from the one thing in each row that
300
+ * is not flexible — the glyph — and comes up as a column of icons.
301
+ */
302
+ const ACTIONS_MIN_WIDTH = 220;
303
+
304
+ /**
305
+ * One of the panel's own glyphs.
306
+ *
307
+ * It exists to keep the colour contract the rest of the library has: an icon
308
+ * takes an explicit colour, then the one an enclosing surface is providing,
309
+ * and only then falls back. The drawing component underneath knows nothing
310
+ * about that inheritance and needs the resolved value handed to it.
311
+ */
312
+ function Glyph({
313
+ icon,
314
+ size = 20,
315
+ color,
316
+ }: {
317
+ icon: IconSvgElement;
318
+ size?: number;
319
+ color?: string;
320
+ }) {
321
+ const inherited = useIconColor();
322
+
323
+ return (
324
+ <HugeiconsIcon
325
+ icon={icon}
326
+ size={size}
327
+ color={color ?? inherited ?? GLYPH_FALLBACK}
328
+ strokeWidth={GLYPH_STROKE}
329
+ />
330
+ );
331
+ }
332
+
271
333
  export type PanelsideMode = 'push' | 'overlay';
272
334
  export type PanelsideSwipeFrom = 'anywhere' | 'edge';
273
335
  export type PanelsideItemSize = 'default' | 'sm';
274
- export type PanelsideCtaSize = 'default' | 'lg';
336
+ export type PanelsideCtaSize = 'default' | 'lg' | 'xl';
275
337
  /**
276
338
  * What a header or a footer paints behind itself.
277
339
  *
@@ -281,6 +343,8 @@ export type PanelsideCtaSize = 'default' | 'lg';
281
343
  * that is a row of the layout rather than something floating over one.
282
344
  */
283
345
  export type PanelsideSurface = 'transparent' | 'fade' | 'solid';
346
+ /** How the panel's small round controls are drawn: a fill, or a ring. */
347
+ export type PanelsideControlVariant = 'filled' | 'outline';
284
348
 
285
349
  const itemVariants = tv({
286
350
  // No width: in a group it stretches on its own, and pinning it to full width
@@ -322,6 +386,7 @@ const ctaVariants = tv({
322
386
  size: {
323
387
  default: 'h-11 gap-2 px-6',
324
388
  lg: 'h-13 gap-2.5 px-7',
389
+ xl: 'h-14 gap-3 px-8',
325
390
  },
326
391
  },
327
392
  defaultVariants: {
@@ -356,6 +421,15 @@ interface PanelsideContextValue {
356
421
  */
357
422
  searchOpen: boolean;
358
423
  setSearchOpen: (open: boolean) => void;
424
+ /**
425
+ * The page the scene is showing.
426
+ *
427
+ * On the root for the same reason `searchOpen` is: the rows that navigate
428
+ * are in the panel and the pages are in the scene, which are different
429
+ * subtrees. Anything else is one `useState` every app threads through both.
430
+ */
431
+ route: string;
432
+ navigate: (route: string) => void;
359
433
  }
360
434
 
361
435
  const PanelsideContext = createContext<PanelsideContextValue | null>(null);
@@ -383,6 +457,10 @@ export interface UsePanelsideResult {
383
457
  /** Whether the search surface is up. `Panelside.SearchTrigger` sets it. */
384
458
  searchOpen: boolean;
385
459
  setSearchOpen: (open: boolean) => void;
460
+ /** The page the scene is showing — a `Panelside.Page`'s `value`. */
461
+ route: string;
462
+ /** Go to a page, closing the panel on the way. */
463
+ navigate: (route: string) => void;
386
464
  }
387
465
 
388
466
  /**
@@ -391,9 +469,20 @@ export interface UsePanelsideResult {
391
469
  * lives.
392
470
  */
393
471
  export function usePanelside(): UsePanelsideResult {
394
- const { open, setOpen, toggle, progress, docked, searchOpen, setSearchOpen } =
472
+ const { open, setOpen, toggle, progress, docked, searchOpen, setSearchOpen, route, navigate } =
395
473
  usePanelsideContext('usePanelside');
396
- return { open, setOpen, toggle, progress, docked, searchOpen, setSearchOpen };
474
+
475
+ // `navigate` closes the panel. The root cannot do it there without making
476
+ // its own `setOpen` a dependency of the callback every row holds.
477
+ const go = useCallback(
478
+ (next: string) => {
479
+ navigate(next);
480
+ setOpen(false);
481
+ },
482
+ [navigate, setOpen]
483
+ );
484
+
485
+ return { open, setOpen, toggle, progress, docked, searchOpen, setSearchOpen, route, navigate: go };
397
486
  }
398
487
 
399
488
  /**
@@ -489,6 +578,19 @@ export interface PanelsideProps {
489
578
  radius?: number;
490
579
  /** How far the scene is dimmed at full open, 0 to 1. */
491
580
  dim?: number;
581
+ /**
582
+ * Which page the scene is showing. Controlled; pair it with `onRouteChange`.
583
+ *
584
+ * A route is any string you choose. It is matched against
585
+ * `Panelside.Page`'s `value` and against `Panelside.Item`'s `to`, so a row
586
+ * marks itself as the current destination and the scene swaps to the page
587
+ * without either being wired to the other.
588
+ */
589
+ route?: string;
590
+ /** Which page the scene starts on, when the panel is not controlling `route`. */
591
+ defaultRoute?: string;
592
+ /** Called with the route a row navigated to. */
593
+ onRouteChange?: (route: string) => void;
492
594
  className?: string;
493
595
  }
494
596
 
@@ -508,6 +610,9 @@ function PanelsideRoot({
508
610
  scale,
509
611
  radius,
510
612
  dim,
613
+ route: controlledRoute,
614
+ defaultRoute = '',
615
+ onRouteChange,
511
616
  className,
512
617
  }: PanelsideProps) {
513
618
  const { width: windowWidth } = useWindowDimensions();
@@ -518,6 +623,23 @@ function PanelsideRoot({
518
623
  const open = controlled ? controlledOpen : uncontrolledOpen;
519
624
  const [searchOpen, setSearchOpen] = useState(false);
520
625
 
626
+ const [uncontrolledRoute, setUncontrolledRoute] = useState(defaultRoute);
627
+ const route = controlledRoute ?? uncontrolledRoute;
628
+
629
+ /*
630
+ * Navigating closes the panel, and does so here rather than at each call
631
+ * site. The thing you just moved to would otherwise be behind the thing you
632
+ * moved from, which is the one arrangement no app wants — and a row that has
633
+ * to be told to close is a row every app writes the same three lines for.
634
+ */
635
+ const navigate = useCallback(
636
+ (next: string) => {
637
+ if (controlledRoute === undefined) setUncontrolledRoute(next);
638
+ onRouteChange?.(next);
639
+ },
640
+ [controlledRoute, onRouteChange]
641
+ );
642
+
521
643
  /*
522
644
  * Measured rather than taken from the window, because Panelside does not
523
645
  * have to be the whole screen — it can be one tab of a larger layout, and
@@ -695,15 +817,19 @@ function PanelsideRoot({
695
817
  dim,
696
818
  searchOpen,
697
819
  setSearchOpen,
820
+ route,
821
+ navigate,
698
822
  }),
699
823
  [
700
824
  dim,
701
825
  dismissible,
702
826
  docked,
703
827
  mode,
828
+ navigate,
704
829
  open,
705
830
  progress,
706
831
  radius,
832
+ route,
707
833
  scale,
708
834
  searchOpen,
709
835
  setOpen,
@@ -908,7 +1034,7 @@ function PanelsideSearch({
908
1034
  containerClassName
909
1035
  )}
910
1036
  >
911
- <SearchIcon size={16} color={muted} />
1037
+ <Glyph icon={Search01Icon} size={16} color={muted} />
912
1038
  <TextInput
913
1039
  placeholder={placeholder}
914
1040
  placeholderTextColor={muted}
@@ -1032,7 +1158,20 @@ export interface PanelsideItemProps extends Omit<PressableProps, 'children'> {
1032
1158
  * shorthand for `Panelside.ItemLabel`.
1033
1159
  */
1034
1160
  label?: string;
1035
- /** Marks the row as the current destination. */
1161
+ /**
1162
+ * The page this row goes to — a `Panelside.Page`'s `value`.
1163
+ *
1164
+ * Pressing it sets the panel's route, and the row marks itself active while
1165
+ * that route is the current one. It also closes the panel, since the thing
1166
+ * you just moved to would otherwise be behind the thing you moved from.
1167
+ *
1168
+ * `active` and `onPress` still win where they are passed, so a row can
1169
+ * navigate and do something else as well.
1170
+ */
1171
+ to?: string;
1172
+ /** Leave the panel open after navigating. Off by default. */
1173
+ closeOnNavigate?: boolean;
1174
+ /** Marks the row as the current destination. Derived from `to` when given. */
1036
1175
  active?: boolean;
1037
1176
  /**
1038
1177
  * Trailing count or status. A number or string renders as a pill; anything
@@ -1081,16 +1220,34 @@ function PanelsideItem({
1081
1220
  className,
1082
1221
  icon,
1083
1222
  label,
1084
- active = false,
1223
+ to,
1224
+ closeOnNavigate = true,
1225
+ active: activeProp,
1085
1226
  badge,
1086
1227
  disabled = false,
1087
1228
  size = 'default',
1229
+ onPress,
1088
1230
  children,
1089
1231
  ...props
1090
1232
  }: PanelsideItemProps) {
1233
+ const panel = useContext(PanelsideContext);
1091
1234
  const restTint = useCSSVariable('--color-muted-foreground');
1092
1235
  const activeTint = useCSSVariable('--color-foreground');
1093
1236
 
1237
+ // A row outside a `<Panelside>` still works — the parts are usable on their
1238
+ // own — it simply has no route to match against.
1239
+ const active = activeProp ?? (to !== undefined && panel?.route === to);
1240
+
1241
+ const press = useCallback(
1242
+ (event: Parameters<NonNullable<PressableProps['onPress']>>[0]) => {
1243
+ onPress?.(event);
1244
+ if (to === undefined || !panel) return;
1245
+ panel.navigate(to);
1246
+ if (closeOnNavigate) panel.setOpen(false);
1247
+ },
1248
+ [closeOnNavigate, onPress, panel, to]
1249
+ );
1250
+
1094
1251
  const tint = active
1095
1252
  ? typeof activeTint === 'string'
1096
1253
  ? activeTint
@@ -1110,8 +1267,16 @@ function PanelsideItem({
1110
1267
  * that, and a written-out `Panelside.ItemLabel` supplies it too — so the
1111
1268
  * spacer is only for the row that has neither, which is a row of nothing but
1112
1269
  * an icon and a badge.
1270
+ *
1271
+ * And only where there is something to push. A row of nothing but an icon
1272
+ * has no trailing content, and a spacer in it is not inert: laid out against
1273
+ * the width available rather than against the row's own contents, it expands,
1274
+ * takes the space with it, and squeezes whatever shares the line — which is
1275
+ * how an icon-only row in a footer beside a compose button cropped that
1276
+ * button's label and left its own icon short of the trailing edge.
1113
1277
  */
1114
1278
  const filled = label !== undefined || children !== undefined;
1279
+ const trailing = badge !== undefined && badge !== null;
1115
1280
 
1116
1281
  return (
1117
1282
  <PanelsideItemContext.Provider value={context}>
@@ -1122,13 +1287,14 @@ function PanelsideItem({
1122
1287
  accessibilityState={{ selected: active, disabled }}
1123
1288
  accessibilityLabel={label}
1124
1289
  pressScale={0.985}
1290
+ onPress={to !== undefined || onPress ? press : undefined}
1125
1291
  {...props}
1126
1292
  >
1127
1293
  {icon ? <PanelsideItemIcon>{icon}</PanelsideItemIcon> : null}
1128
1294
 
1129
1295
  {label !== undefined ? <PanelsideItemLabel>{label}</PanelsideItemLabel> : null}
1130
1296
 
1131
- {filled ? null : <View className="flex-1" />}
1297
+ {filled || !trailing ? null : <View className="flex-1" />}
1132
1298
 
1133
1299
  {badge !== undefined && badge !== null ? (
1134
1300
  <PanelsideItemBadge>{badge}</PanelsideItemBadge>
@@ -1233,6 +1399,7 @@ function PanelsideAction({
1233
1399
  className,
1234
1400
  label = 'More options',
1235
1401
  children,
1402
+ onPress,
1236
1403
  ...props
1237
1404
  }: PanelsideActionProps) {
1238
1405
  const tint = useCSSVariable('--color-muted-foreground');
@@ -1246,13 +1413,97 @@ function PanelsideAction({
1246
1413
  // The glyph is small and sits next to a row-sized target, so it takes
1247
1414
  // the difference back as slop rather than as layout.
1248
1415
  hitSlop={8}
1416
+ // The row underneath is pressable and usually navigates. A press that
1417
+ // reached both would open the menu and leave the thing it is about.
1418
+ onPress={(event) => {
1419
+ event.stopPropagation();
1420
+ onPress?.(event);
1421
+ }}
1249
1422
  {...props}
1250
1423
  >
1251
- {children ?? <EllipsisIcon size={18} color={color} />}
1424
+ {children ?? <Glyph icon={MoreHorizontalIcon} size={18} color={color} />}
1252
1425
  </AnimatedPressable>
1253
1426
  );
1254
1427
  }
1255
1428
 
1429
+ export interface PanelsideItemActionsProps {
1430
+ className?: string;
1431
+ /**
1432
+ * What a screen reader announces for the button. The control is an
1433
+ * unlabelled glyph, so this is the only description it has.
1434
+ */
1435
+ label?: string;
1436
+ /** Replaces the default overflow glyph. */
1437
+ icon?: ReactNode;
1438
+ /** Where the panel opens relative to the button. Defaults to below it. */
1439
+ placement?: MenuContentProps['placement'];
1440
+ /** How it lines up on that edge. Defaults to the button's trailing edge. */
1441
+ align?: MenuContentProps['align'];
1442
+ /**
1443
+ * Floor for the menu's width. A panel sized to its contents takes its width
1444
+ * from whatever inside it is not flexible — in a row of a flexible label and
1445
+ * a fixed glyph, that is the glyph, and the menu comes up as a column of
1446
+ * icons with the words squeezed out of it.
1447
+ */
1448
+ minWidth?: number;
1449
+ /** Passed through to the panel — `width`, `maxHeight`, `offset` and the rest. */
1450
+ contentProps?: Omit<MenuContentProps, 'children' | 'placement' | 'align' | 'minWidth'>;
1451
+ /** The rows: `Menu.Item`, `Menu.Separator`, `Menu.Label`. */
1452
+ children?: ReactNode;
1453
+ }
1454
+
1455
+ /**
1456
+ * A row's actions, behind an overflow button at the end of it.
1457
+ *
1458
+ * ```tsx
1459
+ * <Panelside.Item label={chat.title} to={chat.id}>
1460
+ * <Panelside.ItemActions>
1461
+ * <Menu.Item onSelect={rename}>Rename</Menu.Item>
1462
+ * <Menu.Separator />
1463
+ * <Menu.Item variant="destructive" onSelect={remove}>Delete</Menu.Item>
1464
+ * </Panelside.ItemActions>
1465
+ * </Panelside.Item>
1466
+ * ```
1467
+ *
1468
+ * The panel is anchored to the button rather than presented from the bottom of
1469
+ * the screen, so it lines up with the row it belongs to and the list it came
1470
+ * from stays readable behind it. It is also narrow: the panel it opens in is a
1471
+ * fraction of the screen, and a sheet covering that to offer four verbs costs
1472
+ * more than it says.
1473
+ *
1474
+ * Pressing the button does not press the row. A row that navigates would
1475
+ * otherwise navigate away from the thing the menu is about.
1476
+ */
1477
+ function PanelsideItemActions({
1478
+ className,
1479
+ label = 'More options',
1480
+ icon,
1481
+ placement = 'bottom',
1482
+ align = 'end',
1483
+ minWidth = ACTIONS_MIN_WIDTH,
1484
+ contentProps,
1485
+ children,
1486
+ }: PanelsideItemActionsProps) {
1487
+ return (
1488
+ <Menu>
1489
+ <Menu.Trigger>
1490
+ <PanelsideAction className={className} label={label}>
1491
+ {icon}
1492
+ </PanelsideAction>
1493
+ </Menu.Trigger>
1494
+ <Menu.Content
1495
+ placement={placement}
1496
+ align={align}
1497
+ width="content-fit"
1498
+ minWidth={minWidth}
1499
+ {...contentProps}
1500
+ >
1501
+ {children}
1502
+ </Menu.Content>
1503
+ </Menu>
1504
+ );
1505
+ }
1506
+
1256
1507
  export interface PanelsideFooterProps extends ViewProps {
1257
1508
  className?: string;
1258
1509
  /**
@@ -1364,6 +1615,7 @@ function PanelsideFooter({
1364
1615
  const styles = StyleSheet.create({
1365
1616
  fade: { position: 'absolute', top: 0, left: 0, right: 0 },
1366
1617
  rise: { position: 'absolute', bottom: 0, left: 0, right: 0 },
1618
+ hidden: { display: 'none' },
1367
1619
  });
1368
1620
 
1369
1621
  export interface PanelsideCtaProps extends Omit<PressableProps, 'children'> {
@@ -1378,10 +1630,11 @@ export interface PanelsideCtaProps extends Omit<PressableProps, 'children'> {
1378
1630
  * How tall the pill is. `default` is 44pt — a step above the account button
1379
1631
  * beside it, so the footer reads as one primary control and one secondary
1380
1632
  * one. `lg` is 52pt, for a panel where the call to action is the only thing
1381
- * in the row.
1633
+ * in the row, and `xl` is 56pt.
1382
1634
  *
1383
1635
  * Ignored under `native` — the platform sizes its own button, and asks for a
1384
- * control size rather than a height.
1636
+ * control size rather than a height. The three steps reach the platform's
1637
+ * regular, large and extra-large controls.
1385
1638
  */
1386
1639
  size?: PanelsideCtaSize;
1387
1640
  /**
@@ -1430,7 +1683,7 @@ function PanelsideCta({
1430
1683
  glass={glass}
1431
1684
  // The platform sizes a native button from its label, so a height means
1432
1685
  // nothing here — the step is asked for as a control size instead.
1433
- size={size === 'lg' ? 'lg' : 'md'}
1686
+ size={size === 'default' ? 'md' : size}
1434
1687
  variant={variant}
1435
1688
  accessibilityLabel={label}
1436
1689
  // Pressable allows `null` for disabled; Button does not.
@@ -1448,7 +1701,7 @@ function PanelsideCta({
1448
1701
  React view in it to pad, and hosting one to get a view leaves a width
1449
1702
  nothing knows in advance.
1450
1703
 
1451
- So this one is sized rather than padded — `size="lg"` reaches the
1704
+ So this one is sized rather than padded — the size reaches the
1452
1705
  platform as a control size, which scales the room the style leaves
1453
1706
  around the label and the label with it.
1454
1707
  */}
@@ -1661,6 +1914,115 @@ function PanelsideScene({
1661
1914
  );
1662
1915
  }
1663
1916
 
1917
+ export interface PanelsidePagesProps extends ViewProps {
1918
+ className?: string;
1919
+ /** `Panelside.Page` elements. Anything else is rendered as given. */
1920
+ children?: ReactNode;
1921
+ }
1922
+
1923
+ /**
1924
+ * The pages the panel navigates between.
1925
+ *
1926
+ * Put it inside `Panelside.Scene` and give each page a `value` that a row's
1927
+ * `to` matches. Nothing is wired between the two: the row sets the panel's
1928
+ * route, and the page whose value equals it is the one shown.
1929
+ *
1930
+ * ```tsx
1931
+ * <Panelside.Scene>
1932
+ * <Panelside.Pages>
1933
+ * <Panelside.Page value="inbox"><Inbox /></Panelside.Page>
1934
+ * <Panelside.Page value="drafts"><Drafts /></Panelside.Page>
1935
+ * </Panelside.Pages>
1936
+ * </Panelside.Scene>
1937
+ * ```
1938
+ *
1939
+ * A page is mounted the first time it is visited and stays mounted after
1940
+ * that, hidden rather than removed. Going back to one is then a style change
1941
+ * rather than a mount: its list does not rebuild, its scroll position is where
1942
+ * you left it, and whatever it was fetching is already there. A page whose
1943
+ * contents go stale — or whose data is large enough that keeping it is worse
1944
+ * than fetching it again — takes `keepAlive={false}`.
1945
+ */
1946
+ function PanelsidePages({ className, children, ...props }: PanelsidePagesProps) {
1947
+ const { route } = usePanelsideContext('Panelside.Pages');
1948
+
1949
+ /*
1950
+ * Which pages have ever been the route.
1951
+ *
1952
+ * A `Set` in state rather than a ref: mounting a page for the first time has
1953
+ * to be a render, and the ref would not cause one. It only ever grows, and
1954
+ * only by one entry per page, so the identity change per first visit costs
1955
+ * nothing after the pages have all been seen once.
1956
+ */
1957
+ const [visited, setVisited] = useState<readonly string[]>(() => [route]);
1958
+
1959
+ useEffect(() => {
1960
+ setVisited((current) => (current.includes(route) ? current : [...current, route]));
1961
+ }, [route]);
1962
+
1963
+ return (
1964
+ <View className={cn('flex-1', className)} {...props}>
1965
+ {Children.map(children, (child) => {
1966
+ if (!isValidElement<PanelsidePageProps>(child) || child.type !== PanelsidePage) {
1967
+ return child;
1968
+ }
1969
+
1970
+ const { value, keepAlive = true } = child.props;
1971
+ const current = value === route;
1972
+ if (!current && (!keepAlive || !visited.includes(value))) return null;
1973
+
1974
+ return cloneElement(child, { hidden: !current });
1975
+ })}
1976
+ </View>
1977
+ );
1978
+ }
1979
+
1980
+ export interface PanelsidePageProps extends ViewProps {
1981
+ className?: string;
1982
+ /** What a row's `to` has to equal for this page to be the one shown. */
1983
+ value: string;
1984
+ /**
1985
+ * Keep the page mounted once it has been visited. Default true, which is
1986
+ * what makes going back to it instant. Off, it is torn down on the way out
1987
+ * and rebuilt on the way in.
1988
+ */
1989
+ keepAlive?: boolean;
1990
+ /**
1991
+ * Set by `Panelside.Pages`. A hidden page is laid out by nobody, is not in
1992
+ * the accessibility tree, and takes no touches — but it is still mounted,
1993
+ * which is the whole point of it.
1994
+ */
1995
+ hidden?: boolean;
1996
+ children?: ReactNode;
1997
+ }
1998
+
1999
+ /** One page. Only meaningful inside `Panelside.Pages`. */
2000
+ function PanelsidePage({
2001
+ className,
2002
+ value,
2003
+ keepAlive,
2004
+ hidden = false,
2005
+ children,
2006
+ style,
2007
+ ...props
2008
+ }: PanelsidePageProps) {
2009
+ // `display: none` rather than unmounting, and rather than opacity: it takes
2010
+ // the page out of layout entirely, so a hidden page costs no measurement,
2011
+ // while its component tree — and everything it is holding — stays.
2012
+ return (
2013
+ <View
2014
+ accessibilityElementsHidden={hidden}
2015
+ importantForAccessibility={hidden ? 'no-hide-descendants' : 'auto'}
2016
+ pointerEvents={hidden ? 'none' : 'auto'}
2017
+ style={[hidden ? styles.hidden : null, style]}
2018
+ className={cn('flex-1', className)}
2019
+ {...props}
2020
+ >
2021
+ {textChildren(children)}
2022
+ </View>
2023
+ );
2024
+ }
2025
+
1664
2026
  export interface PanelsideTriggerProps extends Omit<PressableProps, 'children'> {
1665
2027
  className?: string;
1666
2028
  /** What a screen reader announces. */
@@ -1708,7 +2070,7 @@ function PanelsideTrigger({
1708
2070
  accessibilityRole="button"
1709
2071
  accessibilityLabel={label}
1710
2072
  >
1711
- <MenuIcon size={20} color={color} />
2073
+ <Glyph icon={Menu01Icon} size={20} color={color} />
1712
2074
  </AnimatedPressable>
1713
2075
  );
1714
2076
  }
@@ -1738,6 +2100,17 @@ export interface PanelsideSearchTriggerProps extends Omit<PressableProps, 'child
1738
2100
  className?: string;
1739
2101
  /** What a screen reader announces. */
1740
2102
  label?: string;
2103
+ /**
2104
+ * `filled` is the default: a circle in the secondary surface, which is what
2105
+ * a control sitting alone on the panel's own surface needs to read as one.
2106
+ *
2107
+ * `outline` is a ring and no fill, for a panel whose other controls are
2108
+ * outlined too — a filled circle among them is the only thing on the screen
2109
+ * claiming to be a second primary.
2110
+ *
2111
+ * Ignored under `native`, where the platform owns the button's chrome.
2112
+ */
2113
+ variant?: PanelsideControlVariant;
1741
2114
  /** Replaces the default magnifier. */
1742
2115
  children?: ReactNode;
1743
2116
  /**
@@ -1762,6 +2135,7 @@ export interface PanelsideSearchTriggerProps extends Omit<PressableProps, 'child
1762
2135
  function PanelsideSearchTrigger({
1763
2136
  className,
1764
2137
  label = 'Search',
2138
+ variant = 'filled',
1765
2139
  children,
1766
2140
  native = false,
1767
2141
  glass = false,
@@ -1780,7 +2154,7 @@ function PanelsideSearchTrigger({
1780
2154
  [onPress, setSearchOpen]
1781
2155
  );
1782
2156
 
1783
- const glyph = children ?? <SearchIcon size={18} color={native ? color : undefined} />;
2157
+ const glyph = children ?? <Glyph icon={Search01Icon} size={18} color={native ? color : undefined} />;
1784
2158
 
1785
2159
  if (native) {
1786
2160
  return (
@@ -1802,10 +2176,12 @@ function PanelsideSearchTrigger({
1802
2176
  {...props}
1803
2177
  onPress={open}
1804
2178
  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',
2179
+ 'h-10 w-10 items-center justify-center rounded-full',
2180
+ // Filled by default. It sits on the panel's own surface with nothing
2181
+ // else on that row, so an outline at this size reads as an empty
2182
+ // circle before it reads as a control — which stops being true the
2183
+ // moment the rest of the screen's controls are outlined as well.
2184
+ variant === 'outline' ? 'border border-border' : 'bg-secondary',
1809
2185
  className
1810
2186
  )}
1811
2187
  accessibilityRole="button"
@@ -1885,6 +2261,22 @@ export interface PanelsideSearchSheetProps {
1885
2261
  showClose?: boolean;
1886
2262
  /** What a screen reader announces for that button. */
1887
2263
  closeLabel?: string;
2264
+ /**
2265
+ * How that button is drawn. Matches `Panelside.SearchTrigger`'s `variant`,
2266
+ * so the control that opens the surface and the one that closes it are the
2267
+ * same shape.
2268
+ */
2269
+ closeVariant?: PanelsideControlVariant;
2270
+ /**
2271
+ * Draw that button as the platform's own, the way `Panelside.SearchTrigger`
2272
+ * does. Independent of `native` on the sheet itself: the surface can be ours
2273
+ * while the two controls on either end of it are the platform's, which is
2274
+ * the arrangement a native screen wants when the sheet's own presentation
2275
+ * cannot hold this content.
2276
+ */
2277
+ closeNative?: boolean;
2278
+ /** Draw that native button in the platform's Liquid Glass material. */
2279
+ closeGlass?: boolean;
1888
2280
  children?: ReactNode;
1889
2281
  }
1890
2282
 
@@ -1930,6 +2322,9 @@ function PanelsideSearchSheet({
1930
2322
  keyboardGap = 10,
1931
2323
  showClose = true,
1932
2324
  closeLabel = 'Close search',
2325
+ closeVariant = 'filled',
2326
+ closeNative = false,
2327
+ closeGlass = false,
1933
2328
  children,
1934
2329
  }: PanelsideSearchSheetProps) {
1935
2330
  const { searchOpen, setSearchOpen } = usePanelsideContext('Panelside.SearchSheet');
@@ -1945,8 +2340,20 @@ function PanelsideSearchSheet({
1945
2340
  const query = value ?? uncontrolledQuery;
1946
2341
  const activeTab = tab ?? uncontrolledTab;
1947
2342
 
2343
+ /*
2344
+ * Closing takes the keyboard with it, wherever the close came from — the
2345
+ * button, a drag on the sheet, or the system back gesture.
2346
+ *
2347
+ * Leaving it to the field's own blur is too late. The field blurs when
2348
+ * `open` goes false, but the sheet is already on its way out by then: the
2349
+ * styled one unmounts its portal after the exit animation, and the platform
2350
+ * one keeps its content mounted and only stops presenting it. Either way the
2351
+ * blur lands on a field nothing can see, and the keyboard stays up until the
2352
+ * system notices for itself, some seconds later, that nothing is focused.
2353
+ */
1948
2354
  const setOpen = useCallback(
1949
2355
  (next: boolean) => {
2356
+ if (!next) Keyboard.dismiss();
1950
2357
  if (openProp === undefined) setSearchOpen(next);
1951
2358
  onOpenChange?.(next);
1952
2359
  },
@@ -2039,14 +2446,30 @@ function PanelsideSearchSheet({
2039
2446
  >
2040
2447
  {showClose ? (
2041
2448
  <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>
2449
+ {closeNative ? (
2450
+ <Button
2451
+ native
2452
+ glass={closeGlass}
2453
+ size="icon"
2454
+ variant="ghost"
2455
+ accessibilityLabel={closeLabel}
2456
+ onPress={close}
2457
+ >
2458
+ <Glyph icon={Cancel01Icon} size={17} color={glyph} />
2459
+ </Button>
2460
+ ) : (
2461
+ <AnimatedPressable
2462
+ onPress={close}
2463
+ accessibilityRole="button"
2464
+ accessibilityLabel={closeLabel}
2465
+ className={cn(
2466
+ 'h-9 w-9 items-center justify-center rounded-full',
2467
+ closeVariant === 'outline' ? 'border border-border' : 'bg-secondary'
2468
+ )}
2469
+ >
2470
+ <Glyph icon={Cancel01Icon} size={17} color={glyph} />
2471
+ </AnimatedPressable>
2472
+ )}
2050
2473
  </View>
2051
2474
  ) : null}
2052
2475
  {children}
@@ -2059,6 +2482,20 @@ function PanelsideSearchSheet({
2059
2482
 
2060
2483
  export interface PanelsideSearchTabsProps {
2061
2484
  className?: string;
2485
+ /**
2486
+ * Where the row sits across the sheet. Default `center`.
2487
+ *
2488
+ * Centred because only the selected tab is open and the rest are their
2489
+ * icons, so the row's width changes as you move through it — anchored to the
2490
+ * leading edge that change reads as the row growing and shrinking, and
2491
+ * centred it reads as the selection moving.
2492
+ *
2493
+ * `start` anchors it to the leading edge, for a sheet whose tabs are wide
2494
+ * enough to fill the row anyway.
2495
+ */
2496
+ align?: 'start' | 'center';
2497
+ /** Applied to the row itself rather than to the box around it. */
2498
+ listClassName?: string;
2062
2499
  children?: ReactNode;
2063
2500
  }
2064
2501
 
@@ -2072,7 +2509,12 @@ export interface PanelsideSearchTabsProps {
2072
2509
  * Every `Panelside.SearchTab` therefore needs an `icon` — a closed tab has
2073
2510
  * nothing else to be.
2074
2511
  */
2075
- function PanelsideSearchTabs({ className, children }: PanelsideSearchTabsProps) {
2512
+ function PanelsideSearchTabs({
2513
+ className,
2514
+ align = 'center',
2515
+ listClassName,
2516
+ children,
2517
+ }: PanelsideSearchTabsProps) {
2076
2518
  const { tab, setTab } = usePanelsideSearchSheet('Panelside.SearchTabs');
2077
2519
 
2078
2520
  return (
@@ -2083,7 +2525,9 @@ function PanelsideSearchTabs({ className, children }: PanelsideSearchTabsProps)
2083
2525
  defaultValue={tab}
2084
2526
  className={cn('px-4 pt-1', className)}
2085
2527
  >
2086
- <Tabs.List>{children}</Tabs.List>
2528
+ <Tabs.List className={cn(align === 'center' && 'justify-center', listClassName)}>
2529
+ {children}
2530
+ </Tabs.List>
2087
2531
  </Tabs>
2088
2532
  );
2089
2533
  }
@@ -2099,7 +2543,11 @@ export interface PanelsideSearchTabProps {
2099
2543
 
2100
2544
  function PanelsideSearchTab({ className, value, icon, children }: PanelsideSearchTabProps) {
2101
2545
  return (
2102
- <Tabs.Trigger value={value} icon={icon} className={className}>
2546
+ // A step of padding above the tab row's own. This row is the only control
2547
+ // on a surface that is otherwise a list and a field, and a closed tab here
2548
+ // is a glyph with nothing else to be aimed at by — so it is sized to be
2549
+ // hit rather than to the density a row of labelled tabs can afford.
2550
+ <Tabs.Trigger value={value} icon={icon} className={cn('px-4 py-3', className)}>
2103
2551
  {children}
2104
2552
  </Tabs.Trigger>
2105
2553
  );
@@ -2253,13 +2701,24 @@ function PanelsideSearchField({
2253
2701
  bottomInset={bottomInset}
2254
2702
  className="w-full px-4 pb-1"
2255
2703
  >
2704
+ {/*
2705
+ An opaque surface with a ring, not the secondary tint the rest of the
2706
+ panel's controls use.
2707
+
2708
+ The field rides the keyboard by translating rather than by taking a row
2709
+ of the layout, so once it is up the results are directly behind it —
2710
+ and the secondary token is a few percent of a colour, which the list
2711
+ then reads straight through, taking the placeholder with it. The ring
2712
+ is what a floating pill needs to have an edge once its fill is the same
2713
+ colour as most of the sheet.
2714
+ */}
2256
2715
  <View
2257
2716
  className={cn(
2258
- 'h-12 w-full flex-row items-center gap-2 rounded-full bg-secondary px-4',
2717
+ 'h-12 w-full flex-row items-center gap-2 rounded-full border border-border bg-surface px-4 shadow-lg',
2259
2718
  containerClassName
2260
2719
  )}
2261
2720
  >
2262
- <SearchIcon size={17} color={muted} />
2721
+ <Glyph icon={Search01Icon} size={17} color={muted} />
2263
2722
  <TextInput
2264
2723
  ref={field}
2265
2724
  value={text}
@@ -2281,7 +2740,7 @@ function PanelsideSearchField({
2281
2740
  accessibilityLabel="Clear search"
2282
2741
  className="h-5 w-5 items-center justify-center rounded-full bg-muted"
2283
2742
  >
2284
- <XIcon size={12} color={muted} />
2743
+ <Glyph icon={Cancel01Icon} size={12} color={muted} />
2285
2744
  </AnimatedPressable>
2286
2745
  ) : null}
2287
2746
  </View>
@@ -2301,9 +2760,12 @@ PanelsideItemIcon.displayName = 'Panelside.ItemIcon';
2301
2760
  PanelsideItemLabel.displayName = 'Panelside.ItemLabel';
2302
2761
  PanelsideItemBadge.displayName = 'Panelside.ItemBadge';
2303
2762
  PanelsideAction.displayName = 'Panelside.Action';
2763
+ PanelsideItemActions.displayName = 'Panelside.ItemActions';
2304
2764
  PanelsideFooter.displayName = 'Panelside.Footer';
2305
2765
  PanelsideCta.displayName = 'Panelside.Cta';
2306
2766
  PanelsideScene.displayName = 'Panelside.Scene';
2767
+ PanelsidePages.displayName = 'Panelside.Pages';
2768
+ PanelsidePage.displayName = 'Panelside.Page';
2307
2769
  PanelsideTrigger.displayName = 'Panelside.Trigger';
2308
2770
  PanelsideSearchTrigger.displayName = 'Panelside.SearchTrigger';
2309
2771
  PanelsideSearchSheet.displayName = 'Panelside.SearchSheet';
@@ -2325,9 +2787,12 @@ export const Panelside = Object.assign(PanelsideRoot, {
2325
2787
  ItemLabel: PanelsideItemLabel,
2326
2788
  ItemBadge: PanelsideItemBadge,
2327
2789
  Action: PanelsideAction,
2790
+ ItemActions: PanelsideItemActions,
2328
2791
  Footer: PanelsideFooter,
2329
2792
  Cta: PanelsideCta,
2330
2793
  Scene: PanelsideScene,
2794
+ Pages: PanelsidePages,
2795
+ Page: PanelsidePage,
2331
2796
  Trigger: PanelsideTrigger,
2332
2797
  SearchTrigger: PanelsideSearchTrigger,
2333
2798
  SearchSheet: PanelsideSearchSheet,