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.
- package/README.md +6 -1
- package/lib/module/components/accordion/index.js +32 -4
- package/lib/module/components/accordion/index.js.map +1 -1
- package/lib/module/components/button/index.js +83 -15
- package/lib/module/components/button/index.js.map +1 -1
- package/lib/module/components/button-group/index.js +186 -0
- package/lib/module/components/button-group/index.js.map +1 -0
- package/lib/module/components/color-picker/index.js +110 -1
- package/lib/module/components/color-picker/index.js.map +1 -1
- package/lib/module/components/combobox/index.js +7 -1
- package/lib/module/components/combobox/index.js.map +1 -1
- package/lib/module/components/date-time-picker/index.js +272 -0
- package/lib/module/components/date-time-picker/index.js.map +1 -0
- package/lib/module/components/fab/index.js +514 -0
- package/lib/module/components/fab/index.js.map +1 -0
- package/lib/module/components/grid-item/index.js +486 -0
- package/lib/module/components/grid-item/index.js.map +1 -0
- package/lib/module/components/{kpi-chart → kpi}/index.js +62 -62
- package/lib/module/components/kpi/index.js.map +1 -0
- package/lib/module/components/line-chart/index.js +92 -10
- package/lib/module/components/line-chart/index.js.map +1 -1
- package/lib/module/components/markdown-editor/index.js +406 -0
- package/lib/module/components/markdown-editor/index.js.map +1 -0
- package/lib/module/components/markdown-editor/markdown-transforms.js +243 -0
- package/lib/module/components/markdown-editor/markdown-transforms.js.map +1 -0
- package/lib/module/components/pie-chart/index.js +625 -0
- package/lib/module/components/pie-chart/index.js.map +1 -0
- package/lib/module/components/questionnaire/index.js +1312 -0
- package/lib/module/components/questionnaire/index.js.map +1 -0
- package/lib/module/components/scatter-chart/index.js +1173 -0
- package/lib/module/components/scatter-chart/index.js.map +1 -0
- package/lib/module/components/tabs/index.js +359 -41
- package/lib/module/components/tabs/index.js.map +1 -1
- package/lib/module/components/time-picker/index.js +34 -6
- package/lib/module/components/time-picker/index.js.map +1 -1
- package/lib/module/components/tree/index.js +500 -0
- package/lib/module/components/tree/index.js.map +1 -0
- package/lib/module/icons/index.js +217 -0
- package/lib/module/icons/index.js.map +1 -1
- package/lib/module/index.js +11 -2
- package/lib/module/index.js.map +1 -1
- package/lib/module/utils/chart.js +81 -0
- package/lib/module/utils/chart.js.map +1 -1
- package/lib/typescript/src/components/accordion/index.d.ts +21 -0
- package/lib/typescript/src/components/accordion/index.d.ts.map +1 -1
- package/lib/typescript/src/components/button/index.d.ts +21 -0
- package/lib/typescript/src/components/button/index.d.ts.map +1 -1
- package/lib/typescript/src/components/button-group/index.d.ts +212 -0
- package/lib/typescript/src/components/button-group/index.d.ts.map +1 -0
- package/lib/typescript/src/components/color-picker/index.d.ts +82 -1
- package/lib/typescript/src/components/color-picker/index.d.ts.map +1 -1
- package/lib/typescript/src/components/combobox/index.d.ts.map +1 -1
- package/lib/typescript/src/components/date-time-picker/index.d.ts +127 -0
- package/lib/typescript/src/components/date-time-picker/index.d.ts.map +1 -0
- package/lib/typescript/src/components/fab/index.d.ts +285 -0
- package/lib/typescript/src/components/fab/index.d.ts.map +1 -0
- package/lib/typescript/src/components/grid-item/index.d.ts +292 -0
- package/lib/typescript/src/components/grid-item/index.d.ts.map +1 -0
- package/lib/typescript/src/components/{kpi-chart → kpi}/index.d.ts +65 -65
- package/lib/typescript/src/components/kpi/index.d.ts.map +1 -0
- package/lib/typescript/src/components/line-chart/index.d.ts +25 -1
- package/lib/typescript/src/components/line-chart/index.d.ts.map +1 -1
- package/lib/typescript/src/components/markdown-editor/index.d.ts +102 -0
- package/lib/typescript/src/components/markdown-editor/index.d.ts.map +1 -0
- package/lib/typescript/src/components/markdown-editor/markdown-transforms.d.ts +76 -0
- package/lib/typescript/src/components/markdown-editor/markdown-transforms.d.ts.map +1 -0
- package/lib/typescript/src/components/pie-chart/index.d.ts +245 -0
- package/lib/typescript/src/components/pie-chart/index.d.ts.map +1 -0
- package/lib/typescript/src/components/questionnaire/index.d.ts +336 -0
- package/lib/typescript/src/components/questionnaire/index.d.ts.map +1 -0
- package/lib/typescript/src/components/scatter-chart/index.d.ts +309 -0
- package/lib/typescript/src/components/scatter-chart/index.d.ts.map +1 -0
- package/lib/typescript/src/components/tabs/index.d.ts +29 -2
- package/lib/typescript/src/components/tabs/index.d.ts.map +1 -1
- package/lib/typescript/src/components/time-picker/index.d.ts +19 -1
- package/lib/typescript/src/components/time-picker/index.d.ts.map +1 -1
- package/lib/typescript/src/components/tree/index.d.ts +125 -0
- package/lib/typescript/src/components/tree/index.d.ts.map +1 -0
- package/lib/typescript/src/icons/index.d.ts +22 -0
- package/lib/typescript/src/icons/index.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +14 -5
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/utils/chart.d.ts +34 -0
- package/lib/typescript/src/utils/chart.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/accordion/index.tsx +48 -6
- package/src/components/button/index.tsx +97 -15
- package/src/components/button-group/index.tsx +199 -0
- package/src/components/color-picker/index.tsx +140 -3
- package/src/components/combobox/index.tsx +7 -1
- package/src/components/date-time-picker/index.tsx +411 -0
- package/src/components/fab/index.tsx +583 -0
- package/src/components/grid-item/index.tsx +515 -0
- package/src/components/{kpi-chart → kpi}/index.tsx +85 -85
- package/src/components/line-chart/index.tsx +98 -8
- package/src/components/markdown-editor/index.tsx +526 -0
- package/src/components/markdown-editor/markdown-transforms.ts +228 -0
- package/src/components/pie-chart/index.tsx +863 -0
- package/src/components/questionnaire/index.tsx +1615 -0
- package/src/components/scatter-chart/index.tsx +1401 -0
- package/src/components/tabs/index.tsx +392 -50
- package/src/components/time-picker/index.tsx +42 -6
- package/src/components/tree/index.tsx +564 -0
- package/src/icons/index.tsx +154 -0
- package/src/index.ts +142 -18
- package/src/utils/chart.ts +110 -0
- package/lib/module/components/kpi-chart/index.js.map +0 -1
- 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
|
-
|
|
348
|
+
const stayMounted = keepMounted ?? keepMountedDefault;
|
|
349
|
+
if (!isExpanded && !stayMounted) return null;
|
|
350
|
+
|
|
351
|
+
const isHidden = !isExpanded;
|
|
317
352
|
|
|
318
353
|
return (
|
|
319
|
-
<View
|
|
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
|
-
|
|
333
|
+
resolvedVariant === 'destructive' ? 'primary' : (resolvedVariant ?? 'primary')
|
|
256
334
|
]
|
|
257
335
|
);
|
|
258
336
|
const contentColor =
|
|
259
|
-
|
|
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 =
|
|
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
|
-
|
|
289
|
-
isStringLabel
|
|
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[
|
|
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
|
-
|
|
399
|
+
resolvedSize === 'icon'
|
|
320
400
|
? { width: NATIVE_ICON_FRAME, height: NATIVE_ICON_FRAME }
|
|
321
|
-
: { height: NATIVE_HEIGHT[
|
|
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
|
-
|
|
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
|
-
|
|
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[
|
|
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;
|