panelui-native 0.83.0 → 0.84.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 (39) hide show
  1. package/lib/module/components/circular-text/circular-text-geometry.js +63 -0
  2. package/lib/module/components/circular-text/circular-text-geometry.js.map +1 -0
  3. package/lib/module/components/circular-text/index.js +146 -0
  4. package/lib/module/components/circular-text/index.js.map +1 -0
  5. package/lib/module/components/marquee/index.js +12 -5
  6. package/lib/module/components/marquee/index.js.map +1 -1
  7. package/lib/module/components/panelside/index.js +15 -417
  8. package/lib/module/components/panelside/index.js.map +1 -1
  9. package/lib/module/components/popover/index.js +119 -7
  10. package/lib/module/components/popover/index.js.map +1 -1
  11. package/lib/module/icons/index.js +24 -4
  12. package/lib/module/icons/index.js.map +1 -1
  13. package/lib/module/index.js +1 -0
  14. package/lib/module/index.js.map +1 -1
  15. package/lib/module/native/index.js +28 -0
  16. package/lib/module/native/index.js.map +1 -1
  17. package/lib/typescript/src/components/circular-text/circular-text-geometry.d.ts +39 -0
  18. package/lib/typescript/src/components/circular-text/circular-text-geometry.d.ts.map +1 -0
  19. package/lib/typescript/src/components/circular-text/index.d.ts +69 -0
  20. package/lib/typescript/src/components/circular-text/index.d.ts.map +1 -0
  21. package/lib/typescript/src/components/marquee/index.d.ts.map +1 -1
  22. package/lib/typescript/src/components/panelside/index.d.ts +10 -202
  23. package/lib/typescript/src/components/panelside/index.d.ts.map +1 -1
  24. package/lib/typescript/src/components/popover/index.d.ts +25 -1
  25. package/lib/typescript/src/components/popover/index.d.ts.map +1 -1
  26. package/lib/typescript/src/icons/index.d.ts.map +1 -1
  27. package/lib/typescript/src/index.d.ts +2 -1
  28. package/lib/typescript/src/index.d.ts.map +1 -1
  29. package/lib/typescript/src/native/index.d.ts +40 -0
  30. package/lib/typescript/src/native/index.d.ts.map +1 -1
  31. package/package.json +1 -1
  32. package/src/components/circular-text/circular-text-geometry.ts +80 -0
  33. package/src/components/circular-text/index.tsx +217 -0
  34. package/src/components/marquee/index.tsx +12 -5
  35. package/src/components/panelside/index.tsx +16 -604
  36. package/src/components/popover/index.tsx +146 -4
  37. package/src/icons/index.tsx +23 -2
  38. package/src/index.ts +1 -6
  39. package/src/native/index.ts +55 -0
@@ -30,6 +30,7 @@
30
30
  * and jump into place.
31
31
  */
32
32
  import {
33
+ Children,
33
34
  cloneElement,
34
35
  createContext,
35
36
  isValidElement,
@@ -62,6 +63,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
62
63
  import { FocusRestorePortal } from '../../primitives/portal';
63
64
  import { Scrim } from '../../primitives/scrim';
64
65
  import { useBackHandler } from '../../hooks/use-back-handler';
66
+ import { getSwiftUI } from '../../native';
65
67
  import { BottomSheet } from '../bottom-sheet';
66
68
  import { Text, type TextProps, textChildren } from '../../primitives/text';
67
69
  import { cn } from '../../utils/cn';
@@ -116,6 +118,8 @@ interface PopoverContextValue {
116
118
  setArrowOffset: (offset: number) => void;
117
119
  /** Whether Content is presenting as a bottom sheet — Arrow is null then. */
118
120
  presentation: PopoverPresentation;
121
+ /** Whether the platform is drawing the panel. Trigger and Content both read it. */
122
+ native: boolean;
119
123
  }
120
124
 
121
125
  export type PopoverPresentation = 'popover' | 'bottom-sheet';
@@ -176,6 +180,30 @@ export interface PopoverProps {
176
180
  * sheet.
177
181
  */
178
182
  presentation?: PopoverPresentation;
183
+ /**
184
+ * Present the platform's own popover instead of this one. Requires the
185
+ * optional `@expo/ui`.
186
+ *
187
+ * **iOS only.** SwiftUI has a popover that anchors to a view and keeps its
188
+ * anchored shape on a phone rather than becoming a sheet; Compose's nearest
189
+ * relative is a dropdown menu, which is a different control with different
190
+ * rules. Android and web keep the styled panel, as does an iOS device
191
+ * without `@expo/ui` installed.
192
+ *
193
+ * **The platform draws the container, so theme tokens do not reach it.** The
194
+ * panel's surface, its corner radius, its shadow and its arrow are the
195
+ * system's; `className` on `Popover.Content` styles what is *inside* it.
196
+ * `align`, `offset`, `alignOffset`, `scrim` and `blur` have no native
197
+ * equivalent and are ignored; `placement` becomes the edge the arrow is
198
+ * asked for.
199
+ *
200
+ * **Give the content a `width`.** The platform sizes its popover to what is
201
+ * hosted in it, and a React Native subtree with no width of its own has
202
+ * nothing to report — the same rule that governs every hosted view.
203
+ * `Popover.Content` defaults to a sensible one under `native`, but a panel
204
+ * whose rows need more room should say so.
205
+ */
206
+ native?: boolean;
179
207
  }
180
208
 
181
209
  function PopoverRoot({
@@ -184,6 +212,7 @@ function PopoverRoot({
184
212
  onOpenChange,
185
213
  defaultOpen = false,
186
214
  presentation = 'popover',
215
+ native = false,
187
216
  }: PopoverProps) {
188
217
  const [internalOpen, setInternalOpen] = useState(defaultOpen);
189
218
  const [trigger, setTrigger] = useState<TriggerRect | null>(null);
@@ -201,6 +230,16 @@ function PopoverRoot({
201
230
  [isControlled, onOpenChange]
202
231
  );
203
232
 
233
+ /*
234
+ * The platform's popover, where it is reachable and the caller asked for it.
235
+ *
236
+ * A sheet presentation is left alone: `native` names which popover to draw,
237
+ * and asking for both a sheet and a popover is a contradiction rather than a
238
+ * combination.
239
+ */
240
+ const swiftUI = native && presentation === 'popover' ? getSwiftUI() : null;
241
+ const nativeActive = swiftUI !== null;
242
+
204
243
  const context = useMemo(
205
244
  () => ({
206
245
  open: resolvedOpen,
@@ -212,11 +251,95 @@ function PopoverRoot({
212
251
  arrowOffset,
213
252
  setArrowOffset,
214
253
  presentation,
254
+ native: nativeActive,
215
255
  }),
216
- [resolvedOpen, setOpen, trigger, placement, arrowOffset, presentation]
256
+ [resolvedOpen, setOpen, trigger, placement, arrowOffset, presentation, nativeActive]
217
257
  );
218
258
 
219
- return <PopoverContext.Provider value={context}>{children}</PopoverContext.Provider>;
259
+ return (
260
+ <PopoverContext.Provider value={context}>
261
+ {swiftUI ? <NativePopover swiftUI={swiftUI}>{children}</NativePopover> : children}
262
+ </PopoverContext.Provider>
263
+ );
264
+ }
265
+
266
+ /** Default width for a hosted panel, in points. Room for a short menu row. */
267
+ const NATIVE_PANEL_WIDTH = 240;
268
+
269
+ /** `placement` in the platform's vocabulary. */
270
+ const ARROW_EDGE = {
271
+ top: 'top',
272
+ bottom: 'bottom',
273
+ left: 'leading',
274
+ right: 'trailing',
275
+ } as const;
276
+
277
+ /**
278
+ * The platform's popover, with our trigger and our content hosted inside it.
279
+ *
280
+ * SwiftUI attaches a popover to a view, so the two halves this component keeps
281
+ * as siblings have to become parent and child: the trigger is what the panel
282
+ * points at, and the platform will not anchor to something it cannot see. So
283
+ * the children are read here and placed into the two slots the platform
284
+ * expects, rather than the caller having to write a different tree under
285
+ * `native` than without it.
286
+ *
287
+ * Both halves are React Native, so both are wrapped in the host view that lets
288
+ * the platform measure them. The trigger is given no press handling of its
289
+ * own — `Popover.Trigger` already toggles the state this reads, and the
290
+ * platform presents from that.
291
+ */
292
+ function NativePopover({
293
+ swiftUI,
294
+ children,
295
+ }: {
296
+ swiftUI: NonNullable<ReturnType<typeof getSwiftUI>>;
297
+ children: ReactNode;
298
+ }) {
299
+ const { open, setOpen } = usePopover('Popover');
300
+ const { Host, RNHostView, Popover: PlatformPopover } = swiftUI;
301
+
302
+ let trigger: ReactNode = null;
303
+ let content: ReactElement<PopoverContentProps> | null = null;
304
+
305
+ for (const child of Children.toArray(children)) {
306
+ if (!isValidElement(child)) continue;
307
+ if (child.type === PopoverTrigger) trigger = child;
308
+ else if (child.type === PopoverContent) {
309
+ content = child as ReactElement<PopoverContentProps>;
310
+ }
311
+ }
312
+
313
+ const contentProps = content?.props;
314
+ const width =
315
+ typeof contentProps?.width === 'number' ? contentProps.width : NATIVE_PANEL_WIDTH;
316
+
317
+ return (
318
+ <Host matchContents ignoreSafeArea="keyboard">
319
+ <PlatformPopover
320
+ isPresented={open}
321
+ onIsPresentedChange={setOpen}
322
+ arrowEdge={ARROW_EDGE[contentProps?.placement ?? 'bottom']}
323
+ >
324
+ <PlatformPopover.Trigger>
325
+ <RNHostView matchContents>{trigger}</RNHostView>
326
+ </PlatformPopover.Trigger>
327
+ <PlatformPopover.Content>
328
+ <RNHostView matchContents>
329
+ {/*
330
+ An explicit width, not a class. Inside the host there is no
331
+ parent for a percentage or a flex basis to resolve against, so a
332
+ panel that does not state its width reports none and the platform
333
+ sizes its popover to nothing.
334
+ */}
335
+ <View style={{ width }} className={cn('gap-1 p-2', contentProps?.className)}>
336
+ {textChildren(contentProps?.children)}
337
+ </View>
338
+ </RNHostView>
339
+ </PlatformPopover.Content>
340
+ </PlatformPopover>
341
+ </Host>
342
+ );
220
343
  }
221
344
 
222
345
  export interface PopoverTriggerProps {
@@ -231,7 +354,7 @@ export interface PopoverTriggerProps {
231
354
  * and only a wrapper we own is guaranteed to be measurable.
232
355
  */
233
356
  function PopoverTrigger({ children }: PopoverTriggerProps) {
234
- const { open, setOpen, setTrigger } = usePopover('Popover.Trigger');
357
+ const { open, setOpen, setTrigger, native } = usePopover('Popover.Trigger');
235
358
  const ref = useRef<View>(null);
236
359
 
237
360
  const measureThenToggle = (...args: unknown[]) => {
@@ -242,6 +365,17 @@ function PopoverTrigger({ children }: PopoverTriggerProps) {
242
365
  return;
243
366
  }
244
367
 
368
+ /*
369
+ * Nothing to measure when the platform is drawing the panel: it anchors to
370
+ * this trigger itself, and asking a view hosted inside the native tree for
371
+ * its window coordinates is a callback that may never come back — which
372
+ * would leave the press doing nothing at all.
373
+ */
374
+ if (native) {
375
+ setOpen(true);
376
+ return;
377
+ }
378
+
245
379
  // Measured on every open rather than on layout: the trigger may have
246
380
  // scrolled since it was laid out, and a stale rect anchors the panel to
247
381
  // where the trigger used to be.
@@ -363,7 +497,8 @@ function PopoverContent({
363
497
  ...props
364
498
  }: PopoverContentProps) {
365
499
  const context = usePopover('Popover.Content');
366
- const { open, setOpen, trigger, setPlacement, setArrowOffset, presentation } = context;
500
+ const { open, setOpen, trigger, setPlacement, setArrowOffset, presentation, native } =
501
+ context;
367
502
 
368
503
  // The anchored panel owns the Android back button while it is up. The sheet
369
504
  // presentation is left alone — BottomSheet installs its own handler.
@@ -509,6 +644,13 @@ function PopoverContent({
509
644
  * the surface it is merged into the sheet's own padding classes and replaces
510
645
  * them, so a panel asking for `p-3` silently strips the sheet's `px-5 pt-2`.
511
646
  */
647
+ /*
648
+ * Already drawn. Under `native` the root reads this element's props and
649
+ * hosts its children inside the platform's own popover, so rendering the
650
+ * styled panel here as well would put a second one on the screen.
651
+ */
652
+ if (native) return null;
653
+
512
654
  if (presentation === 'bottom-sheet') {
513
655
  return (
514
656
  <BottomSheet open={open} onOpenChange={setOpen}>
@@ -50,6 +50,7 @@
50
50
  * outline could only ignore. All four are drawn here rather than mapped.
51
51
  */
52
52
  import { createContext, useContext, type ReactNode } from 'react';
53
+ import { View } from 'react-native';
53
54
  import { HugeiconsIcon, type IconSvgElement } from '@hugeicons/react-native';
54
55
  import Svg, { G, Path, type SvgProps } from 'react-native-svg';
55
56
  import { useDirection } from '../hooks/use-direction';
@@ -208,17 +209,37 @@ function icon(glyph: IconSvgElement, defaults: IconDefaults) {
208
209
  }: ToggleIconProps) {
209
210
  const resolved = useResolvedColor(color, defaults.color);
210
211
  const rtl = useDirection() === 'rtl';
211
- return (
212
+
213
+ const drawing = (
212
214
  <HugeiconsIcon
213
215
  icon={glyph}
214
216
  size={size}
215
217
  color={resolved}
216
218
  strokeWidth={strokeWidth ?? defaults.strokeWidth ?? STROKE}
217
219
  fill={defaults.fillable && filled ? resolved : 'none'}
218
- style={defaults.flip ? [{ transform: [{ scaleX: rtl ? -1 : 1 }] }, style] : style}
220
+ style={defaults.flip ? undefined : style}
219
221
  {...props}
220
222
  />
221
223
  );
224
+
225
+ if (!defaults.flip) return drawing;
226
+
227
+ /*
228
+ * The mirror goes on a view around the glyph, not on the glyph.
229
+ *
230
+ * The drawing component takes a `style` prop and drops it: it destructures
231
+ * `style` out of its props and never puts it back, and the interop layer
232
+ * that would otherwise have carried it is a package this library does not
233
+ * use. A transform handed to it is silently discarded, which is a chevron
234
+ * that keeps pointing right in a right-to-left row — and a failure with
235
+ * nothing to see, since every other prop on the same element does arrive.
236
+ *
237
+ * A plain view cannot lose it. It also puts the caller's own `style` back
238
+ * on the outside, where it is applied rather than thrown away.
239
+ */
240
+ return (
241
+ <View style={[{ transform: [{ scaleX: rtl ? -1 : 1 }] }, style]}>{drawing}</View>
242
+ );
222
243
  }
223
244
 
224
245
  return Icon;
package/src/index.ts CHANGED
@@ -421,6 +421,7 @@ export {
421
421
  type ChipVariant,
422
422
  type ChipSize,
423
423
  } from './components/chip';
424
+ export { CircularText, type CircularTextProps } from './components/circular-text';
424
425
  export {
425
426
  Collapsible,
426
427
  type CollapsibleProps,
@@ -557,12 +558,6 @@ export {
557
558
  type PanelsidePageProps,
558
559
  type PanelsideTriggerProps,
559
560
  type PanelsideSearchTriggerProps,
560
- type PanelsideSearchSheetProps,
561
- type PanelsideSearchTabsProps,
562
- type PanelsideSearchTabProps,
563
- type PanelsideSearchResultsProps,
564
- type PanelsideSearchResultProps,
565
- type PanelsideSearchFieldProps,
566
561
  type PanelsideMode,
567
562
  type PanelsideSwipeFrom,
568
563
  type PanelsideItemSize,
@@ -161,6 +161,61 @@ interface SwiftUIModifiers {
161
161
  presentationBackground: (color: string) => unknown;
162
162
  }
163
163
 
164
+ /**
165
+ * The SwiftUI-only components, for the ones the universal set does not carry.
166
+ *
167
+ * `@expo/ui` exports a portable component for everything both toolkits agree
168
+ * on, and that is what `getNativeUI` reaches. A popover is not on that list:
169
+ * SwiftUI has one that anchors to a view and adapts itself on a compact
170
+ * screen, and Compose's nearest relative is a dropdown menu, which is a
171
+ * different control with different rules. Rather than pretend the two are one
172
+ * thing, the iOS one is reached here and Android keeps the styled panel.
173
+ */
174
+ interface SwiftUIComponents {
175
+ Host: ComponentType<{
176
+ children?: ReactNode;
177
+ matchContents?: boolean | { vertical?: boolean; horizontal?: boolean };
178
+ ignoreSafeArea?: unknown;
179
+ style?: unknown;
180
+ }>;
181
+ RNHostView: ComponentType<{ children?: ReactNode; matchContents?: boolean }>;
182
+ Popover: ComponentType<{
183
+ children?: ReactNode;
184
+ isPresented?: boolean;
185
+ onIsPresentedChange?: (isPresented: boolean) => void;
186
+ attachmentAnchor?: 'leading' | 'trailing' | 'center' | 'top' | 'bottom';
187
+ arrowEdge?: 'leading' | 'trailing' | 'top' | 'bottom' | 'none';
188
+ }> & {
189
+ Trigger: ComponentType<{ children?: ReactNode }>;
190
+ Content: ComponentType<{ children?: ReactNode }>;
191
+ };
192
+ }
193
+
194
+ let swiftUIResolved = false;
195
+ let swiftUI: SwiftUIComponents | null = null;
196
+
197
+ export function getSwiftUI(): SwiftUIComponents | null {
198
+ if (swiftUIResolved) return swiftUI;
199
+ swiftUIResolved = true;
200
+
201
+ if (Platform.OS !== 'ios') return null;
202
+
203
+ try {
204
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
205
+ const module = require('@expo/ui/swift-ui') as Partial<SwiftUIComponents>;
206
+ // A version without the popover is not an error; it is a fallback to the
207
+ // styled panel, which is what every missing native path here does.
208
+ swiftUI =
209
+ module.Host && module.RNHostView && module.Popover
210
+ ? (module as SwiftUIComponents)
211
+ : null;
212
+ } catch {
213
+ swiftUI = null;
214
+ }
215
+
216
+ return swiftUI;
217
+ }
218
+
164
219
  let modifiersResolved = false;
165
220
  let modifiers: SwiftUIModifiers | null = null;
166
221