expo-interface 0.1.1 → 0.3.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 (78) hide show
  1. package/README.md +119 -20
  2. package/package.json +9 -9
  3. package/src/accent.tsx +21 -8
  4. package/src/alert/alert.css +3 -3
  5. package/src/alert/index.tsx +1 -2
  6. package/src/button/button.css +12 -0
  7. package/src/button/index.android.tsx +14 -3
  8. package/src/button/index.ios.tsx +13 -4
  9. package/src/button/index.tsx +8 -1
  10. package/src/button/types.ts +20 -0
  11. package/src/checkbox/index.tsx +1 -1
  12. package/src/color-picker/color-picker.css +136 -0
  13. package/src/color-picker/index.android.tsx +145 -0
  14. package/src/color-picker/index.ios.tsx +80 -0
  15. package/src/color-picker/index.tsx +112 -0
  16. package/src/color-picker/shared.ts +204 -0
  17. package/src/color-picker/sheet.tsx +432 -0
  18. package/src/color-picker/types.ts +40 -0
  19. package/src/context-menu/index.android.tsx +39 -11
  20. package/src/context-menu/index.ios.tsx +3 -1
  21. package/src/context-menu/index.tsx +24 -7
  22. package/src/fab/fab.css +72 -0
  23. package/src/fab/index.android.tsx +72 -0
  24. package/src/fab/index.ios.tsx +68 -0
  25. package/src/fab/index.tsx +37 -0
  26. package/src/fab/shared.ts +23 -0
  27. package/src/fab/types.ts +39 -0
  28. package/src/field-group/field-group.css +23 -7
  29. package/src/field-group/index.android.tsx +44 -38
  30. package/src/field-group/index.tsx +36 -5
  31. package/src/field-group/index.web.tsx +27 -11
  32. package/src/field-group/shared.tsx +49 -0
  33. package/src/field-group/types.ts +25 -0
  34. package/src/gauge/gauge.css +200 -0
  35. package/src/gauge/index.android.tsx +212 -0
  36. package/src/gauge/index.ios.tsx +40 -0
  37. package/src/gauge/index.tsx +165 -0
  38. package/src/gauge/shared.ts +79 -0
  39. package/src/gauge/types.ts +64 -0
  40. package/src/header-menu/index.tsx +76 -0
  41. package/src/host/index.tsx +32 -0
  42. package/src/index.ts +35 -6
  43. package/src/keyboard/index.tsx +81 -0
  44. package/src/keyboard/library.native.ts +17 -0
  45. package/src/keyboard/library.ts +12 -0
  46. package/src/keyboard/types.ts +20 -0
  47. package/src/list-item/index.android.tsx +23 -6
  48. package/src/list-item/index.tsx +20 -4
  49. package/src/list-item/index.web.tsx +60 -0
  50. package/src/list-item/list-item.css +65 -0
  51. package/src/list-item/types.ts +21 -0
  52. package/src/menu/index.android.tsx +22 -9
  53. package/src/menu/index.ios.tsx +26 -11
  54. package/src/menu/index.tsx +26 -10
  55. package/src/menu/list.tsx +25 -10
  56. package/src/menu/menu.css +52 -0
  57. package/src/menu/types.ts +41 -2
  58. package/src/progress/progress.css +9 -0
  59. package/src/scheme.ts +140 -0
  60. package/src/screen/header.tsx +1 -0
  61. package/src/screen/index.tsx +49 -5
  62. package/src/segmented/index.tsx +1 -1
  63. package/src/segmented/segmented.css +6 -7
  64. package/src/slider/index.tsx +1 -1
  65. package/src/stepper/index.tsx +1 -1
  66. package/src/switch/index.tsx +2 -0
  67. package/src/tab-stack/index.tsx +15 -2
  68. package/src/tabs/index.tsx +2 -1
  69. package/src/tabs/index.web.tsx +29 -4
  70. package/src/tabs/types.ts +20 -2
  71. package/src/text-field/index.android.tsx +30 -6
  72. package/src/text-field/index.ios.tsx +16 -3
  73. package/src/text-field/index.tsx +24 -4
  74. package/src/text-field/inline.tsx +87 -0
  75. package/src/text-field/shared.ts +40 -1
  76. package/src/text-field/types.ts +47 -2
  77. package/src/theme.ts +44 -5
  78. package/src/qr/index.tsx +0 -26
@@ -0,0 +1,72 @@
1
+ import type {ModifierConfig} from '@expo/ui/jetpack-compose/modifiers';
2
+ import type {FabProps, FabSize} from './types';
3
+
4
+ import {useState} from 'react';
5
+ import {
6
+ DropdownMenu,
7
+ ExtendedFloatingActionButton,
8
+ FloatingActionButton,
9
+ Icon,
10
+ LargeFloatingActionButton,
11
+ SmallFloatingActionButton,
12
+ Text,
13
+ } from '@expo/ui/jetpack-compose';
14
+ import {alpha, testID as testIDModifier} from '@expo/ui/jetpack-compose/modifiers';
15
+ import {NativeHost} from '../host';
16
+ import {MenuItems} from '../menu/index.android';
17
+ import {useColor} from '../theme';
18
+ import {FAB_ICON} from './shared';
19
+
20
+ const VARIANT: Record<FabSize, typeof FloatingActionButton> = {
21
+ small: SmallFloatingActionButton,
22
+ regular: FloatingActionButton,
23
+ large: LargeFloatingActionButton,
24
+ extended: ExtendedFloatingActionButton,
25
+ };
26
+
27
+ /**
28
+ * Android renders the Material 3 `FloatingActionButton` family, filled with
29
+ * the accent (the container color) and the icon in `onTint`, inside its own
30
+ * accent-seeded host so it can float over a React Native screen. With
31
+ * `items` the button is the trigger of the same Compose `DropdownMenu` the
32
+ * kit's `Menu` anchors, so the menu is shared and only the trigger differs.
33
+ */
34
+ export function Fab({label, icon, onPress, items, size = 'regular', disabled, testID}: FabProps) {
35
+ const tint = useColor('tint');
36
+ const onTint = useColor('onTint');
37
+ const [expanded, setExpanded] = useState(false);
38
+ const Component = VARIANT[size];
39
+ const modifiers: ModifierConfig[] = [];
40
+ if (disabled) modifiers.push(alpha(0.4));
41
+ if (testID) modifiers.push(testIDModifier(testID));
42
+ const press = items ? () => setExpanded(true) : onPress;
43
+
44
+ const button = (
45
+ <Component containerColor={tint} onClick={disabled ? undefined : press} modifiers={modifiers}>
46
+ <FloatingActionButton.Icon>
47
+ {icon.drawable ? (
48
+ <Icon source={icon.drawable} size={FAB_ICON[size]} tint={onTint} contentDescription={label}/>
49
+ ) : (
50
+ // No drawable registered for this icon (see `icon()`): the label stands in.
51
+ <Text color={onTint}>{label.slice(0, 1)}</Text>
52
+ )}
53
+ </FloatingActionButton.Icon>
54
+ {size === 'extended' ? (
55
+ <ExtendedFloatingActionButton.Text>
56
+ <Text color={onTint} style={{fontWeight: '600'}}>{label}</Text>
57
+ </ExtendedFloatingActionButton.Text>
58
+ ) : null}
59
+ </Component>
60
+ );
61
+
62
+ return (
63
+ <NativeHost fit>
64
+ {items ? (
65
+ <DropdownMenu expanded={expanded} onDismissRequest={() => setExpanded(false)}>
66
+ <DropdownMenu.Trigger>{button}</DropdownMenu.Trigger>
67
+ <MenuItems items={items} onClose={() => setExpanded(false)}/>
68
+ </DropdownMenu>
69
+ ) : button}
70
+ </NativeHost>
71
+ );
72
+ }
@@ -0,0 +1,68 @@
1
+ import type {ViewModifier} from '@expo/ui/swift-ui/modifiers';
2
+ import type {FabProps} from './types';
3
+
4
+ import {Button, HStack, Image, Menu as SwiftUIMenu, Text} from '@expo/ui/swift-ui';
5
+ import {
6
+ accessibilityLabel,
7
+ background,
8
+ buttonStyle,
9
+ disabled as disabledMod,
10
+ font,
11
+ foregroundStyle,
12
+ frame,
13
+ opacity,
14
+ padding,
15
+ shadow,
16
+ shapes,
17
+ } from '@expo/ui/swift-ui/modifiers';
18
+ import {iosSymbol} from '../button/shared';
19
+ import {NativeHost} from '../host';
20
+ import {MenuItems} from '../menu/index.ios';
21
+ import {useColor} from '../theme';
22
+ import {FAB_EXTENDED_PADDING, FAB_GAP, FAB_ICON, FAB_SIZE} from './shared';
23
+
24
+ /**
25
+ * iOS has no floating action button, so it is drawn in SwiftUI: a circle
26
+ * (56 pt, 40 small, 96 large) or a capsule when extended, filled with the
27
+ * tint, the icon in `onTint`, a soft shadow, as the label of a plain
28
+ * `Button`; with `items`, as the label of a SwiftUI `Menu`, whose entries are
29
+ * the ones the kit's `Menu` renders.
30
+ */
31
+ export function Fab({label, icon, onPress, items, size = 'regular', disabled, testID}: FabProps) {
32
+ const tint = useColor('tint');
33
+ const onTint = useColor('onTint');
34
+ const circle = size !== 'extended';
35
+ const dimension = FAB_SIZE[size];
36
+ const faceModifiers: ViewModifier[] = [
37
+ circle ? frame({width: dimension, height: dimension}) : frame({height: dimension}),
38
+ ];
39
+ if (!circle) faceModifiers.push(padding({horizontal: FAB_EXTENDED_PADDING}));
40
+ faceModifiers.push(
41
+ background(tint, circle ? shapes.circle() : shapes.capsule()),
42
+ shadow({radius: 4, y: 2, color: 'rgba(0, 0, 0, 0.25)'}),
43
+ );
44
+ const face = (
45
+ <HStack spacing={FAB_GAP} modifiers={faceModifiers}>
46
+ <Image systemName={iosSymbol(icon)} color={onTint} size={FAB_ICON[size]}/>
47
+ {circle ? null : (
48
+ <Text modifiers={[foregroundStyle({type: 'color', color: onTint}), font({weight: 'semibold'})]}>{label}</Text>
49
+ )}
50
+ </HStack>
51
+ );
52
+ const modifiers: ViewModifier[] = [buttonStyle('plain'), accessibilityLabel(label)];
53
+ if (disabled) modifiers.push(disabledMod(true), opacity(0.4));
54
+
55
+ return (
56
+ <NativeHost fit>
57
+ {items ? (
58
+ <SwiftUIMenu label={face} modifiers={modifiers} testID={testID}>
59
+ <MenuItems items={items}/>
60
+ </SwiftUIMenu>
61
+ ) : (
62
+ <Button onPress={onPress} modifiers={modifiers} testID={testID}>
63
+ {face}
64
+ </Button>
65
+ )}
66
+ </NativeHost>
67
+ );
68
+ }
@@ -0,0 +1,37 @@
1
+ import './fab.css';
2
+ import type {CSSProperties} from 'react';
3
+ import type {FabProps} from './types';
4
+ import {useId, useRef} from 'react';
5
+ import {SymbolView} from 'expo-symbols';
6
+ import {MenuList, menuIdent} from '../menu/list';
7
+ import {FAB_ICON} from './shared';
8
+
9
+ /**
10
+ * On web the button is a `<button>` with the Material geometry (a circle or
11
+ * an extended capsule, filled with the tint) styled via `fab.css`. With
12
+ * `items` it carries a `popovertarget` to the kit's popup menu, the same
13
+ * `MenuList` the `Menu` opens, anchored above the button by CSS anchor
14
+ * positioning. Where it floats is the screen's job (`Screen`'s `fab` slot).
15
+ */
16
+ export function Fab({label, icon, onPress, items, size = 'regular', disabled, testID}: FabProps) {
17
+ const ident = menuIdent(useId());
18
+ const anchor = `--${ident}`;
19
+ const wrapper = useRef<HTMLSpanElement>(null);
20
+ const extended = size === 'extended';
21
+ return (
22
+ <span ref={wrapper} className="ui-fab__anchor" style={{anchorName: anchor} as CSSProperties}>
23
+ <button
24
+ type="button"
25
+ className={`ui-fab ui-fab--${size}`}
26
+ aria-label={extended ? undefined : label}
27
+ disabled={disabled}
28
+ onClick={items ? undefined : onPress}
29
+ popoverTarget={items ? ident : undefined}
30
+ data-testid={testID}>
31
+ <SymbolView name={icon.symbol} size={FAB_ICON[size]} tintColor="currentColor"/>
32
+ {extended ? <span className="ui-fab__label">{label}</span> : null}
33
+ </button>
34
+ {items ? <MenuList id={ident} items={items} anchor={anchor} anchorRef={wrapper}/> : null}
35
+ </span>
36
+ );
37
+ }
@@ -0,0 +1,23 @@
1
+ import type {FabSize} from './types';
2
+
3
+ /** Diameter of the circular sizes and height of the extended one, in points (Material's). */
4
+ export const FAB_SIZE: Record<FabSize, number> = {
5
+ small: 40,
6
+ regular: 56,
7
+ large: 96,
8
+ extended: 56,
9
+ };
10
+
11
+ /** Icon size per `FabSize`. */
12
+ export const FAB_ICON: Record<FabSize, number> = {
13
+ small: 24,
14
+ regular: 24,
15
+ large: 36,
16
+ extended: 24,
17
+ };
18
+
19
+ /** Horizontal padding of the extended capsule. */
20
+ export const FAB_EXTENDED_PADDING = 20;
21
+
22
+ /** Gap between the icon and the label of the extended one. */
23
+ export const FAB_GAP = 12;
@@ -0,0 +1,39 @@
1
+ import type {IconToken} from '../icons';
2
+ import type {MenuItem} from '../menu/types';
3
+
4
+ /**
5
+ * Size of a `Fab`. `regular`, `small` and `large` are circles (56, 40 and
6
+ * 96 points); `extended` is a capsule that shows the label beside the icon.
7
+ */
8
+ export type FabSize = 'small' | 'regular' | 'large' | 'extended';
9
+
10
+ /**
11
+ * Floating action button: the screen's primary action, floating over its
12
+ * content at the bottom trailing corner (`Screen`'s `fab` slot places it).
13
+ *
14
+ * Android renders the Material 3 `FloatingActionButton` family; iOS, which
15
+ * has no such control, draws the same geometry in SwiftUI (a circle or
16
+ * capsule filled with the tint, the icon in `onTint`, a soft shadow); web
17
+ * renders a DOM button with that geometry. With `items` the button opens a
18
+ * menu instead of pressing: the same menu `Menu` renders, only the trigger
19
+ * differs.
20
+ */
21
+ export interface FabProps {
22
+ /** Accessibility name of the button, and the text of an `extended` one. */
23
+ label: string;
24
+ /** The icon. */
25
+ icon: IconToken;
26
+ /** Called when the button is pressed (ignored when `items` are given). */
27
+ onPress?: () => void;
28
+ /** With items the button opens a menu instead of pressing. */
29
+ items?: MenuItem[];
30
+ /**
31
+ * The size.
32
+ * @default 'regular'
33
+ */
34
+ size?: FabSize;
35
+ /** Disables interaction and dims the button. */
36
+ disabled?: boolean;
37
+ /** Identifier used to locate the button in end-to-end tests. */
38
+ testID?: string;
39
+ }
@@ -7,12 +7,28 @@
7
7
  }
8
8
 
9
9
  /*
10
- * @expo/ui hardcodes inverted iOS grouped-list colors on web (#f2f2f7 page,
11
- * #ffffff cards). Light mode should match native: white page, grey cards.
12
- * Target only section cards not nested pills/controls inside rows.
10
+ * @expo/ui paints inverted iOS grouped-list colors on web (grey page, white
11
+ * cards in light mode). Match native instead: the page is the screen
12
+ * background and the cards the raised element color, in both schemes, so the
13
+ * cards follow the kit palette rather than @expo/ui's own (which switches on
14
+ * `prefers-color-scheme` and can disagree with a forced scheme).
15
+ *
16
+ * The section card reads `--expo-ui-background`, so remap that variable here
17
+ * rather than matching react-native-web class names: those carry the property
18
+ * name (`r-borderRadius-…`) only in development and are hashed to `r-…` in a
19
+ * production build. Nothing else the kit renders inside a group reads it.
13
20
  */
14
- @media (prefers-color-scheme: light) {
15
- .field-group [class*='r-borderRadius-']:not([class*='r-borderRadius-'] [class*='r-borderRadius-']) {
16
- background-color: var(--color-background-element) !important;
17
- }
21
+ .field-group {
22
+ --expo-ui-background: var(--color-background-element);
23
+ }
24
+
25
+ /*
26
+ * Row dividers: @expo/ui paints them in its gray-100, which is within a few
27
+ * shades of the card color above in both schemes. The selected-background
28
+ * token is one step away from the card in either scheme: visible, not heavy.
29
+ * `role="separator"` is the only stable hook; kit overlays inside a row (a
30
+ * Menu's own separator) carry `ui-*` classes and keep their color.
31
+ */
32
+ .field-group [role='separator']:not([class^='ui-']) {
33
+ background-color: var(--color-background-selected);
18
34
  }
@@ -1,12 +1,8 @@
1
1
  import type {ReactNode, ReactElement} from 'react';
2
- import type {
3
- FieldGroupProps,
4
- FieldSectionProps,
5
- FieldSectionHeaderProps,
6
- FieldSectionFooterProps,
7
- } from '@expo/ui';
2
+ import type {FieldSectionHeaderProps, FieldSectionFooterProps} from '@expo/ui';
3
+ import type {FieldGroupProps, FieldGroupSectionProps} from './types';
8
4
  import {Children, Fragment, isValidElement} from 'react';
9
- import {Box, Column, LazyColumn, Text} from '@expo/ui/jetpack-compose';
5
+ import {Box, Column, Text} from '@expo/ui/jetpack-compose';
10
6
  import {
11
7
  background,
12
8
  clip,
@@ -15,6 +11,7 @@ import {
15
11
  padding,
16
12
  Shapes,
17
13
  testID as testIDModifier,
14
+ verticalScroll,
18
15
  type ModifierConfig,
19
16
  } from '@expo/ui/jetpack-compose/modifiers';
20
17
  import {useColor} from '../theme';
@@ -22,6 +19,12 @@ import {useColor} from '../theme';
22
19
  /**
23
20
  * Android `FieldGroup`. Mirrors `@expo/ui`'s Material 3 connected-list
24
21
  * layout, with app deviations for web parity:
22
+ * - the group is a scrolling `Column`, not a `LazyColumn`: a lazy list
23
+ * disposes and recomposes its items as it scrolls and on any state change,
24
+ * and every React Native view hosted inside an item is then added to the
25
+ * view tree a second time while still attached ("The specified child
26
+ * already has a parent"). A settings form has a dozen rows and does not
27
+ * need laziness; composed once, its rows can hold anything the kit offers;
25
28
  * - the group has no background (the universal one paints the Host palette's
26
29
  * `surface`, a grey panel over the app's screen background);
27
30
  * - rows use the `backgroundElement` token instead of the seeded
@@ -33,16 +36,13 @@ import {useColor} from '../theme';
33
36
  */
34
37
  function FieldGroupBase({children, style, hidden, testID}: FieldGroupProps) {
35
38
  if (hidden) return null;
36
- const modifiers: ModifierConfig[] = [];
39
+ const modifiers: ModifierConfig[] = [fillMaxWidth(), verticalScroll(), padding(16, 16, 16, 16)];
37
40
  if (style?.backgroundColor) modifiers.push(background(String(style.backgroundColor)));
38
41
  if (testID) modifiers.push(testIDModifier(testID));
39
42
  return (
40
- <LazyColumn
41
- verticalArrangement={{spacedBy: 24}}
42
- contentPadding={{start: 16, end: 16, top: 16, bottom: 16}}
43
- modifiers={modifiers}>
43
+ <Column verticalArrangement={{spacedBy: 24}} modifiers={modifiers}>
44
44
  {groupChildren(children)}
45
- </LazyColumn>
45
+ </Column>
46
46
  );
47
47
  }
48
48
 
@@ -56,12 +56,14 @@ function SectionFooter(props: FieldSectionFooterProps) {
56
56
  return <>{props.children}</>;
57
57
  }
58
58
 
59
- function Section({children, title, titleUppercase = false, hidden}: FieldSectionProps) {
59
+ function Section({children, title, titleUppercase = false, hidden, footer: footerText, footerColor = 'secondaryLabel'}: FieldGroupSectionProps) {
60
60
  const card = useColor('backgroundElement');
61
61
  const subtle = useColor('secondaryLabel');
62
+ const danger = useColor('destructive');
63
+ const label = useColor('label');
62
64
  if (hidden) return null;
63
65
 
64
- const {header, footer, rows} = extractSlots(children);
66
+ const {header, footer, rows} = extractSlots(children, label);
65
67
  const headerNode = header ?? (title ? (
66
68
  <Text
67
69
  color={subtle}
@@ -69,6 +71,11 @@ function Section({children, title, titleUppercase = false, hidden}: FieldSection
69
71
  {titleUppercase ? title.toUpperCase() : title}
70
72
  </Text>
71
73
  ) : null);
74
+ const footerNode = footer ?? (footerText != null ? (
75
+ <Text color={footerColor === 'destructive' ? danger : subtle} style={{typography: 'bodySmall'}}>
76
+ {footerText}
77
+ </Text>
78
+ ) : null);
72
79
 
73
80
  return (
74
81
  <Column verticalArrangement={{spacedBy: 4}} modifiers={[fillMaxWidth()]}>
@@ -93,7 +100,7 @@ function Section({children, title, titleUppercase = false, hidden}: FieldSection
93
100
  ))}
94
101
  </Column>
95
102
  ) : null}
96
- {footer ? <Column modifiers={[padding(16, 4, 16, 0)]}>{footer}</Column> : null}
103
+ {footerNode ? <Column modifiers={[padding(16, 4, 16, 0)]}>{footerNode}</Column> : null}
97
104
  </Column>
98
105
  );
99
106
  }
@@ -104,7 +111,7 @@ export const FieldGroup = Object.assign(FieldGroupBase, {
104
111
  SectionFooter,
105
112
  });
106
113
 
107
- export type {FieldGroupProps};
114
+ export type {FieldGroupProps, FieldGroupSectionProps};
108
115
 
109
116
  /**
110
117
  * Per-position corner radii producing the Material 3 grouped-list look:
@@ -118,33 +125,33 @@ function cornerRadii(index: number, total: number) {
118
125
  return {topStart: top, topEnd: top, bottomStart: bottom, bottomEnd: bottom};
119
126
  }
120
127
 
121
- /** Pulls `SectionHeader`/`SectionFooter` slots out of a section's children. */
122
- function extractSlots(children: ReactNode) {
128
+ /**
129
+ * Pulls `SectionHeader`/`SectionFooter` slots out of a section's children.
130
+ * Empty children (`null`, booleans) are dropped, and raw strings or numbers
131
+ * become a `Text` row, since Compose slots cannot render them directly.
132
+ */
133
+ function extractSlots(children: ReactNode, textColor: string) {
123
134
  let header: ReactNode | undefined;
124
135
  let footer: ReactNode | undefined;
125
136
  const rows: ReactNode[] = [];
126
137
 
127
138
  const walk = (node: ReactNode) => {
128
- Children.forEach(node, child => {
139
+ for (const child of Children.toArray(node)) {
129
140
  if (!isValidElement(child)) {
130
- rows.push(child);
131
- return;
141
+ rows.push(<Text color={textColor}>{String(child)}</Text>);
142
+ continue;
132
143
  }
133
144
  const props = child.props as {children?: ReactNode};
134
145
  if (child.type === SectionHeader) {
135
146
  header = props.children;
136
- return;
137
- }
138
- if (child.type === SectionFooter) {
147
+ } else if (child.type === SectionFooter) {
139
148
  footer = props.children;
140
- return;
141
- }
142
- if (child.type === Fragment) {
149
+ } else if (child.type === Fragment) {
143
150
  walk(props.children);
144
- return;
151
+ } else {
152
+ rows.push(child);
145
153
  }
146
- rows.push(child);
147
- });
154
+ }
148
155
  };
149
156
 
150
157
  walk(children);
@@ -175,13 +182,12 @@ function groupChildren(children: ReactNode): ReactNode[] {
175
182
  return;
176
183
  }
177
184
  if (isValidElement(child) && child.type === Fragment) {
178
- for (const nested of groupChildren((child.props as {children?: ReactNode}).children)) {
179
- if (isSection(nested)) {
180
- flush();
181
- result.push(nested);
182
- } else {
183
- buffered.push(nested);
184
- }
185
+ // The recursion yields sections only (loose rows come back wrapped in
186
+ // their own implicit section), so they close any open implicit section.
187
+ const nested = groupChildren((child.props as {children?: ReactNode}).children);
188
+ if (nested.length > 0) {
189
+ flush();
190
+ result.push(...nested);
185
191
  }
186
192
  return;
187
193
  }
@@ -1,8 +1,39 @@
1
+ import type {FieldGroupProps, FieldGroupSectionProps, FieldSectionFooterColor} from './types';
2
+ import {FieldGroup as Base} from '@expo/ui';
3
+ import {Text} from '@expo/ui/swift-ui';
4
+ import {font, foregroundStyle} from '@expo/ui/swift-ui/modifiers';
5
+ import {useColor} from '../theme';
6
+ import {baseSection, mapSections} from './shared';
7
+
1
8
  /**
2
9
  * App `FieldGroup`: a scrollable container of grouped settings-style rows.
3
- * iOS uses `@expo/ui`'s SwiftUI `Form`. Android (`index.android.tsx`) keeps
4
- * the Material 3 connected-list look with app palette colors. Web
5
- * (`index.web.tsx` + `field-group.css`) re-themes the universal component via
6
- * CSS instead of forking its layout.
10
+ * iOS (this file) uses `@expo/ui`'s SwiftUI `Form`, whose `Section` gains
11
+ * the `footer` prop as the native section footer slot. Android
12
+ * (`index.android.tsx`) keeps the Material 3 connected-list look with app
13
+ * palette colors. Web (`index.web.tsx` + `field-group.css`) re-themes the
14
+ * universal component via CSS instead of forking its layout.
7
15
  */
8
- export {FieldGroup, type FieldGroupProps} from '@expo/ui';
16
+
17
+ /** The section `footer` as SwiftUI text, in the footnote size and the token's color. */
18
+ function FooterText({color, children}: {color: FieldSectionFooterColor; children: string}) {
19
+ const resolved = useColor(color);
20
+ return <Text modifiers={[foregroundStyle({type: 'color', color: resolved}), font({size: 13})]}>{children}</Text>;
21
+ }
22
+
23
+ const renderFooter = (footer: string, color: FieldSectionFooterColor) => <FooterText color={color}>{footer}</FooterText>;
24
+
25
+ function Section(props: FieldGroupSectionProps) {
26
+ return baseSection(props, renderFooter);
27
+ }
28
+
29
+ function FieldGroupBase({children, ...props}: FieldGroupProps) {
30
+ return <Base {...props}>{mapSections(children, Section, renderFooter)}</Base>;
31
+ }
32
+
33
+ export const FieldGroup = Object.assign(FieldGroupBase, {
34
+ Section,
35
+ SectionHeader: Base.SectionHeader,
36
+ SectionFooter: Base.SectionFooter,
37
+ });
38
+
39
+ export type {FieldGroupProps, FieldGroupSectionProps};
@@ -1,24 +1,40 @@
1
1
  import './field-group.css';
2
- import {FieldGroup as BaseFieldGroup, type FieldGroupProps} from '@expo/ui';
2
+ import type {FieldGroupProps, FieldGroupSectionProps, FieldSectionFooterColor} from './types';
3
+ import {FieldGroup as Base} from '@expo/ui';
4
+ import {Footnote} from '../typography';
5
+ import {baseSection, mapSections} from './shared';
6
+
7
+ const renderFooter = (footer: string, color: FieldSectionFooterColor) => <Footnote color={color}>{footer}</Footnote>;
8
+
9
+ /**
10
+ * Web: the universal `FieldGroup.Section` with the `footer` prop as its
11
+ * footer slot, drawn as the kit's `Footnote`.
12
+ */
13
+ function Section(props: FieldGroupSectionProps) {
14
+ return baseSection(props, renderFooter);
15
+ }
3
16
 
4
17
  /**
5
18
  * Web hook for `field-group.css`. Clears the universal component's hardcoded
6
- * scroll background so the wrapper supplies the app palette; section cards are
7
- * recolored in CSS (light mode only dark matches @expo/ui).
19
+ * scroll background so the wrapper supplies the app palette; section cards
20
+ * and row dividers are recolored to the kit tokens in the CSS.
8
21
  */
9
- function FieldGroup({style, ...props}: FieldGroupProps) {
22
+ function FieldGroupBase({style, children, ...props}: FieldGroupProps) {
10
23
  return (
11
24
  <div className="field-group">
12
- <BaseFieldGroup
25
+ <Base
13
26
  {...props}
14
- style={{...style, backgroundColor: 'transparent'}}
15
- />
27
+ style={{...style, backgroundColor: 'transparent'}}>
28
+ {mapSections(children, Section, renderFooter)}
29
+ </Base>
16
30
  </div>
17
31
  );
18
32
  }
19
33
 
20
- FieldGroup.Section = BaseFieldGroup.Section;
21
- FieldGroup.SectionHeader = BaseFieldGroup.SectionHeader;
22
- FieldGroup.SectionFooter = BaseFieldGroup.SectionFooter;
34
+ export const FieldGroup = Object.assign(FieldGroupBase, {
35
+ Section,
36
+ SectionHeader: Base.SectionHeader,
37
+ SectionFooter: Base.SectionFooter,
38
+ });
23
39
 
24
- export {FieldGroup, type FieldGroupProps};
40
+ export type {FieldGroupProps, FieldGroupSectionProps};
@@ -0,0 +1,49 @@
1
+ import type {ComponentType, ReactElement, ReactNode} from 'react';
2
+ import type {FieldGroupSectionProps, FieldSectionFooterColor} from './types';
3
+ import {Children, Fragment, isValidElement} from 'react';
4
+ import {FieldGroup as Base} from '@expo/ui';
5
+
6
+ /** Renders the `footer` prop's text in the platform's own text control. */
7
+ export type FooterRenderer = (footer: string, color: FieldSectionFooterColor) => ReactNode;
8
+
9
+ /** Whether the section's children already carry a `SectionFooter` slot. */
10
+ export function hasFooterSlot(children: ReactNode): boolean {
11
+ return Children.toArray(children).some(child => isValidElement(child) && child.type === Base.SectionFooter);
12
+ }
13
+
14
+ /**
15
+ * The universal `FieldGroup.Section` with the `footer` prop turned into a
16
+ * `SectionFooter` slot (unless the children carry one already).
17
+ */
18
+ export function baseSection(
19
+ {footer, footerColor = 'secondaryLabel', children, ...props}: FieldGroupSectionProps,
20
+ renderFooter: FooterRenderer,
21
+ ): ReactElement {
22
+ const note = footer != null && !hasFooterSlot(children) ? (
23
+ <Base.SectionFooter>{renderFooter(footer, footerColor)}</Base.SectionFooter>
24
+ ) : null;
25
+ return (
26
+ <Base.Section {...props}>
27
+ {children}
28
+ {note}
29
+ </Base.Section>
30
+ );
31
+ }
32
+
33
+ /**
34
+ * The universal `FieldGroup` groups loose rows into implicit sections by
35
+ * checking each child's type against its own `Section`, so a kit `Section`
36
+ * wrapping it would be grouped as a row. The kit sections are therefore
37
+ * replaced by the universal ones (with the footer slot) before the children
38
+ * reach the group; fragments are walked like the group walks them.
39
+ */
40
+ export function mapSections(children: ReactNode, Section: ComponentType<FieldGroupSectionProps>, renderFooter: FooterRenderer): ReactNode {
41
+ return Children.map(children, child => {
42
+ if (!isValidElement(child)) return child;
43
+ if (child.type === Section) return baseSection(child.props as FieldGroupSectionProps, renderFooter);
44
+ if (child.type === Fragment) {
45
+ return <Fragment>{mapSections((child.props as {children?: ReactNode}).children, Section, renderFooter)}</Fragment>;
46
+ }
47
+ return child;
48
+ });
49
+ }
@@ -0,0 +1,25 @@
1
+ import type {FieldGroupProps, FieldSectionProps} from '@expo/ui';
2
+
3
+ /** Color of a section's `footer` text. */
4
+ export type FieldSectionFooterColor = 'secondaryLabel' | 'destructive';
5
+
6
+ /**
7
+ * Props of `FieldGroup.Section`: `@expo/ui`'s, plus a plain-text footer.
8
+ * SwiftUI's `Section` has a footer, the web draws a caption under the group
9
+ * and Android under the rows, so a note under a section (what a setting
10
+ * does, why it failed) is one prop on every platform.
11
+ */
12
+ export interface FieldGroupSectionProps extends FieldSectionProps {
13
+ /**
14
+ * A note under the rows, in the secondary color. Ignored when a
15
+ * `<FieldGroup.SectionFooter>` child is given.
16
+ */
17
+ footer?: string;
18
+ /**
19
+ * Color of the footer text: `destructive` for an error.
20
+ * @default 'secondaryLabel'
21
+ */
22
+ footerColor?: FieldSectionFooterColor;
23
+ }
24
+
25
+ export type {FieldGroupProps};