@multiplatform.one/components 7.7.3 → 7.7.6

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 (117) hide show
  1. package/dist/cjs/Accordion/index.cjs +4 -0
  2. package/dist/cjs/Accordion/index.native.js +4 -0
  3. package/dist/cjs/Accordion/index.native.js.map +1 -1
  4. package/dist/cjs/AnimateHeight/index.cjs +29 -2
  5. package/dist/cjs/AnimateHeight/index.native.js +30 -2
  6. package/dist/cjs/AnimateHeight/index.native.js.map +1 -1
  7. package/dist/cjs/Collapsible.cjs +48 -0
  8. package/dist/cjs/Collapsible.native.js +56 -0
  9. package/dist/cjs/Collapsible.native.js.map +1 -0
  10. package/dist/cjs/Pagination.cjs +28 -9
  11. package/dist/cjs/Pagination.native.js +28 -9
  12. package/dist/cjs/Pagination.native.js.map +1 -1
  13. package/dist/cjs/SheetModal/index.native.js +49 -25
  14. package/dist/cjs/SheetModal/index.native.js.map +1 -1
  15. package/dist/cjs/Tabs/index.cjs +144 -91
  16. package/dist/cjs/Tabs/index.native.js +186 -123
  17. package/dist/cjs/Tabs/index.native.js.map +1 -1
  18. package/dist/cjs/knobImmuneExports.cjs +0 -1
  19. package/dist/cjs/knobImmuneExports.native.js +0 -1
  20. package/dist/cjs/knobImmuneExports.native.js.map +1 -1
  21. package/dist/cjs/tamagui.cjs +3 -2
  22. package/dist/cjs/tamagui.native.js +2 -1
  23. package/dist/cjs/tamagui.native.js.map +1 -1
  24. package/dist/esm/Accordion/index.mjs +4 -0
  25. package/dist/esm/Accordion/index.mjs.map +1 -1
  26. package/dist/esm/Accordion/index.native.js +4 -0
  27. package/dist/esm/Accordion/index.native.js.map +1 -1
  28. package/dist/esm/AnimateHeight/index.mjs +29 -2
  29. package/dist/esm/AnimateHeight/index.mjs.map +1 -1
  30. package/dist/esm/AnimateHeight/index.native.js +30 -2
  31. package/dist/esm/AnimateHeight/index.native.js.map +1 -1
  32. package/dist/esm/Collapsible.mjs +23 -0
  33. package/dist/esm/Collapsible.mjs.map +1 -0
  34. package/dist/esm/Collapsible.native.js +28 -0
  35. package/dist/esm/Collapsible.native.js.map +1 -0
  36. package/dist/esm/Pagination.mjs +28 -9
  37. package/dist/esm/Pagination.mjs.map +1 -1
  38. package/dist/esm/Pagination.native.js +28 -9
  39. package/dist/esm/Pagination.native.js.map +1 -1
  40. package/dist/esm/SheetModal/index.native.js +49 -25
  41. package/dist/esm/SheetModal/index.native.js.map +1 -1
  42. package/dist/esm/Tabs/index.mjs +144 -91
  43. package/dist/esm/Tabs/index.mjs.map +1 -1
  44. package/dist/esm/Tabs/index.native.js +186 -123
  45. package/dist/esm/Tabs/index.native.js.map +1 -1
  46. package/dist/esm/knobImmuneExports.mjs +0 -1
  47. package/dist/esm/knobImmuneExports.mjs.map +1 -1
  48. package/dist/esm/knobImmuneExports.native.js +0 -1
  49. package/dist/esm/knobImmuneExports.native.js.map +1 -1
  50. package/dist/esm/tamagui.mjs +2 -1
  51. package/dist/esm/tamagui.mjs.map +1 -1
  52. package/dist/esm/tamagui.native.js +2 -1
  53. package/dist/esm/tamagui.native.js.map +1 -1
  54. package/dist/jsx/Accordion/index.mjs +4 -0
  55. package/dist/jsx/Accordion/index.mjs.map +1 -1
  56. package/dist/jsx/Accordion/index.native.js +4 -0
  57. package/dist/jsx/Accordion/index.native.js.map +1 -1
  58. package/dist/jsx/AnimateHeight/index.mjs +29 -2
  59. package/dist/jsx/AnimateHeight/index.mjs.map +1 -1
  60. package/dist/jsx/AnimateHeight/index.native.js +30 -2
  61. package/dist/jsx/AnimateHeight/index.native.js.map +1 -1
  62. package/dist/jsx/Collapsible.mjs +23 -0
  63. package/dist/jsx/Collapsible.mjs.map +1 -0
  64. package/dist/jsx/Collapsible.native.js +56 -0
  65. package/dist/jsx/Collapsible.native.js.map +1 -0
  66. package/dist/jsx/Pagination.mjs +28 -9
  67. package/dist/jsx/Pagination.mjs.map +1 -1
  68. package/dist/jsx/Pagination.native.js +28 -9
  69. package/dist/jsx/Pagination.native.js.map +1 -1
  70. package/dist/jsx/SheetModal/index.native.js +49 -25
  71. package/dist/jsx/SheetModal/index.native.js.map +1 -1
  72. package/dist/jsx/Tabs/index.mjs +144 -91
  73. package/dist/jsx/Tabs/index.mjs.map +1 -1
  74. package/dist/jsx/Tabs/index.native.js +186 -123
  75. package/dist/jsx/Tabs/index.native.js.map +1 -1
  76. package/dist/jsx/knobImmuneExports.mjs +0 -1
  77. package/dist/jsx/knobImmuneExports.mjs.map +1 -1
  78. package/dist/jsx/knobImmuneExports.native.js +0 -1
  79. package/dist/jsx/knobImmuneExports.native.js.map +1 -1
  80. package/dist/jsx/tamagui.mjs +2 -1
  81. package/dist/jsx/tamagui.mjs.map +1 -1
  82. package/dist/jsx/tamagui.native.js +2 -1
  83. package/dist/jsx/tamagui.native.js.map +1 -1
  84. package/package.json +9 -9
  85. package/src/Accordion/index.tsx +7 -1
  86. package/src/AnimateHeight/AnimateHeight.native.spec.tsx +51 -0
  87. package/src/AnimateHeight/index.tsx +30 -5
  88. package/src/Collapsible.tsx +23 -0
  89. package/src/Pagination.simple.spec.tsx +23 -0
  90. package/src/Pagination.stories.tsx +8 -0
  91. package/src/Pagination.tsx +27 -10
  92. package/src/SheetModal/SheetModal.native.spec.tsx +138 -0
  93. package/src/SheetModal/index.native.tsx +59 -26
  94. package/src/Tabs/Tabs.bandColor.spec.tsx +23 -0
  95. package/src/Tabs/Tabs.native.spec.tsx +35 -0
  96. package/src/Tabs/Tabs.spec.tsx +51 -1
  97. package/src/Tabs/Tabs.stories.tsx +27 -1
  98. package/src/Tabs/index.tsx +266 -187
  99. package/src/disclosureForm.spec.tsx +57 -0
  100. package/src/disclosureNativeProps.spec.tsx +35 -0
  101. package/src/knobImmuneExports.ts +0 -2
  102. package/src/primitives.spec.tsx +19 -4
  103. package/src/tamagui.ts +3 -1
  104. package/types/Accordion/index.d.ts +2 -0
  105. package/types/Accordion/index.d.ts.map +1 -1
  106. package/types/AnimateHeight/index.d.ts +3 -4
  107. package/types/AnimateHeight/index.d.ts.map +1 -1
  108. package/types/Collapsible.d.ts +19 -0
  109. package/types/Collapsible.d.ts.map +1 -0
  110. package/types/Pagination.d.ts +1 -1
  111. package/types/Pagination.d.ts.map +1 -1
  112. package/types/SheetModal/index.native.d.ts.map +1 -1
  113. package/types/Tabs/index.d.ts +7 -3
  114. package/types/Tabs/index.d.ts.map +1 -1
  115. package/types/knobImmuneExports.d.ts.map +1 -1
  116. package/types/tamagui.d.ts +2 -1
  117. package/types/tamagui.d.ts.map +1 -1
@@ -59,7 +59,7 @@ import { componentColors } from "../componentColors";
59
59
 
60
60
  // ── Types ─────────────────────────────────────────────────────
61
61
 
62
- export type TabsVariant = "underline" | "contained";
62
+ export type TabsVariant = "underline" | "contained" | "band";
63
63
  export type TabsSize = "$2" | "$3" | "$4" | "$5";
64
64
  export type TabsDirection = "ltr" | "rtl";
65
65
 
@@ -68,6 +68,8 @@ export interface TabsItem {
68
68
  value: string;
69
69
  /** Tab label */
70
70
  label: ReactNode;
71
+ /** Spoken name when the label contains more than plain text. */
72
+ accessibilityLabel?: string;
71
73
  /** Optional leading icon */
72
74
  icon?: ReactNode;
73
75
  /** Optional trailing adornment (e.g. a Badge/count pill) */
@@ -89,6 +91,8 @@ export interface TabsProps {
89
91
  onChange?: (value: string) => void;
90
92
  /** underline (desk-style, default) or contained (segmented track) */
91
93
  variant?: TabsVariant;
94
+ /** Equal tabs share a bounded row. Band always uses equal distribution. */
95
+ distribution?: "content" | "equal";
92
96
  /** Accent intent for the active indicator (default) */
93
97
  accent?: boolean;
94
98
  /** Error intent */
@@ -218,7 +222,8 @@ const fillProps = { flex: 1, minHeight: 0 } as const;
218
222
 
219
223
  const StripWrapper = styled(View, {
220
224
  name: "MpTabsStrip",
221
- width: "100%",
225
+ alignSelf: "stretch",
226
+ minWidth: 0,
222
227
  position: "relative",
223
228
  });
224
229
 
@@ -297,6 +302,7 @@ function TabsRoot({
297
302
  defaultValue,
298
303
  onChange,
299
304
  variant = "underline",
305
+ distribution = "content",
300
306
  accent,
301
307
  error,
302
308
  warning,
@@ -395,6 +401,27 @@ function TabsRoot({
395
401
  }, [activeLayout, direction]);
396
402
 
397
403
  const isUnderline = variant === "underline";
404
+ const isBand = variant === "band";
405
+ const equalDistribution = isBand || distribution === "equal";
406
+ const tabRefs = React.useRef(new Map<string, HTMLElement>());
407
+ const onTabKeyDown = (event: React.KeyboardEvent, value: string) => {
408
+ if (!["Home", "End", "ArrowLeft", "ArrowRight"].includes(event.key)) return;
409
+ const enabled = items.filter((item) => !disabled && !item.disabled);
410
+ if (!enabled.length) return;
411
+ const current = enabled.findIndex((item) => item.value === value);
412
+ const step = (event.key === "ArrowRight" ? 1 : -1) * (direction === "rtl" ? -1 : 1);
413
+ const next =
414
+ event.key === "Home"
415
+ ? 0
416
+ : event.key === "End"
417
+ ? enabled.length - 1
418
+ : loop
419
+ ? (current + step + enabled.length) % enabled.length
420
+ : Math.max(0, Math.min(enabled.length - 1, current + step));
421
+ event.preventDefault();
422
+ event.stopPropagation();
423
+ tabRefs.current.get(enabled[next]!.value)?.focus();
424
+ };
398
425
  const indicatorTheme = (intent ?? "accent") as ThemeName;
399
426
  // Size recipe owns chrome (LC-75). Explicit `size` already flowed into
400
427
  // knobProps via useResolvedKnobs. Compact tightens gaps only (LC-76).
@@ -426,36 +453,209 @@ function TabsRoot({
426
453
  })()
427
454
  : undefined;
428
455
 
429
- const indicator = activeLayout ? (
430
- isUnderline ? (
431
- <Theme name={indicatorTheme}>
432
- <UnderlineIndicator
433
- x={activeLayout.x}
434
- width={activeLayout.width}
435
- data-tabs-indicator="underline"
436
- {...(transition ? { transition } : undefined)}
437
- />
438
- </Theme>
439
- ) : (
440
- <Theme name={indicatorTheme}>
441
- <ThumbIndicator
442
- x={activeLayout.x}
443
- y={activeLayout.y}
444
- width={activeLayout.width}
445
- height={activeLayout.height}
446
- backgroundColor={selectedTint ?? "$color4"}
447
- data-tabs-indicator="thumb"
448
- {...thumbRadius}
449
- {...(knobProps.elevation !== undefined ? { elevation: knobProps.elevation } : undefined)}
450
- {...(transition ? { transition } : undefined)}
451
- />
452
- </Theme>
453
- )
454
- ) : null;
456
+ const indicator =
457
+ activeLayout && !isBand ? (
458
+ isUnderline ? (
459
+ <Theme name={indicatorTheme}>
460
+ <UnderlineIndicator
461
+ x={activeLayout.x}
462
+ width={activeLayout.width}
463
+ data-tabs-indicator="underline"
464
+ {...(transition ? { transition } : undefined)}
465
+ />
466
+ </Theme>
467
+ ) : (
468
+ <Theme name={indicatorTheme}>
469
+ <ThumbIndicator
470
+ x={activeLayout.x}
471
+ y={activeLayout.y}
472
+ width={activeLayout.width}
473
+ height={activeLayout.height}
474
+ backgroundColor={selectedTint ?? "$color4"}
475
+ data-tabs-indicator="thumb"
476
+ {...thumbRadius}
477
+ {...(knobProps.elevation !== undefined
478
+ ? { elevation: knobProps.elevation }
479
+ : undefined)}
480
+ {...(transition ? { transition } : undefined)}
481
+ />
482
+ </Theme>
483
+ )
484
+ ) : null;
455
485
 
456
486
  if (items.length === 0) return null;
457
487
 
458
488
  const tabListLabel = ariaLabel ?? t("Tabs");
489
+ const tabList = (
490
+ <TabsList
491
+ loop={loop}
492
+ aria-label={tabListLabel}
493
+ data-tabs-list={variant}
494
+ data-size-token={visualSizeToken}
495
+ data-density={knobProps.density}
496
+ data-visual-px={sizePx}
497
+ {...(!isWeb ? { role: "tablist" as const } : undefined)}
498
+ alignSelf={equalDistribution ? "stretch" : "flex-start"}
499
+ minWidth={0}
500
+ contained={!isUnderline && !isBand}
501
+ {...(isUnderline || isBand
502
+ ? { gap: 0 }
503
+ : {
504
+ backgroundColor: componentColors.interactive.background,
505
+ gap: 0,
506
+ // Full fragment spread (AGENTS.md knob rule) so the
507
+ // cornerSmoothing class rides along with the radius.
508
+ ...knobProps.borderRadiusNested,
509
+ })}
510
+ >
511
+ {indicator}
512
+ {items.map((item, itemIndex) => {
513
+ const isActive = item.value === currentValue;
514
+ const isDisabled = disabled || item.disabled;
515
+ const position = getGroupPosition(itemIndex, enabledCount);
516
+ const cornerProps = isUnderline
517
+ ? underlineStackRadius(position, radiusToken)
518
+ : stackRadiusProps(position, outerRadiusToken, "horizontal");
519
+ // Roving tabindex (single tab stop): tamagui's vendored
520
+ // roving-focus item hardcodes tabIndex=0 on every tab, making
521
+ // each tab its own Tab stop. Exactly one tab stays in the page
522
+ // tab order — the focused one while focus is in the tablist,
523
+ // else the active one (or the first, when value matches
524
+ // nothing); ArrowLeft/Right move within the tablist.
525
+ const anchorValue =
526
+ (focusValue !== null &&
527
+ items.some((i) => i.value === focusValue && !(disabled || i.disabled))
528
+ ? focusValue
529
+ : undefined) ??
530
+ (items.some((i) => i.value === currentValue && !(disabled || i.disabled))
531
+ ? currentValue
532
+ : items.find((i) => !(disabled || i.disabled))?.value);
533
+ const isTabStop =
534
+ item.value === anchorValue &&
535
+ itemIndex === items.findIndex((i) => i.value === anchorValue);
536
+ const kbRing = kbFocusValue === item.value;
537
+ return (
538
+ <Trigger
539
+ key={item.value}
540
+ value={item.value}
541
+ ref={(node) => {
542
+ if (!isWeb) return;
543
+ if (node) tabRefs.current.set(item.value, node as unknown as HTMLElement);
544
+ else tabRefs.current.delete(item.value);
545
+ }}
546
+ flex={equalDistribution ? 1 : undefined}
547
+ minWidth={0}
548
+ minHeight={isBand ? MIN_PRESS_TARGET : undefined}
549
+ borderEndWidth={isBand && itemIndex < items.length - 1 ? 1 : 0}
550
+ borderColor={isBand ? componentColors.surface.border : undefined}
551
+ disabled={isDisabled}
552
+ {...(item.content === undefined ? { "aria-controls": undefined } : undefined)}
553
+ data-tab-position={position}
554
+ data-kb-ring={kbRing ? "true" : undefined}
555
+ {...(isWeb
556
+ ? {
557
+ tabIndex: isDisabled ? -1 : isTabStop ? 0 : -1,
558
+ "aria-label": item.accessibilityLabel,
559
+ onKeyDownCapture: (event: React.KeyboardEvent) => onTabKeyDown(event, item.value),
560
+ }
561
+ : {
562
+ accessible: true,
563
+ role: "button" as const,
564
+ accessibilityRole: "button" as const,
565
+ accessibilityLabel:
566
+ item.accessibilityLabel ??
567
+ (typeof item.label === "string" || typeof item.label === "number"
568
+ ? String(item.label)
569
+ : undefined),
570
+ accessibilityState: { selected: isActive, disabled: Boolean(isDisabled) },
571
+ })}
572
+ hitSlop={tabHitSlop}
573
+ onInteraction={handleInteraction}
574
+ cursor={isDisabled ? "not-allowed" : "pointer"}
575
+ backgroundColor={
576
+ isBand && isActive ? componentColors.surface.background : "transparent"
577
+ }
578
+ borderRadius={0}
579
+ {...(isDisabled
580
+ ? isActive
581
+ ? disabledState.chromeKnobProps
582
+ : { ...disabledState.chromeKnobProps, ...disabledState.textKnobProps }
583
+ : undefined)}
584
+ {...(isWeb
585
+ ? {
586
+ onFocusCapture: () => {
587
+ setFocusValue(item.value);
588
+ if (wasKeyboardFocus()) setKbFocusValue(item.value);
589
+ },
590
+ onBlurCapture: () => {
591
+ setFocusValue((prev) => (prev === item.value ? null : prev));
592
+ setKbFocusValue((prev) => (prev === item.value ? null : prev));
593
+ },
594
+ onPointerEnter: () => setHoverValue(item.value),
595
+ onPointerLeave: () =>
596
+ setHoverValue((prev) => (prev === item.value ? null : prev)),
597
+ onPointerDown: () => setPressValue(item.value),
598
+ onPointerUp: () => setPressValue((prev) => (prev === item.value ? null : prev)),
599
+ }
600
+ : undefined)}
601
+ >
602
+ <View
603
+ {...(isBand ? undefined : cornerProps)}
604
+ // ONE chrome owner (LC-75/LC-79): the full control recipe
605
+ // (height/paddingHorizontal/gap) lives on this inner row —
606
+ // it also paints hover/press fill, so the pill covers the
607
+ // padded box. The Trigger stays an unpainted hit target
608
+ // (spreading the recipe there too doubled the horizontal
609
+ // padding).
610
+ {...knobProps.control}
611
+ // Equal columns own band spacing; extra inset can exceed a narrow column.
612
+ paddingHorizontal={isBand ? 0 : knobProps.control.paddingHorizontal}
613
+ alignSelf="stretch"
614
+ flexGrow={equalDistribution ? 1 : undefined}
615
+ flexShrink={equalDistribution ? 1 : undefined}
616
+ flexBasis={equalDistribution ? 0 : undefined}
617
+ minWidth={0}
618
+ flexDirection="row"
619
+ alignItems="center"
620
+ justifyContent="center"
621
+ backgroundColor={
622
+ isActive || isDisabled
623
+ ? "transparent"
624
+ : pressValue === item.value
625
+ ? componentColors.interactive.hover
626
+ : hoverValue === item.value
627
+ ? componentColors.interactive.background
628
+ : "transparent"
629
+ }
630
+ {...(kbRing ? keyboardFocusRingProps : undefined)}
631
+ >
632
+ {item.icon}
633
+ <TabLabel
634
+ minWidth={equalDistribution ? 0 : undefined}
635
+ flexShrink={equalDistribution ? 1 : undefined}
636
+ active={isActive}
637
+ {...knobProps.controlType}
638
+ fontFamily={knobProps.label.fontFamily}
639
+ fontWeight={!isBand && isActive ? "700" : knobProps.label.fontWeight}
640
+ color={
641
+ isDisabled
642
+ ? componentColors.text.muted
643
+ : isActive
644
+ ? variant === "contained" && onSelectedTint
645
+ ? onSelectedTint
646
+ : componentColors.text.primary
647
+ : componentColors.text.secondary
648
+ }
649
+ >
650
+ {wrapBareTextChildren(item.label, (label) => label)}
651
+ </TabLabel>
652
+ {item.badge}
653
+ </View>
654
+ </Trigger>
655
+ );
656
+ })}
657
+ </TabsList>
658
+ );
459
659
 
460
660
  return (
461
661
  <TamaguiTabs
@@ -463,13 +663,16 @@ function TabsRoot({
463
663
  onValueChange={handleChange}
464
664
  orientation="horizontal"
465
665
  activationMode={activationMode}
466
- width="100%"
666
+ width={equalDistribution ? "100%" : undefined}
667
+ alignSelf="stretch"
668
+ minWidth={0}
467
669
  flexDirection="column"
468
670
  // The root is the first link in the chain a filling panel needs.
469
671
  // `flex: 1` alone is not enough — without `minHeight: 0` the column
470
672
  // refuses to shrink below its content and the panel never gets a box.
471
673
  {...(fill ? fillProps : undefined)}
472
674
  data-tabs-variant={variant}
675
+ data-tabs-distribution={equalDistribution ? "equal" : "content"}
473
676
  data-tabs-fill={fill ? "true" : undefined}
474
677
  data-tabs-dir={direction}
475
678
  data-size-token={visualSizeToken}
@@ -477,171 +680,47 @@ function TabsRoot({
477
680
  data-visual-px={sizePx}
478
681
  >
479
682
  <StripWrapper
683
+ backgroundColor={isBand ? "$color2" : undefined}
684
+ borderBottomWidth={isBand ? 1 : undefined}
685
+ borderBottomColor={isBand ? componentColors.surface.border : undefined}
480
686
  {...(isUnderline
481
687
  ? { ...hairline.bottom, borderColor: componentColors.surface.border }
482
688
  : undefined)}
483
689
  data-fade-left={fades.left ? "true" : undefined}
484
690
  data-fade-right={fades.right ? "true" : undefined}
485
691
  >
486
- <ScrollView
487
- ref={scrollRef}
488
- horizontal
489
- showsHorizontalScrollIndicator={false}
490
- scrollEventThrottle={16}
491
- style={maskStyle as never}
492
- // Slop padding keeps the 44px press floor from being clipped when
493
- // the painted tab is smaller than the floor (LC-54 / nested scale).
494
- contentContainerStyle={{ paddingVertical: slop }}
495
- marginVertical={-slop}
496
- onScroll={(e) => {
497
- scrollMetrics.current.offset = e.nativeEvent.contentOffset.x;
498
- updateFades();
499
- }}
500
- onLayout={(e) => {
501
- scrollMetrics.current.viewport = e.nativeEvent.layout.width;
502
- updateFades();
503
- }}
504
- onContentSizeChange={(w) => {
505
- scrollMetrics.current.content = w;
506
- updateFades();
507
- }}
508
- >
509
- <TabsList
510
- loop={loop}
511
- aria-label={tabListLabel}
512
- data-tabs-list={variant}
513
- data-size-token={visualSizeToken}
514
- data-density={knobProps.density}
515
- data-visual-px={sizePx}
516
- {...(!isWeb ? { role: "tablist" as const } : undefined)}
517
- contained={!isUnderline}
518
- {...(isUnderline
519
- ? { gap: 0 }
520
- : {
521
- backgroundColor: componentColors.interactive.background,
522
- gap: 0,
523
- // Full fragment spread (AGENTS.md knob rule) so the
524
- // cornerSmoothing class rides along with the radius.
525
- ...knobProps.borderRadiusNested,
526
- })}
692
+ {equalDistribution ? (
693
+ <View alignSelf="stretch" minWidth={0}>
694
+ {tabList}
695
+ </View>
696
+ ) : (
697
+ <ScrollView
698
+ ref={scrollRef}
699
+ data-tabs-scroll="true"
700
+ horizontal
701
+ showsHorizontalScrollIndicator={false}
702
+ scrollEventThrottle={16}
703
+ style={maskStyle as never}
704
+ // Slop padding keeps the 44px press floor from being clipped when
705
+ // the painted tab is smaller than the floor (LC-54 / nested scale).
706
+ contentContainerStyle={{ paddingVertical: slop }}
707
+ marginVertical={-slop}
708
+ onScroll={(e) => {
709
+ scrollMetrics.current.offset = e.nativeEvent.contentOffset.x;
710
+ updateFades();
711
+ }}
712
+ onLayout={(e) => {
713
+ scrollMetrics.current.viewport = e.nativeEvent.layout.width;
714
+ updateFades();
715
+ }}
716
+ onContentSizeChange={(w) => {
717
+ scrollMetrics.current.content = w;
718
+ updateFades();
719
+ }}
527
720
  >
528
- {indicator}
529
- {items.map((item, itemIndex) => {
530
- const isActive = item.value === currentValue;
531
- const isDisabled = disabled || item.disabled;
532
- const position = getGroupPosition(itemIndex, enabledCount);
533
- const cornerProps = isUnderline
534
- ? underlineStackRadius(position, radiusToken)
535
- : stackRadiusProps(position, outerRadiusToken, "horizontal");
536
- // Roving tabindex (single tab stop): tamagui's vendored
537
- // roving-focus item hardcodes tabIndex=0 on every tab, making
538
- // each tab its own Tab stop. Exactly one tab stays in the page
539
- // tab order — the focused one while focus is in the tablist,
540
- // else the active one (or the first, when value matches
541
- // nothing); ArrowLeft/Right move within the tablist.
542
- const anchorValue =
543
- (focusValue !== null &&
544
- items.some((i) => i.value === focusValue && !(disabled || i.disabled))
545
- ? focusValue
546
- : undefined) ??
547
- (items.some((i) => i.value === currentValue) ? currentValue : items[0]?.value);
548
- const isTabStop =
549
- item.value === anchorValue &&
550
- itemIndex === items.findIndex((i) => i.value === anchorValue);
551
- const kbRing = kbFocusValue === item.value;
552
- return (
553
- <Trigger
554
- key={item.value}
555
- value={item.value}
556
- disabled={isDisabled}
557
- data-tab-position={position}
558
- data-kb-ring={kbRing ? "true" : undefined}
559
- {...(isWeb
560
- ? { tabIndex: isDisabled ? -1 : isTabStop ? 0 : -1 }
561
- : {
562
- accessible: true,
563
- role: "tab" as const,
564
- "aria-selected": isActive,
565
- ...(isDisabled ? { "aria-disabled": true } : undefined),
566
- })}
567
- hitSlop={tabHitSlop}
568
- onInteraction={handleInteraction}
569
- cursor={isDisabled ? "not-allowed" : "pointer"}
570
- backgroundColor="transparent"
571
- borderRadius={0}
572
- {...(isDisabled
573
- ? isActive
574
- ? disabledState.chromeKnobProps
575
- : { ...disabledState.chromeKnobProps, ...disabledState.textKnobProps }
576
- : undefined)}
577
- {...(isWeb
578
- ? {
579
- onFocusCapture: () => {
580
- setFocusValue(item.value);
581
- if (wasKeyboardFocus()) setKbFocusValue(item.value);
582
- },
583
- onBlurCapture: () => {
584
- setFocusValue((prev) => (prev === item.value ? null : prev));
585
- setKbFocusValue((prev) => (prev === item.value ? null : prev));
586
- },
587
- onPointerEnter: () => setHoverValue(item.value),
588
- onPointerLeave: () =>
589
- setHoverValue((prev) => (prev === item.value ? null : prev)),
590
- onPointerDown: () => setPressValue(item.value),
591
- onPointerUp: () =>
592
- setPressValue((prev) => (prev === item.value ? null : prev)),
593
- }
594
- : undefined)}
595
- >
596
- <View
597
- {...cornerProps}
598
- // ONE chrome owner (LC-75/LC-79): the full control recipe
599
- // (height/paddingHorizontal/gap) lives on this inner row —
600
- // it also paints hover/press fill, so the pill covers the
601
- // padded box. The Trigger stays an unpainted hit target
602
- // (spreading the recipe there too doubled the horizontal
603
- // padding).
604
- {...knobProps.control}
605
- width="100%"
606
- flexDirection="row"
607
- alignItems="center"
608
- justifyContent="center"
609
- backgroundColor={
610
- isActive || isDisabled
611
- ? "transparent"
612
- : pressValue === item.value
613
- ? componentColors.interactive.hover
614
- : hoverValue === item.value
615
- ? componentColors.interactive.background
616
- : "transparent"
617
- }
618
- {...(kbRing ? keyboardFocusRingProps : undefined)}
619
- >
620
- {item.icon}
621
- <TabLabel
622
- active={isActive}
623
- {...knobProps.controlType}
624
- fontFamily={knobProps.label.fontFamily}
625
- fontWeight={isActive ? "700" : knobProps.label.fontWeight}
626
- color={
627
- isDisabled
628
- ? componentColors.text.muted
629
- : isActive
630
- ? !isUnderline && onSelectedTint
631
- ? onSelectedTint
632
- : componentColors.text.primary
633
- : componentColors.text.secondary
634
- }
635
- >
636
- {wrapBareTextChildren(item.label, (label) => label)}
637
- </TabLabel>
638
- {item.badge}
639
- </View>
640
- </Trigger>
641
- );
642
- })}
643
- </TabsList>
644
- </ScrollView>
721
+ {tabList}
722
+ </ScrollView>
723
+ )}
645
724
  </StripWrapper>
646
725
 
647
726
  {/*
@@ -0,0 +1,57 @@
1
+ /** @vitest-environment jsdom */
2
+ import { fireEvent } from "@testing-library/react";
3
+ import { renderWithProviders } from "@multiplatform.one/test-utils";
4
+ import { describe, expect, it, vi } from "vitest";
5
+ import { Accordion } from "./Accordion";
6
+ import { Collapsible } from "./tamagui";
7
+
8
+ function Disclosure({
9
+ kind,
10
+ type,
11
+ }: {
12
+ kind: "accordion" | "collapsible";
13
+ type?: "button" | "submit";
14
+ }) {
15
+ const props = type ? { type } : {};
16
+ return kind === "accordion" ? (
17
+ <Accordion type="single" collapsible>
18
+ <Accordion.Item value="preview">
19
+ <Accordion.Trigger {...props}>Preview</Accordion.Trigger>
20
+ <Accordion.Content>Contents</Accordion.Content>
21
+ </Accordion.Item>
22
+ </Accordion>
23
+ ) : (
24
+ <Collapsible>
25
+ <Collapsible.Trigger {...props}>Preview</Collapsible.Trigger>
26
+ <Collapsible.Content>Contents</Collapsible.Content>
27
+ </Collapsible>
28
+ );
29
+ }
30
+
31
+ describe("disclosure triggers inside forms", () => {
32
+ for (const kind of ["accordion", "collapsible"] as const) {
33
+ it(`${kind} opens without submitting by default`, () => {
34
+ const submit = vi.fn((event) => event.preventDefault());
35
+ const result = renderWithProviders(
36
+ <form onSubmit={submit}>
37
+ <Disclosure kind={kind} />
38
+ </form>,
39
+ );
40
+ const trigger = result.getByRole("button", { name: "Preview" });
41
+ fireEvent.click(trigger);
42
+ expect(trigger.getAttribute("aria-expanded")).toBe("true");
43
+ expect(submit).not.toHaveBeenCalled();
44
+ expect(trigger.getAttribute("type")).toBe("button");
45
+ });
46
+ it(`${kind} preserves an explicit submit request`, () => {
47
+ const submit = vi.fn((event) => event.preventDefault());
48
+ const result = renderWithProviders(
49
+ <form onSubmit={submit}>
50
+ <Disclosure kind={kind} type="submit" />
51
+ </form>,
52
+ );
53
+ fireEvent.click(result.getByRole("button", { name: "Preview" }));
54
+ expect(submit).toHaveBeenCalledTimes(1);
55
+ });
56
+ }
57
+ });
@@ -0,0 +1,35 @@
1
+ import { createRef } from "react";
2
+ import { renderWithProviders } from "@multiplatform.one/test-utils";
3
+ import { expect, it, vi } from "vitest";
4
+ import { Accordion } from "./Accordion";
5
+ import { Collapsible } from "./Collapsible";
6
+
7
+ vi.mock("@multiplatform.one/platform", async (importOriginal) => ({
8
+ ...(await importOriginal<typeof import("@multiplatform.one/platform")>()),
9
+ isWeb: false,
10
+ }));
11
+
12
+ it("the native prop branch omits HTML button intent and retains compound refs", () => {
13
+ const root = createRef<any>();
14
+ const trigger = createRef<any>();
15
+ const result = renderWithProviders(
16
+ <>
17
+ <Collapsible ref={root}>
18
+ <Collapsible.Trigger ref={trigger} type="submit">
19
+ Disclosure
20
+ </Collapsible.Trigger>
21
+ <Collapsible.Content>Contents</Collapsible.Content>
22
+ </Collapsible>
23
+ <Accordion>
24
+ <Accordion.Item value="one">
25
+ <Accordion.Trigger type="submit">Section</Accordion.Trigger>
26
+ <Accordion.Content>Section contents</Accordion.Content>
27
+ </Accordion.Item>
28
+ </Accordion>
29
+ </>,
30
+ );
31
+ expect(root.current).toBeTruthy();
32
+ expect(trigger.current).toBe(result.getByRole("button", { name: "Disclosure" }));
33
+ expect(trigger.current.getAttribute("type")).toBeNull();
34
+ expect(result.getByRole("button", { name: "Section" }).getAttribute("type")).toBeNull();
35
+ });
@@ -42,8 +42,6 @@ export const knobImmuneExports: Readonly<Record<string, string>> = {
42
42
  // own the painted chrome. Restored with the three below after MPO-21 R9 G8
43
43
  // (the Adapt drop) rewrote this file from a pre-MPO-21 copy and took them
44
44
  // out as collateral; Adapt itself stays dropped.
45
- Collapsible:
46
- "composition root (open/close shell); knobs land on composing surfaces (Accordion, Fold)",
47
45
  Unspaced:
48
46
  "spacing opt-out marker; paints nothing, tells the parent stack to skip gap on its children",
49
47
  VisuallyHidden: "a11y hide utility; paints nothing visible, no chrome for knobs to reach",