panelui-native 0.44.0 → 0.49.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 (108) hide show
  1. package/README.md +6 -1
  2. package/lib/module/components/accordion/index.js +32 -4
  3. package/lib/module/components/accordion/index.js.map +1 -1
  4. package/lib/module/components/button/index.js +83 -15
  5. package/lib/module/components/button/index.js.map +1 -1
  6. package/lib/module/components/button-group/index.js +186 -0
  7. package/lib/module/components/button-group/index.js.map +1 -0
  8. package/lib/module/components/color-picker/index.js +110 -1
  9. package/lib/module/components/color-picker/index.js.map +1 -1
  10. package/lib/module/components/combobox/index.js +7 -1
  11. package/lib/module/components/combobox/index.js.map +1 -1
  12. package/lib/module/components/date-time-picker/index.js +272 -0
  13. package/lib/module/components/date-time-picker/index.js.map +1 -0
  14. package/lib/module/components/fab/index.js +514 -0
  15. package/lib/module/components/fab/index.js.map +1 -0
  16. package/lib/module/components/grid-item/index.js +486 -0
  17. package/lib/module/components/grid-item/index.js.map +1 -0
  18. package/lib/module/components/{kpi-chart → kpi}/index.js +62 -62
  19. package/lib/module/components/kpi/index.js.map +1 -0
  20. package/lib/module/components/line-chart/index.js +92 -10
  21. package/lib/module/components/line-chart/index.js.map +1 -1
  22. package/lib/module/components/markdown-editor/index.js +406 -0
  23. package/lib/module/components/markdown-editor/index.js.map +1 -0
  24. package/lib/module/components/markdown-editor/markdown-transforms.js +243 -0
  25. package/lib/module/components/markdown-editor/markdown-transforms.js.map +1 -0
  26. package/lib/module/components/pie-chart/index.js +625 -0
  27. package/lib/module/components/pie-chart/index.js.map +1 -0
  28. package/lib/module/components/questionnaire/index.js +1312 -0
  29. package/lib/module/components/questionnaire/index.js.map +1 -0
  30. package/lib/module/components/scatter-chart/index.js +1173 -0
  31. package/lib/module/components/scatter-chart/index.js.map +1 -0
  32. package/lib/module/components/tabs/index.js +359 -41
  33. package/lib/module/components/tabs/index.js.map +1 -1
  34. package/lib/module/components/time-picker/index.js +34 -6
  35. package/lib/module/components/time-picker/index.js.map +1 -1
  36. package/lib/module/components/tree/index.js +500 -0
  37. package/lib/module/components/tree/index.js.map +1 -0
  38. package/lib/module/icons/index.js +217 -0
  39. package/lib/module/icons/index.js.map +1 -1
  40. package/lib/module/index.js +11 -2
  41. package/lib/module/index.js.map +1 -1
  42. package/lib/module/utils/chart.js +81 -0
  43. package/lib/module/utils/chart.js.map +1 -1
  44. package/lib/typescript/src/components/accordion/index.d.ts +21 -0
  45. package/lib/typescript/src/components/accordion/index.d.ts.map +1 -1
  46. package/lib/typescript/src/components/button/index.d.ts +21 -0
  47. package/lib/typescript/src/components/button/index.d.ts.map +1 -1
  48. package/lib/typescript/src/components/button-group/index.d.ts +212 -0
  49. package/lib/typescript/src/components/button-group/index.d.ts.map +1 -0
  50. package/lib/typescript/src/components/color-picker/index.d.ts +82 -1
  51. package/lib/typescript/src/components/color-picker/index.d.ts.map +1 -1
  52. package/lib/typescript/src/components/combobox/index.d.ts.map +1 -1
  53. package/lib/typescript/src/components/date-time-picker/index.d.ts +127 -0
  54. package/lib/typescript/src/components/date-time-picker/index.d.ts.map +1 -0
  55. package/lib/typescript/src/components/fab/index.d.ts +285 -0
  56. package/lib/typescript/src/components/fab/index.d.ts.map +1 -0
  57. package/lib/typescript/src/components/grid-item/index.d.ts +292 -0
  58. package/lib/typescript/src/components/grid-item/index.d.ts.map +1 -0
  59. package/lib/typescript/src/components/{kpi-chart → kpi}/index.d.ts +65 -65
  60. package/lib/typescript/src/components/kpi/index.d.ts.map +1 -0
  61. package/lib/typescript/src/components/line-chart/index.d.ts +25 -1
  62. package/lib/typescript/src/components/line-chart/index.d.ts.map +1 -1
  63. package/lib/typescript/src/components/markdown-editor/index.d.ts +102 -0
  64. package/lib/typescript/src/components/markdown-editor/index.d.ts.map +1 -0
  65. package/lib/typescript/src/components/markdown-editor/markdown-transforms.d.ts +76 -0
  66. package/lib/typescript/src/components/markdown-editor/markdown-transforms.d.ts.map +1 -0
  67. package/lib/typescript/src/components/pie-chart/index.d.ts +245 -0
  68. package/lib/typescript/src/components/pie-chart/index.d.ts.map +1 -0
  69. package/lib/typescript/src/components/questionnaire/index.d.ts +336 -0
  70. package/lib/typescript/src/components/questionnaire/index.d.ts.map +1 -0
  71. package/lib/typescript/src/components/scatter-chart/index.d.ts +309 -0
  72. package/lib/typescript/src/components/scatter-chart/index.d.ts.map +1 -0
  73. package/lib/typescript/src/components/tabs/index.d.ts +29 -2
  74. package/lib/typescript/src/components/tabs/index.d.ts.map +1 -1
  75. package/lib/typescript/src/components/time-picker/index.d.ts +19 -1
  76. package/lib/typescript/src/components/time-picker/index.d.ts.map +1 -1
  77. package/lib/typescript/src/components/tree/index.d.ts +125 -0
  78. package/lib/typescript/src/components/tree/index.d.ts.map +1 -0
  79. package/lib/typescript/src/icons/index.d.ts +22 -0
  80. package/lib/typescript/src/icons/index.d.ts.map +1 -1
  81. package/lib/typescript/src/index.d.ts +14 -5
  82. package/lib/typescript/src/index.d.ts.map +1 -1
  83. package/lib/typescript/src/utils/chart.d.ts +34 -0
  84. package/lib/typescript/src/utils/chart.d.ts.map +1 -1
  85. package/package.json +1 -1
  86. package/src/components/accordion/index.tsx +48 -6
  87. package/src/components/button/index.tsx +97 -15
  88. package/src/components/button-group/index.tsx +199 -0
  89. package/src/components/color-picker/index.tsx +140 -3
  90. package/src/components/combobox/index.tsx +7 -1
  91. package/src/components/date-time-picker/index.tsx +411 -0
  92. package/src/components/fab/index.tsx +583 -0
  93. package/src/components/grid-item/index.tsx +515 -0
  94. package/src/components/{kpi-chart → kpi}/index.tsx +85 -85
  95. package/src/components/line-chart/index.tsx +98 -8
  96. package/src/components/markdown-editor/index.tsx +526 -0
  97. package/src/components/markdown-editor/markdown-transforms.ts +228 -0
  98. package/src/components/pie-chart/index.tsx +863 -0
  99. package/src/components/questionnaire/index.tsx +1615 -0
  100. package/src/components/scatter-chart/index.tsx +1401 -0
  101. package/src/components/tabs/index.tsx +392 -50
  102. package/src/components/time-picker/index.tsx +42 -6
  103. package/src/components/tree/index.tsx +564 -0
  104. package/src/icons/index.tsx +154 -0
  105. package/src/index.ts +142 -18
  106. package/src/utils/chart.ts +110 -0
  107. package/lib/module/components/kpi-chart/index.js.map +0 -1
  108. package/lib/typescript/src/components/kpi-chart/index.d.ts.map +0 -1
@@ -9,6 +9,14 @@
9
9
  * `selectionMode` decides whether opening one section closes the others, which
10
10
  * also changes the shape of `value`: a string when single, an array when
11
11
  * multiple.
12
+ *
13
+ * A closed section costs nothing, because its body is unmounted. That is the
14
+ * right default and the wrong one for a body with state in it: a half-filled
15
+ * form, a list scrolled to the middle, a video part-way through. Collapsing
16
+ * such a section throws that away and reopening it starts over. `keepMounted`
17
+ * is the way out — the body stays mounted and is hidden from layout instead,
18
+ * so it takes up no room, the same layout transition carries the change, and
19
+ * everything inside is exactly where it was left.
12
20
  */
13
21
  import {
14
22
  Children,
@@ -90,6 +98,7 @@ interface AccordionContextValue {
90
98
  expanded: string[];
91
99
  toggle: (value: string) => void;
92
100
  variant: AccordionVariant;
101
+ keepMounted: boolean;
93
102
  }
94
103
 
95
104
  interface AccordionItemContextValue {
@@ -124,6 +133,13 @@ export interface AccordionProps extends ViewProps {
124
133
  onValueChange?: (value: string | string[]) => void;
125
134
  /** Hide the hairlines drawn between items. */
126
135
  hideSeparator?: boolean;
136
+ /**
137
+ * Keep every body mounted while its section is closed, so state inside it —
138
+ * a part-filled form, a scroll position, a running animation — survives being
139
+ * collapsed. Costs the render of every section up front; set it per section
140
+ * on `Accordion.Content` instead when only one of them needs it.
141
+ */
142
+ keepMounted?: boolean;
127
143
  children?: ReactNode;
128
144
  }
129
145
 
@@ -140,6 +156,7 @@ const AccordionRoot = forwardRef<View, AccordionProps>(
140
156
  defaultValue,
141
157
  onValueChange,
142
158
  hideSeparator = false,
159
+ keepMounted = false,
143
160
  children,
144
161
  ...props
145
162
  },
@@ -169,8 +186,8 @@ const AccordionRoot = forwardRef<View, AccordionProps>(
169
186
  );
170
187
 
171
188
  const context = useMemo(
172
- () => ({ expanded, toggle, variant }),
173
- [expanded, toggle, variant]
189
+ () => ({ expanded, toggle, variant, keepMounted }),
190
+ [expanded, toggle, variant, keepMounted]
174
191
  );
175
192
 
176
193
  const { root, separator } = accordionVariants({ variant });
@@ -299,6 +316,12 @@ AccordionIndicator.displayName = 'Accordion.Indicator';
299
316
 
300
317
  export interface AccordionContentProps extends ViewProps {
301
318
  className?: string;
319
+ /**
320
+ * Stay mounted while closed instead of unmounting, so state inside the body
321
+ * survives the section being collapsed. Overrides the accordion's own
322
+ * setting, either way round.
323
+ */
324
+ keepMounted?: boolean;
302
325
  children?: ReactNode;
303
326
  }
304
327
 
@@ -306,17 +329,36 @@ export interface AccordionContentProps extends ViewProps {
306
329
  * The collapsible body. Unmounts when closed, per the repo's convention for
307
330
  * conditionally shown content — the layout transition on the item animates
308
331
  * the height change.
332
+ *
333
+ * `keepMounted` swaps the unmount for `display: 'none'`, which is the one way
334
+ * to hide a view that also takes it out of Yoga's layout. That matters twice
335
+ * over: the item's height changes by exactly as much as it would have on an
336
+ * unmount, so the same layout transition plays and the two modes are
337
+ * indistinguishable to look at — and everything inside stays mounted, so a
338
+ * text field keeps what was typed into it. A hidden subtree is still in the
339
+ * accessibility tree, though, so it is explicitly taken out of that too;
340
+ * otherwise a screen reader would read out a section the eye cannot see.
309
341
  */
310
342
  const AccordionContent = forwardRef<View, AccordionContentProps>(
311
- ({ className, children, ...props }, ref) => {
312
- const { variant } = useAccordion('Accordion.Content');
343
+ ({ className, keepMounted, children, ...props }, ref) => {
344
+ const { variant, keepMounted: keepMountedDefault } = useAccordion('Accordion.Content');
313
345
  const { isExpanded } = useAccordionItem('Accordion.Content');
314
346
  const { content, contentText } = accordionVariants({ variant });
315
347
 
316
- if (!isExpanded) return null;
348
+ const stayMounted = keepMounted ?? keepMountedDefault;
349
+ if (!isExpanded && !stayMounted) return null;
350
+
351
+ const isHidden = !isExpanded;
317
352
 
318
353
  return (
319
- <View ref={ref} className={content({ className })} {...props}>
354
+ <View
355
+ ref={ref}
356
+ style={isHidden ? { display: 'none' } : undefined}
357
+ accessibilityElementsHidden={isHidden}
358
+ importantForAccessibility={isHidden ? 'no-hide-descendants' : 'auto'}
359
+ className={content({ className })}
360
+ {...props}
361
+ >
320
362
  {textChildren(children, (text) => (
321
363
  <Text className={contentText()}>{text}</Text>
322
364
  ))}
@@ -1,4 +1,4 @@
1
- import { forwardRef, type ReactNode } from 'react';
1
+ import { createContext, forwardRef, useContext, type ReactNode } from 'react';
2
2
  import { View } from 'react-native';
3
3
  import { tv, type VariantProps } from 'tailwind-variants';
4
4
  import { useCSSVariable } from 'uniwind';
@@ -7,6 +7,7 @@ import {
7
7
  type AnimatedPressableProps,
8
8
  } from '../../primitives/animated-pressable';
9
9
  import { Text, textChildren } from '../../primitives/text';
10
+ import { cn } from '../../utils/cn';
10
11
  import { IconColorProvider } from '../../icons';
11
12
  import { getNativeUI, getSwiftUIModifiers } from '../../native';
12
13
  import { Spinner } from '../spinner';
@@ -72,6 +73,65 @@ const buttonVariants = tv({
72
73
 
73
74
  type ButtonVariantProps = VariantProps<typeof buttonVariants>;
74
75
 
76
+ /** How a button looks. */
77
+ export type ButtonVariant = NonNullable<ButtonVariantProps['variant']>;
78
+ /** How big a button is. `icon` is a square. */
79
+ export type ButtonSize = NonNullable<ButtonVariantProps['size']>;
80
+
81
+ /* -------------------------------------------------------------------------- *
82
+ * Grouping
83
+ *
84
+ * A button reads this; `ButtonGroup` writes it. The context lives here rather
85
+ * than beside the group because the button is the consumer, and a component
86
+ * should not have to import the thing that contains it to find out that it is
87
+ * contained — which would also make the two import each other.
88
+ * -------------------------------------------------------------------------- */
89
+
90
+ export interface ButtonGroupContextValue {
91
+ /** Fills in for a button that did not choose one. */
92
+ variant?: ButtonVariant;
93
+ /** Fills in for a button that did not choose one. */
94
+ size?: ButtonSize;
95
+ /** The buttons are joined into one shape, so each drops its own. */
96
+ attached: boolean;
97
+ /** Segments share the row equally. */
98
+ fullWidth: boolean;
99
+ }
100
+
101
+ const ButtonGroupContext = createContext<ButtonGroupContextValue | null>(null);
102
+
103
+ /** Announces to every button below it that it is part of one control. */
104
+ export function ButtonGroupProvider({
105
+ value,
106
+ children,
107
+ }: {
108
+ value: ButtonGroupContextValue;
109
+ children: ReactNode;
110
+ }) {
111
+ return <ButtonGroupContext.Provider value={value}>{children}</ButtonGroupContext.Provider>;
112
+ }
113
+
114
+ /** The enclosing group, if there is one. `null` for a button standing alone. */
115
+ export function useButtonGroup(): ButtonGroupContextValue | null {
116
+ return useContext(ButtonGroupContext);
117
+ }
118
+
119
+ /**
120
+ * What an attached button gives up so the group can draw the shape once.
121
+ *
122
+ * The radius and the shadow go because the group owns both — a rounded,
123
+ * shadowed segment inside a rounded, shadowed container is two shapes where
124
+ * there should be one. The border stays but turns transparent rather than
125
+ * being removed: it is holding a pixel of the button's height, and dropping it
126
+ * would make an `outline` segment a hair shorter than a `ghost` one beside it.
127
+ *
128
+ * The press feedback changes too. A button on its own shrinks slightly when
129
+ * pressed, which inside a joined run would pull the segment away from its
130
+ * neighbours and show the container through the gap. A background is the same
131
+ * signal without the movement.
132
+ */
133
+ const ATTACHED = 'rounded-none border-transparent shadow-none active:bg-accent';
134
+
75
135
  const SPINNER_SIZE = { sm: 'sm', md: 'sm', lg: 'md', icon: 'sm' } as const;
76
136
 
77
137
  /**
@@ -240,10 +300,28 @@ export const Button = forwardRef<View, ButtonProps>(
240
300
  ) => {
241
301
  const isDisabled = disabled || loading;
242
302
  const nativeUI = native ? getNativeUI() : null;
303
+
304
+ /*
305
+ * A group fills in what a button did not say for itself, and never
306
+ * overrides what it did: a run of buttons should look like one control
307
+ * without every segment repeating the same two props, and the odd segment
308
+ * that wants to stand out — the selected one, the destructive one — has to
309
+ * be able to say so.
310
+ *
311
+ * `native` is deliberately outside all of this. The platform draws that
312
+ * button, so it has no border, radius or shadow for a group to take over,
313
+ * and joining several of them would produce a row of platform buttons with
314
+ * a border drawn around it rather than a segmented control.
315
+ */
316
+ const group = useButtonGroup();
317
+ const attached = !native && group?.attached === true;
318
+ const resolvedVariant = variant ?? group?.variant;
319
+ const resolvedSize = size ?? group?.size;
320
+
243
321
  const { root, label, spinner } = buttonVariants({
244
- variant,
245
- size,
246
- fullWidth,
322
+ variant: resolvedVariant,
323
+ size: resolvedSize,
324
+ fullWidth: fullWidth ?? (attached && group?.fullWidth),
247
325
  disabled: isDisabled,
248
326
  });
249
327
 
@@ -252,11 +330,11 @@ export const Button = forwardRef<View, ButtonProps>(
252
330
  // hardcode a hex that is wrong in one theme or the other.
253
331
  const themedColor = useCSSVariable(
254
332
  CONTENT_COLOR_VAR[
255
- variant === 'destructive' ? 'primary' : (variant ?? 'primary')
333
+ resolvedVariant === 'destructive' ? 'primary' : (resolvedVariant ?? 'primary')
256
334
  ]
257
335
  );
258
336
  const contentColor =
259
- variant === 'destructive'
337
+ resolvedVariant === 'destructive'
260
338
  ? '#ffffff'
261
339
  : typeof themedColor === 'string'
262
340
  ? themedColor
@@ -265,7 +343,7 @@ export const Button = forwardRef<View, ButtonProps>(
265
343
  if (nativeUI) {
266
344
  const { Host, Button: NativeButton, RNHostView } = nativeUI;
267
345
  const isStringLabel = typeof children === 'string';
268
- const prominent = variant === 'primary' || variant === 'destructive';
346
+ const prominent = resolvedVariant === 'primary' || resolvedVariant === 'destructive';
269
347
 
270
348
  /*
271
349
  * Looks the portable props cannot ask for.
@@ -285,8 +363,10 @@ export const Button = forwardRef<View, ButtonProps>(
285
363
  const nativeModifiers = swiftUI
286
364
  ? [
287
365
  glass ? swiftUI.buttonStyle(prominent ? 'glassProminent' : 'glass') : null,
288
- size === 'icon' ? swiftUI.buttonBorderShape('circle') : null,
289
- isStringLabel ? swiftUI.controlSize(NATIVE_CONTROL_SIZE[size ?? 'md']) : null,
366
+ resolvedSize === 'icon' ? swiftUI.buttonBorderShape('circle') : null,
367
+ isStringLabel
368
+ ? swiftUI.controlSize(NATIVE_CONTROL_SIZE[resolvedSize ?? 'md'])
369
+ : null,
290
370
  ].filter(Boolean)
291
371
  : [];
292
372
 
@@ -303,7 +383,7 @@ export const Button = forwardRef<View, ButtonProps>(
303
383
  <Host matchContents>
304
384
  <NativeButton
305
385
  label={isStringLabel ? children : undefined}
306
- variant={NATIVE_VARIANT[variant ?? 'primary']}
386
+ variant={NATIVE_VARIANT[resolvedVariant ?? 'primary']}
307
387
  disabled={isDisabled}
308
388
  /*
309
389
  * A square for an icon button, a height for a labelled one — and
@@ -316,9 +396,9 @@ export const Button = forwardRef<View, ButtonProps>(
316
396
  * the result.
317
397
  */
318
398
  style={
319
- size === 'icon'
399
+ resolvedSize === 'icon'
320
400
  ? { width: NATIVE_ICON_FRAME, height: NATIVE_ICON_FRAME }
321
- : { height: NATIVE_HEIGHT[size ?? 'md'] }
401
+ : { height: NATIVE_HEIGHT[resolvedSize ?? 'md'] }
322
402
  }
323
403
  modifiers={nativeModifiers.length ? nativeModifiers : undefined}
324
404
  onPress={props.onPress}
@@ -337,7 +417,7 @@ export const Button = forwardRef<View, ButtonProps>(
337
417
  sits on ends up off to one side of a circle that is not. */}
338
418
  <View
339
419
  style={
340
- size === 'icon'
420
+ resolvedSize === 'icon'
341
421
  ? {
342
422
  padding: NATIVE_ICON_PADDING,
343
423
  alignItems: 'center',
@@ -366,11 +446,13 @@ export const Button = forwardRef<View, ButtonProps>(
366
446
  accessibilityRole="button"
367
447
  accessibilityState={{ disabled: isDisabled, busy: loading }}
368
448
  disabled={isDisabled}
369
- className={root({ className })}
449
+ // Before the spread, so a caller can still ask for the scale back.
450
+ pressScale={attached ? 1 : undefined}
451
+ className={root({ className: cn(attached && ATTACHED, className) })}
370
452
  {...props}
371
453
  >
372
454
  {loading ? (
373
- <Spinner size={SPINNER_SIZE[size ?? 'md']} className={spinner()} />
455
+ <Spinner size={SPINNER_SIZE[resolvedSize ?? 'md']} className={spinner()} />
374
456
  ) : (
375
457
  startContent
376
458
  )}
@@ -0,0 +1,199 @@
1
+ /**
2
+ * ButtonGroup — several buttons drawn as one control.
3
+ *
4
+ * ```tsx
5
+ * <ButtonGroup>
6
+ * <Button startContent={<ListChecksIcon size={16} />}>Tasks</Button>
7
+ * <Button startContent={<CalendarIcon size={16} />}>Agenda</Button>
8
+ * <Button startContent={<ChartIcon size={16} />}>Board</Button>
9
+ * </ButtonGroup>
10
+ * ```
11
+ *
12
+ * The buttons stay buttons. Anything a `Button` does — an icon, a badge, a
13
+ * loading state, a disabled segment, opening a Popover — it still does inside a
14
+ * group, because the group is a container rather than a component that takes a
15
+ * list of items and renders them for you. A list-of-items API has to grow a
16
+ * prop for every one of those things; this one has none of them and can do all
17
+ * of them.
18
+ *
19
+ * ## Why the container draws the border
20
+ *
21
+ * A joined run could be built by giving the first and last segments their
22
+ * corners and squaring the ones between, then collapsing every shared edge with
23
+ * a negative margin. That works on the web and is a stack of off-by-one
24
+ * problems on a phone: the hairlines land on different fractions of a pixel per
25
+ * device, and a run that wraps has no first or last segment any more.
26
+ *
27
+ * So the group draws the shape once — one border, one radius, one shadow,
28
+ * clipped — and the buttons inside it draw none of their own. The dividers are
29
+ * real one-pixel views the group puts between its children, which is also why
30
+ * they are always exactly one pixel and always in the same place.
31
+ *
32
+ * ## What it passes down
33
+ *
34
+ * `variant` and `size` fill in for a button that did not choose its own, so a
35
+ * run of six does not repeat the same two props six times. A segment that wants
36
+ * to stand out — the selected one, the destructive one — sets its own and wins.
37
+ *
38
+ * ```tsx
39
+ * <ButtonGroup size="sm">
40
+ * <Button>Day</Button>
41
+ * <Button variant="secondary">Week</Button> {/* the selected one *\/}
42
+ * <Button>Month</Button>
43
+ * </ButtonGroup>
44
+ * ```
45
+ *
46
+ * ## Not a selection control
47
+ *
48
+ * This joins buttons; what they mean is yours. For a control that owns which
49
+ * one is on, reach for `ToggleButtonGroup`, and for switching between panels of
50
+ * content reach for `Tabs` — a segmented run of buttons that swaps a screen is
51
+ * navigation, and navigation should say so to a screen reader.
52
+ */
53
+ import { Children, Fragment, forwardRef, useMemo, type ReactNode } from 'react';
54
+ import { View, type ViewProps } from 'react-native';
55
+ import { tv, type VariantProps } from 'tailwind-variants';
56
+ import {
57
+ ButtonGroupProvider,
58
+ type ButtonGroupContextValue,
59
+ type ButtonSize,
60
+ type ButtonVariant,
61
+ } from '../button';
62
+
63
+ /** Which way the run reads. */
64
+ export type ButtonGroupOrientation = 'horizontal' | 'vertical';
65
+
66
+ const buttonGroupVariants = tv({
67
+ slots: {
68
+ root: 'items-stretch',
69
+ divider: 'bg-border',
70
+ },
71
+ variants: {
72
+ orientation: {
73
+ horizontal: { root: 'flex-row', divider: 'w-px' },
74
+ vertical: { root: 'flex-col', divider: 'h-px' },
75
+ },
76
+ attached: {
77
+ // `overflow-hidden` is what squares the inner corners and rounds the
78
+ // outer ones without any segment knowing which it is.
79
+ true: { root: 'overflow-hidden border border-input bg-popover shadow-sm' },
80
+ false: { root: 'gap-2' },
81
+ },
82
+ /* Matched to the height the buttons will be, since a radius that suited a
83
+ 36pt run reads as a rectangle around a 48pt one. */
84
+ size: {
85
+ sm: { root: 'rounded-lg' },
86
+ md: { root: 'rounded-xl' },
87
+ lg: { root: 'rounded-xl' },
88
+ icon: { root: 'rounded-xl' },
89
+ },
90
+ fullWidth: {
91
+ true: { root: 'w-full' },
92
+ },
93
+ },
94
+ compoundVariants: [
95
+ // Nothing is being joined, so the radius belongs to each button instead.
96
+ { attached: false, class: { root: 'rounded-none' } },
97
+ ],
98
+ defaultVariants: {
99
+ orientation: 'horizontal',
100
+ attached: true,
101
+ size: 'md',
102
+ },
103
+ });
104
+
105
+ type ButtonGroupVariantProps = VariantProps<typeof buttonGroupVariants>;
106
+
107
+ export interface ButtonGroupProps
108
+ extends ViewProps,
109
+ Omit<ButtonGroupVariantProps, 'size' | 'attached'> {
110
+ className?: string;
111
+ /** Which way the run reads. Vertical is the toolbar down the side of a canvas. */
112
+ orientation?: ButtonGroupOrientation;
113
+ /** Fills in for any button that did not choose its own. */
114
+ variant?: ButtonVariant;
115
+ /** Fills in for any button that did not choose its own, and sets the radius. */
116
+ size?: ButtonSize;
117
+ /**
118
+ * Draw the run as one joined shape.
119
+ *
120
+ * On by default — that is what a group is. Turn it off for a plain row of
121
+ * separate buttons that should still share a variant and a size, which is a
122
+ * toolbar rather than a segmented control.
123
+ */
124
+ attached?: boolean;
125
+ /**
126
+ * Span the container, with the segments sharing it equally.
127
+ *
128
+ * Equally, not by content: a row of segments at their natural widths is a row
129
+ * whose divisions move when the labels change, and a picker whose halves are
130
+ * different sizes reads as though one of them matters more.
131
+ */
132
+ fullWidth?: boolean;
133
+ children?: ReactNode;
134
+ }
135
+
136
+ const ButtonGroupRoot = forwardRef<View, ButtonGroupProps>(
137
+ (
138
+ {
139
+ className,
140
+ orientation = 'horizontal',
141
+ variant,
142
+ size = 'md',
143
+ attached = true,
144
+ fullWidth = false,
145
+ children,
146
+ ...props
147
+ },
148
+ ref
149
+ ) => {
150
+ const { root, divider } = buttonGroupVariants({
151
+ orientation,
152
+ attached,
153
+ size,
154
+ fullWidth,
155
+ });
156
+
157
+ const context = useMemo<ButtonGroupContextValue>(
158
+ () => ({ variant, size, attached, fullWidth }),
159
+ [variant, size, attached, fullWidth]
160
+ );
161
+
162
+ /*
163
+ * `Children.toArray` rather than the raw children: it drops the nulls a
164
+ * conditional segment leaves behind and flattens fragments, so a run built
165
+ * by a `map` or by `{canEdit && <Button/>}` gets its dividers in the right
166
+ * places instead of one before a segment that is not there.
167
+ */
168
+ const items = Children.toArray(children);
169
+
170
+ return (
171
+ <ButtonGroupProvider value={context}>
172
+ <View
173
+ ref={ref}
174
+ // A run of related actions is a toolbar; a screen reader announcing
175
+ // the group is what tells someone the buttons in it belong together.
176
+ accessibilityRole="toolbar"
177
+ className={root({ className })}
178
+ {...props}
179
+ >
180
+ {items.map((child, index) => (
181
+ // Index keys: these fragments have no identity of their own, and
182
+ // the children keep whatever keys they were already given.
183
+ <Fragment key={index}>
184
+ {index > 0 && attached ? <View className={divider()} /> : null}
185
+ {/* The share of the row is taken by a wrapper rather than by the
186
+ button, so a segment that is a Popover trigger — a button
187
+ inside something else — still gets one. */}
188
+ {fullWidth ? <View className="flex-1">{child}</View> : child}
189
+ </Fragment>
190
+ ))}
191
+ </View>
192
+ </ButtonGroupProvider>
193
+ );
194
+ }
195
+ );
196
+
197
+ ButtonGroupRoot.displayName = 'ButtonGroup';
198
+
199
+ export const ButtonGroup = ButtonGroupRoot;