@hzblj/kara-components 0.18.0 → 0.19.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/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # @hzblj/kara-components
2
2
 
3
+ ## 0.19.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#58](https://github.com/hzblj/kara-ui/pull/58) [`024f36b`](https://github.com/hzblj/kara-ui/commit/024f36b58b3ceca20f04889b876eb7905ed7b540) Thanks [@hzblj](https://github.com/hzblj)! - `SegmentedControl` segments can show more than a label: `{label, icon}` leads with one of the app's icons in the label's colour, and `{label, image}` shows an image in its own colours instead, such as a carrier's logo from a URL, raster or SVG. `iconSize` sets their height (`s20` by default) and `spacing` the space above and below the content, for a taller control. The images load natively, as Footshop's platform-ui loads them: kara-native now depends on SDWebImage and SDWebImageSVGCoder on iOS and on androidsvg on Android, and its new `NativeImage` draws such an image in a Compose host. The label stays what screen readers say, and is shown if the image cannot be loaded.
8
+
9
+ ### Patch Changes
10
+
11
+ - Updated dependencies [[`024f36b`](https://github.com/hzblj/kara-ui/commit/024f36b58b3ceca20f04889b876eb7905ed7b540)]:
12
+ - @hzblj/kara-native@0.19.0
13
+ - @hzblj/kara-sx@0.19.0
14
+ - @hzblj/kara-theme@0.19.0
15
+
3
16
  ## 0.18.0
4
17
 
5
18
  ### Minor Changes
@@ -1,19 +1,59 @@
1
1
  "use strict";
2
2
 
3
3
  import { Host, SegmentedButton, SingleChoiceSegmentedButtonRow, Text } from '@expo/ui/jetpack-compose';
4
- import { fillMaxWidth, testID as testIdModifier, weight } from '@expo/ui/jetpack-compose/modifiers';
4
+ import { fillMaxWidth, height, testID as testIdModifier, weight } from '@expo/ui/jetpack-compose/modifiers';
5
+ import { iconResourceName } from '@hzblj/expo-native-icons';
5
6
  import { haptics } from '@hzblj/expo-rich-haptics';
6
- import { useAppTheme } from '@hzblj/kara-theme';
7
+ import { DrawableIcon, NativeImage } from '@hzblj/kara-native/jetpack-compose';
8
+ import { spacing as spacingTokens, useAppTheme } from '@hzblj/kara-theme';
7
9
  import { useCallback, useMemo } from 'react';
8
10
  import { useInsideHost } from "../../host-context.js";
9
- import { jsx as _jsx } from "react/jsx-runtime";
11
+ import { HStack } from "../h-stack/index.js";
12
+ import { imageUri, segmentOf } from "./segments.js";
13
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
10
14
  const MATCH_HEIGHT_ONLY = {
11
15
  horizontal: false,
12
16
  vertical: true
13
17
  };
14
18
 
15
- /** Every segment the same width, like UIKit's: Material's would size each to its label. */
16
- const SEGMENT = [weight(1)];
19
+ /** Material's own segment height, which `spacing` only ever adds to. */
20
+ const MATERIAL_HEIGHT = 40;
21
+ /**
22
+ * What a segment shows: its label, one of the app's icons before it, or an image loaded by kara-native. The icon and a
23
+ * label the image falls back to take the segment's content colour, so they follow the selection and `disabled`.
24
+ */
25
+ const SegmentLabel = ({
26
+ iconSize,
27
+ segment
28
+ }) => {
29
+ const theme = useAppTheme();
30
+ const face = theme.typography.subheadlineMedium;
31
+ if (segment.image !== undefined) {
32
+ return /*#__PURE__*/_jsx(NativeImage, {
33
+ fontFamily: face.fontFamily,
34
+ fontSize: face.fontSize,
35
+ fontWeight: String(face.fontWeight),
36
+ imageHeight: iconSize,
37
+ label: segment.label,
38
+ source: imageUri(segment.image)
39
+ });
40
+ }
41
+ const label = /*#__PURE__*/_jsx(Text, {
42
+ style: face,
43
+ children: segment.label
44
+ });
45
+ if (segment.icon === undefined) {
46
+ return label;
47
+ }
48
+ return /*#__PURE__*/_jsxs(HStack, {
49
+ align: "center",
50
+ spacing: "s6",
51
+ children: [/*#__PURE__*/_jsx(DrawableIcon, {
52
+ iconSize: iconSize,
53
+ resourceName: iconResourceName(segment.icon)
54
+ }), label]
55
+ });
56
+ };
17
57
 
18
58
  /**
19
59
  * Material's single-choice segmented buttons, the selected one filled with the accent like a checked `Switch`. Its own
@@ -23,13 +63,27 @@ const SEGMENT = [weight(1)];
23
63
  */
24
64
  export const SegmentedControl = ({
25
65
  disabled = false,
66
+ iconSize = 's20',
26
67
  onChange,
27
68
  selectedIndex,
69
+ spacing,
28
70
  testID,
29
71
  values
30
72
  }) => {
31
73
  const theme = useAppTheme();
32
74
  const insideHost = useInsideHost();
75
+ const segments = useMemo(() => values.map(segmentOf), [values]);
76
+ const iconPoints = spacingTokens[iconSize];
77
+
78
+ // Every segment the same width, like UIKit's: Material's would size each to its label. With `spacing`, as tall as
79
+ // the content and that space, and never shorter than Material's own segment.
80
+ const segmentModifiers = useMemo(() => {
81
+ if (spacing === undefined) {
82
+ return [weight(1)];
83
+ }
84
+ const content = Math.max(theme.typography.subheadlineMedium.lineHeight, iconPoints);
85
+ return [weight(1), height(Math.max(MATERIAL_HEIGHT, content + 2 * spacingTokens[spacing]))];
86
+ }, [iconPoints, spacing, theme]);
33
87
 
34
88
  // Every segment keeps the same outline, the selected one too: an accent border would run its fill out over the line
35
89
  // the others draw inside, and make it look taller than its neighbours.
@@ -53,20 +107,20 @@ export const SegmentedControl = ({
53
107
  return;
54
108
  }
55
109
  haptics.selection();
56
- onChange?.(index, values[index] ?? '');
57
- }, [onChange, selectedIndex, values]);
110
+ onChange?.(index, segments[index]?.label ?? '');
111
+ }, [onChange, segments, selectedIndex]);
58
112
  const row = /*#__PURE__*/_jsx(SingleChoiceSegmentedButtonRow, {
59
113
  modifiers: rowModifiers,
60
- children: values.map((label, index) => /*#__PURE__*/_jsx(SegmentedButton, {
114
+ children: segments.map((segment, index) => /*#__PURE__*/_jsx(SegmentedButton, {
61
115
  colors: colors,
62
116
  enabled: !disabled,
63
- modifiers: SEGMENT,
117
+ modifiers: segmentModifiers,
64
118
  onClick: () => select(index),
65
119
  selected: index === selectedIndex,
66
120
  children: /*#__PURE__*/_jsx(SegmentedButton.Label, {
67
- children: /*#__PURE__*/_jsx(Text, {
68
- style: theme.typography.subheadlineMedium,
69
- children: label
121
+ children: /*#__PURE__*/_jsx(SegmentLabel, {
122
+ iconSize: iconPoints,
123
+ segment: segment
70
124
  })
71
125
  })
72
126
  }, index))
@@ -1 +1 @@
1
- {"version":3,"names":["Host","SegmentedButton","SingleChoiceSegmentedButtonRow","Text","fillMaxWidth","testID","testIdModifier","weight","haptics","useAppTheme","useCallback","useMemo","useInsideHost","jsx","_jsx","MATCH_HEIGHT_ONLY","horizontal","vertical","SEGMENT","SegmentedControl","disabled","onChange","selectedIndex","values","theme","insideHost","colors","activeBorderColor","color","activeContainerColor","activeContentColor","disabledActiveBorderColor","disabledActiveContainerColor","disabledActiveContentColor","disabledInactiveBorderColor","disabledInactiveContentColor","inactiveBorderColor","inactiveContentColor","rowModifiers","select","index","selection","row","modifiers","children","map","label","enabled","onClick","selected","Label","style","typography","subheadlineMedium","colorScheme","dark","matchContents","seedColor","materialSeed"],"sourceRoot":"../../../../src","sources":["atoms/segmented-control/segmented-control.android.tsx"],"mappings":"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","ignoreList":[]}
1
+ {"version":3,"names":["Host","SegmentedButton","SingleChoiceSegmentedButtonRow","Text","fillMaxWidth","height","testID","testIdModifier","weight","iconResourceName","haptics","DrawableIcon","NativeImage","spacing","spacingTokens","useAppTheme","useCallback","useMemo","useInsideHost","HStack","imageUri","segmentOf","jsx","_jsx","jsxs","_jsxs","MATCH_HEIGHT_ONLY","horizontal","vertical","MATERIAL_HEIGHT","SegmentLabel","iconSize","segment","theme","face","typography","subheadlineMedium","image","undefined","fontFamily","fontSize","fontWeight","String","imageHeight","label","source","style","children","icon","align","resourceName","SegmentedControl","disabled","onChange","selectedIndex","values","insideHost","segments","map","iconPoints","segmentModifiers","content","Math","max","lineHeight","colors","activeBorderColor","color","activeContainerColor","activeContentColor","disabledActiveBorderColor","disabledActiveContainerColor","disabledActiveContentColor","disabledInactiveBorderColor","disabledInactiveContentColor","inactiveBorderColor","inactiveContentColor","rowModifiers","select","index","selection","row","modifiers","enabled","onClick","selected","Label","colorScheme","dark","matchContents","seedColor","materialSeed"],"sourceRoot":"../../../../src","sources":["atoms/segmented-control/segmented-control.android.tsx"],"mappings":"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","ignoreList":[]}
@@ -1,10 +1,12 @@
1
1
  "use strict";
2
2
 
3
3
  import { RNHostView } from '@expo/ui/swift-ui';
4
+ import { iconResourceName } from '@hzblj/expo-native-icons';
4
5
  import { SegmentedControl as NativeSegmentedControl } from '@hzblj/kara-native/swift-ui';
5
- import { useAppTheme } from '@hzblj/kara-theme';
6
+ import { spacing as spacingTokens, useAppTheme } from '@hzblj/kara-theme';
6
7
  import { useCallback, useMemo } from 'react';
7
8
  import { useInsideHost } from "../../host-context.js";
9
+ import { imageUri, segmentOf } from "./segments.js";
8
10
  import { jsx as _jsx } from "react/jsx-runtime";
9
11
  const MATCH_HEIGHT_ONLY = {
10
12
  horizontal: false,
@@ -15,39 +17,48 @@ const MATCH_HEIGHT_ONLY = {
15
17
  * kara's own `UISegmentedControl`, with the pill in the accent and white text on it — the system control tints only the
16
18
  * pill, and SwiftUI's segmented picker not even that. A plain `UIView` in React Native layout: it needs no host, takes
17
19
  * the width it is given and UIKit's height. Inside a `Screen` or `Island` it is carried into that host by `RNHostView`
18
- * (see `useInsideHost`), the host's width and its own height. UIKit plays the control's own feedback.
20
+ * (see `useInsideHost`), the host's width and its own height. UIKit plays the control's own feedback. A segment shows a
21
+ * title or an image, never both, so an icon segment is drawn natively as one image of the icon and the label, in the
22
+ * label's colour; an image segment is the image alone, loaded and drawn by kara-native at `iconSize`.
19
23
  */
20
24
  export const SegmentedControl = ({
21
25
  disabled = false,
26
+ iconSize = 's20',
22
27
  onChange,
23
28
  selectedIndex,
29
+ spacing,
24
30
  testID,
25
31
  values
26
32
  }) => {
27
33
  const theme = useAppTheme();
28
34
  const insideHost = useInsideHost();
29
35
  const face = theme.typography.footnoteMedium;
36
+ const segments = useMemo(() => values.map(segmentOf), [values]);
30
37
 
31
38
  // The native control speaks in values; a segment's is its index, so two labels may read the same.
32
- const options = useMemo(() => values.map((label, index) => ({
33
- label,
39
+ const options = useMemo(() => segments.map((segment, index) => ({
40
+ icon: segment.icon ? iconResourceName(segment.icon) : undefined,
41
+ image: imageUri(segment.image),
42
+ label: segment.label,
34
43
  value: String(index)
35
- })), [values]);
44
+ })), [segments]);
36
45
  const onValueChange = useCallback(value => {
37
46
  const index = Number(value);
38
- onChange?.(index, values[index] ?? '');
39
- }, [onChange, values]);
47
+ onChange?.(index, segments[index]?.label ?? '');
48
+ }, [onChange, segments]);
40
49
  const control = /*#__PURE__*/_jsx(NativeSegmentedControl, {
41
50
  activePillColor: theme.color('fill.accent.primary'),
42
51
  activeTextColor: theme.color('content.on.accent'),
43
52
  disabled: disabled,
44
53
  fontFamily: face.fontFamily,
45
54
  fontSize: face.fontSize,
55
+ iconSize: spacingTokens[iconSize],
46
56
  inactiveTextColor: theme.color('content.primary'),
47
57
  onValueChange: onValueChange,
48
58
  options: options,
49
59
  selectedValue: String(selectedIndex),
50
- testID: testID
60
+ testID: testID,
61
+ verticalPadding: spacing === undefined ? undefined : spacingTokens[spacing]
51
62
  });
52
63
  return insideHost ? /*#__PURE__*/_jsx(RNHostView, {
53
64
  matchContents: MATCH_HEIGHT_ONLY,
@@ -1 +1 @@
1
- {"version":3,"names":["RNHostView","SegmentedControl","NativeSegmentedControl","useAppTheme","useCallback","useMemo","useInsideHost","jsx","_jsx","MATCH_HEIGHT_ONLY","horizontal","vertical","disabled","onChange","selectedIndex","testID","values","theme","insideHost","face","typography","footnoteMedium","options","map","label","index","value","String","onValueChange","Number","control","activePillColor","color","activeTextColor","fontFamily","fontSize","inactiveTextColor","selectedValue","matchContents","children"],"sourceRoot":"../../../../src","sources":["atoms/segmented-control/segmented-control.ios.tsx"],"mappings":";;AAAA,SAAQA,UAAU,QAAO,mBAAmB;AAC5C,SAAQC,gBAAgB,IAAIC,sBAAsB,QAAO,6BAA6B;AACtF,SAAQC,WAAW,QAAO,mBAAmB;AAC7C,SAAQC,WAAW,EAAEC,OAAO,QAAO,OAAO;AAE1C,SAAQC,aAAa,QAAO,uBAAoB;AAAA,SAAAC,GAAA,IAAAC,IAAA;AAGhD,MAAMC,iBAAiB,GAAG;EAACC,UAAU,EAAE,KAAK;EAAEC,QAAQ,EAAE;AAAI,CAAC;;AAE7D;AACA;AACA;AACA;AACA;AACA;AACA,OAAO,MAAMV,gBAA2C,GAAGA,CAAC;EAC1DW,QAAQ,GAAG,KAAK;EAChBC,QAAQ;EACRC,aAAa;EACbC,MAAM;EACNC;AACqB,CAAC,KAAK;EAC3B,MAAMC,KAAK,GAAGd,WAAW,CAAC,CAAC;EAC3B,MAAMe,UAAU,GAAGZ,aAAa,CAAC,CAAC;EAClC,MAAMa,IAAI,GAAGF,KAAK,CAACG,UAAU,CAACC,cAAc;;EAE5C;EACA,MAAMC,OAAO,GAAGjB,OAAO,CAAC,MAAMW,MAAM,CAACO,GAAG,CAAC,CAACC,KAAK,EAAEC,KAAK,MAAM;IAACD,KAAK;IAAEE,KAAK,EAAEC,MAAM,CAACF,KAAK;EAAC,CAAC,CAAC,CAAC,EAAE,CAACT,MAAM,CAAC,CAAC;EAEtG,MAAMY,aAAa,GAAGxB,WAAW,CAC9BsB,KAAa,IAAK;IACjB,MAAMD,KAAK,GAAGI,MAAM,CAACH,KAAK,CAAC;IAC3Bb,QAAQ,GAAGY,KAAK,EAAET,MAAM,CAACS,KAAK,CAAC,IAAI,EAAE,CAAC;EACxC,CAAC,EACD,CAACZ,QAAQ,EAAEG,MAAM,CACnB,CAAC;EAED,MAAMc,OAAO,gBACXtB,IAAA,CAACN,sBAAsB;IACrB6B,eAAe,EAAEd,KAAK,CAACe,KAAK,CAAC,qBAAqB,CAAE;IACpDC,eAAe,EAAEhB,KAAK,CAACe,KAAK,CAAC,mBAAmB,CAAE;IAClDpB,QAAQ,EAAEA,QAAS;IACnBsB,UAAU,EAAEf,IAAI,CAACe,UAAW;IAC5BC,QAAQ,EAAEhB,IAAI,CAACgB,QAAS;IACxBC,iBAAiB,EAAEnB,KAAK,CAACe,KAAK,CAAC,iBAAiB,CAAE;IAClDJ,aAAa,EAAEA,aAAc;IAC7BN,OAAO,EAAEA,OAAQ;IACjBe,aAAa,EAAEV,MAAM,CAACb,aAAa,CAAE;IACrCC,MAAM,EAAEA;EAAO,CAChB,CACF;EAED,OAAOG,UAAU,gBAAGV,IAAA,CAACR,UAAU;IAACsC,aAAa,EAAE7B,iBAAkB;IAAA8B,QAAA,EAAET;EAAO,CAAa,CAAC,GAAGA,OAAO;AACpG,CAAC","ignoreList":[]}
1
+ {"version":3,"names":["RNHostView","iconResourceName","SegmentedControl","NativeSegmentedControl","spacing","spacingTokens","useAppTheme","useCallback","useMemo","useInsideHost","imageUri","segmentOf","jsx","_jsx","MATCH_HEIGHT_ONLY","horizontal","vertical","disabled","iconSize","onChange","selectedIndex","testID","values","theme","insideHost","face","typography","footnoteMedium","segments","map","options","segment","index","icon","undefined","image","label","value","String","onValueChange","Number","control","activePillColor","color","activeTextColor","fontFamily","fontSize","inactiveTextColor","selectedValue","verticalPadding","matchContents","children"],"sourceRoot":"../../../../src","sources":["atoms/segmented-control/segmented-control.ios.tsx"],"mappings":";;AAAA,SAAQA,UAAU,QAAO,mBAAmB;AAC5C,SAAQC,gBAAgB,QAAO,0BAA0B;AACzD,SAAQC,gBAAgB,IAAIC,sBAAsB,QAAO,6BAA6B;AACtF,SAAQC,OAAO,IAAIC,aAAa,EAAEC,WAAW,QAAO,mBAAmB;AACvE,SAAQC,WAAW,EAAEC,OAAO,QAAO,OAAO;AAE1C,SAAQC,aAAa,QAAO,uBAAoB;AAEhD,SAAQC,QAAQ,EAAEC,SAAS,QAAO,eAAY;AAAA,SAAAC,GAAA,IAAAC,IAAA;AAE9C,MAAMC,iBAAiB,GAAG;EAACC,UAAU,EAAE,KAAK;EAAEC,QAAQ,EAAE;AAAI,CAAC;;AAE7D;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,OAAO,MAAMd,gBAA2C,GAAGA,CAAC;EAC1De,QAAQ,GAAG,KAAK;EAChBC,QAAQ,GAAG,KAAK;EAChBC,QAAQ;EACRC,aAAa;EACbhB,OAAO;EACPiB,MAAM;EACNC;AACqB,CAAC,KAAK;EAC3B,MAAMC,KAAK,GAAGjB,WAAW,CAAC,CAAC;EAC3B,MAAMkB,UAAU,GAAGf,aAAa,CAAC,CAAC;EAClC,MAAMgB,IAAI,GAAGF,KAAK,CAACG,UAAU,CAACC,cAAc;EAE5C,MAAMC,QAAQ,GAAGpB,OAAO,CAAC,MAAMc,MAAM,CAACO,GAAG,CAAClB,SAAS,CAAC,EAAE,CAACW,MAAM,CAAC,CAAC;;EAE/D;EACA,MAAMQ,OAAO,GAAGtB,OAAO,CACrB,MACEoB,QAAQ,CAACC,GAAG,CAAC,CAACE,OAAO,EAAEC,KAAK,MAAM;IAChCC,IAAI,EAAEF,OAAO,CAACE,IAAI,GAAGhC,gBAAgB,CAAC8B,OAAO,CAACE,IAAI,CAAC,GAAGC,SAAS;IAC/DC,KAAK,EAAEzB,QAAQ,CAACqB,OAAO,CAACI,KAAK,CAAC;IAC9BC,KAAK,EAAEL,OAAO,CAACK,KAAK;IACpBC,KAAK,EAAEC,MAAM,CAACN,KAAK;EACrB,CAAC,CAAC,CAAC,EACL,CAACJ,QAAQ,CACX,CAAC;EAED,MAAMW,aAAa,GAAGhC,WAAW,CAC9B8B,KAAa,IAAK;IACjB,MAAML,KAAK,GAAGQ,MAAM,CAACH,KAAK,CAAC;IAC3BlB,QAAQ,GAAGa,KAAK,EAAEJ,QAAQ,CAACI,KAAK,CAAC,EAAEI,KAAK,IAAI,EAAE,CAAC;EACjD,CAAC,EACD,CAACjB,QAAQ,EAAES,QAAQ,CACrB,CAAC;EAED,MAAMa,OAAO,gBACX5B,IAAA,CAACV,sBAAsB;IACrBuC,eAAe,EAAEnB,KAAK,CAACoB,KAAK,CAAC,qBAAqB,CAAE;IACpDC,eAAe,EAAErB,KAAK,CAACoB,KAAK,CAAC,mBAAmB,CAAE;IAClD1B,QAAQ,EAAEA,QAAS;IACnB4B,UAAU,EAAEpB,IAAI,CAACoB,UAAW;IAC5BC,QAAQ,EAAErB,IAAI,CAACqB,QAAS;IACxB5B,QAAQ,EAAEb,aAAa,CAACa,QAAQ,CAAE;IAClC6B,iBAAiB,EAAExB,KAAK,CAACoB,KAAK,CAAC,iBAAiB,CAAE;IAClDJ,aAAa,EAAEA,aAAc;IAC7BT,OAAO,EAAEA,OAAQ;IACjBkB,aAAa,EAAEV,MAAM,CAAClB,aAAa,CAAE;IACrCC,MAAM,EAAEA,MAAO;IACf4B,eAAe,EAAE7C,OAAO,KAAK8B,SAAS,GAAGA,SAAS,GAAG7B,aAAa,CAACD,OAAO;EAAE,CAC7E,CACF;EAED,OAAOoB,UAAU,gBAAGX,IAAA,CAACb,UAAU;IAACkD,aAAa,EAAEpC,iBAAkB;IAAAqC,QAAA,EAAEV;EAAO,CAAa,CAAC,GAAGA,OAAO;AACpG,CAAC","ignoreList":[]}
@@ -0,0 +1,16 @@
1
+ "use strict";
2
+
3
+ import { Image } from 'react-native';
4
+ /** A plain label is a segment with nothing else. */
5
+ export const segmentOf = value => typeof value === 'string' ? {
6
+ label: value
7
+ } : value;
8
+
9
+ /** What the native loaders take: a `require()`d file's packager or file URL, or the URI itself. */
10
+ export const imageUri = source => {
11
+ if (source === undefined) {
12
+ return undefined;
13
+ }
14
+ return Image.resolveAssetSource(Array.isArray(source) ? source[0] : source)?.uri;
15
+ };
16
+ //# sourceMappingURL=segments.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"names":["Image","segmentOf","value","label","imageUri","source","undefined","resolveAssetSource","Array","isArray","uri"],"sourceRoot":"../../../../src","sources":["atoms/segmented-control/segments.ts"],"mappings":";;AAAA,SAAQA,KAAK,QAAiC,cAAc;AAI5D;AACA,OAAO,MAAMC,SAAS,GAAIC,KAAuC,IAC/D,OAAOA,KAAK,KAAK,QAAQ,GAAG;EAACC,KAAK,EAAED;AAAK,CAAC,GAAGA,KAAK;;AAEpD;AACA,OAAO,MAAME,QAAQ,GAAIC,MAAuC,IAAyB;EACvF,IAAIA,MAAM,KAAKC,SAAS,EAAE;IACxB,OAAOA,SAAS;EAClB;EAEA,OAAON,KAAK,CAACO,kBAAkB,CAACC,KAAK,CAACC,OAAO,CAACJ,MAAM,CAAC,GAAGA,MAAM,CAAC,CAAC,CAAC,GAAGA,MAAM,CAAC,EAAEK,GAAG;AAClF,CAAC","ignoreList":[]}
@@ -3,6 +3,8 @@ import type { SegmentedControlComponent } from './segmented-control.types';
3
3
  * kara's own `UISegmentedControl`, with the pill in the accent and white text on it — the system control tints only the
4
4
  * pill, and SwiftUI's segmented picker not even that. A plain `UIView` in React Native layout: it needs no host, takes
5
5
  * the width it is given and UIKit's height. Inside a `Screen` or `Island` it is carried into that host by `RNHostView`
6
- * (see `useInsideHost`), the host's width and its own height. UIKit plays the control's own feedback.
6
+ * (see `useInsideHost`), the host's width and its own height. UIKit plays the control's own feedback. A segment shows a
7
+ * title or an image, never both, so an icon segment is drawn natively as one image of the icon and the label, in the
8
+ * label's colour; an image segment is the image alone, loaded and drawn by kara-native at `iconSize`.
7
9
  */
8
10
  export declare const SegmentedControl: SegmentedControlComponent;
@@ -1,13 +1,31 @@
1
+ import type { SpaceSize } from '@hzblj/kara-theme';
1
2
  import type { ReactNode } from 'react';
3
+ import type { ImageSourcePropType } from 'react-native';
4
+ import type { IconName } from '../icon';
5
+ /** A segment that shows more than its label. */
6
+ export type SegmentedControlSegment = {
7
+ /** One of the app's icons, before the label and in the label's colour, the selected segment's or the others'. */
8
+ icon?: IconName;
9
+ /**
10
+ * An image in its own colours instead of the label, such as a carrier's logo: a `require()`d file, or `{uri}` for a
11
+ * URL (raster or SVG). The label stays what screen readers say, and is shown instead if the image cannot be loaded.
12
+ */
13
+ image?: ImageSourcePropType;
14
+ /** What the segment says, and what `onChange` reports. */
15
+ label: string;
16
+ };
2
17
  /**
3
18
  * A row of mutually exclusive segments, the selected one in the accent: kara's own `UISegmentedControl` on iOS,
4
19
  * Material's single-choice segmented buttons on Android. It needs no `Screen` or `Island`: iOS is a plain view, Android
5
20
  * brings its own host. Inside one it joins that host, so it can share a stack with other native views. It takes the
6
- * width it is given; the height is the platform's.
21
+ * width it is given; the height is the platform's, or taller with `spacing`. A segment can lead with one of the app's
22
+ * icons or be an image, a logo, both loaded natively (SDWebImage on iOS, androidsvg on Android, as Footshop does).
7
23
  */
8
24
  export type SegmentedControlProps = {
9
25
  /** Stops the user changing the segment, and dims the control. */
10
26
  disabled?: boolean;
27
+ /** The height of the segments' icons and images, a spacing token. `s20` by default. */
28
+ iconSize?: SpaceSize;
11
29
  /**
12
30
  * Called with the index and label of the segment the user picked, never for a tap on the one already selected.
13
31
  * Android plays `selection`; iOS's control plays its own feedback.
@@ -15,9 +33,14 @@ export type SegmentedControlProps = {
15
33
  onChange?: (index: number, value: string) => void;
16
34
  /** The selected segment. Controlled: pass back what `onChange` reports. */
17
35
  selectedIndex: number;
36
+ /**
37
+ * Space above and below the segments' content, a spacing token: a taller control, for bigger icons or an easier
38
+ * target. Unset keeps the platform's own height, and the control is never shorter than that.
39
+ */
40
+ spacing?: SpaceSize;
18
41
  /** Test identifier, on the whole control. Reach a segment by its label. */
19
42
  testID?: string;
20
- /** The segments' labels, in order. */
21
- values: string[];
43
+ /** The segments, in order: a label, or a segment with an icon or an image. */
44
+ values: (string | SegmentedControlSegment)[];
22
45
  };
23
46
  export type SegmentedControlComponent = (props: SegmentedControlProps) => ReactNode;
@@ -0,0 +1,6 @@
1
+ import { type ImageSourcePropType } from 'react-native';
2
+ import type { SegmentedControlSegment } from './segmented-control.types';
3
+ /** A plain label is a segment with nothing else. */
4
+ export declare const segmentOf: (value: string | SegmentedControlSegment) => SegmentedControlSegment;
5
+ /** What the native loaders take: a `require()`d file's packager or file URL, or the URI itself. */
6
+ export declare const imageUri: (source: ImageSourcePropType | undefined) => string | undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hzblj/kara-components",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
4
4
  "description": "The atoms, molecules, organisms and templates of @hzblj/kara-ui — real SwiftUI and Jetpack Compose views through @expo/ui.",
5
5
  "author": "Jan Blazej",
6
6
  "license": "MIT",
@@ -61,9 +61,9 @@
61
61
  "dependencies": {
62
62
  "@hzblj/expo-native-icons": "^0.1.0",
63
63
  "@hzblj/expo-rich-haptics": "^0.1.0",
64
- "@hzblj/kara-native": "^0.18.0",
65
- "@hzblj/kara-sx": "^0.18.0",
66
- "@hzblj/kara-theme": "^0.18.0",
64
+ "@hzblj/kara-native": "^0.19.0",
65
+ "@hzblj/kara-sx": "^0.19.0",
66
+ "@hzblj/kara-theme": "^0.19.0",
67
67
  "sf-symbols-typescript": "^2.2.0"
68
68
  },
69
69
  "peerDependencies": {
@@ -1,16 +1,60 @@
1
1
  import {Host, SegmentedButton, SingleChoiceSegmentedButtonRow, Text} from '@expo/ui/jetpack-compose'
2
- import {fillMaxWidth, testID as testIdModifier, weight} from '@expo/ui/jetpack-compose/modifiers'
2
+ import {fillMaxWidth, height, testID as testIdModifier, weight} from '@expo/ui/jetpack-compose/modifiers'
3
+ import {iconResourceName} from '@hzblj/expo-native-icons'
3
4
  import {haptics} from '@hzblj/expo-rich-haptics'
4
- import {useAppTheme} from '@hzblj/kara-theme'
5
+ import {DrawableIcon, NativeImage} from '@hzblj/kara-native/jetpack-compose'
6
+ import {spacing as spacingTokens, useAppTheme} from '@hzblj/kara-theme'
5
7
  import {useCallback, useMemo} from 'react'
6
8
 
7
9
  import {useInsideHost} from '../../host-context'
8
- import type {SegmentedControlComponent, SegmentedControlProps} from './segmented-control.types'
10
+ import {HStack} from '../h-stack'
11
+ import type {SegmentedControlComponent, SegmentedControlProps, SegmentedControlSegment} from './segmented-control.types'
12
+ import {imageUri, segmentOf} from './segments'
9
13
 
10
14
  const MATCH_HEIGHT_ONLY = {horizontal: false, vertical: true}
11
15
 
12
- /** Every segment the same width, like UIKit's: Material's would size each to its label. */
13
- const SEGMENT = [weight(1)]
16
+ /** Material's own segment height, which `spacing` only ever adds to. */
17
+ const MATERIAL_HEIGHT = 40
18
+
19
+ type SegmentLabelProps = {
20
+ iconSize: number
21
+ segment: SegmentedControlSegment
22
+ }
23
+
24
+ /**
25
+ * What a segment shows: its label, one of the app's icons before it, or an image loaded by kara-native. The icon and a
26
+ * label the image falls back to take the segment's content colour, so they follow the selection and `disabled`.
27
+ */
28
+ const SegmentLabel = ({iconSize, segment}: SegmentLabelProps) => {
29
+ const theme = useAppTheme()
30
+ const face = theme.typography.subheadlineMedium
31
+
32
+ if (segment.image !== undefined) {
33
+ return (
34
+ <NativeImage
35
+ fontFamily={face.fontFamily}
36
+ fontSize={face.fontSize}
37
+ fontWeight={String(face.fontWeight)}
38
+ imageHeight={iconSize}
39
+ label={segment.label}
40
+ source={imageUri(segment.image)}
41
+ />
42
+ )
43
+ }
44
+
45
+ const label = <Text style={face}>{segment.label}</Text>
46
+
47
+ if (segment.icon === undefined) {
48
+ return label
49
+ }
50
+
51
+ return (
52
+ <HStack align="center" spacing="s6">
53
+ <DrawableIcon iconSize={iconSize} resourceName={iconResourceName(segment.icon)} />
54
+ {label}
55
+ </HStack>
56
+ )
57
+ }
14
58
 
15
59
  /**
16
60
  * Material's single-choice segmented buttons, the selected one filled with the accent like a checked `Switch`. Its own
@@ -20,13 +64,28 @@ const SEGMENT = [weight(1)]
20
64
  */
21
65
  export const SegmentedControl: SegmentedControlComponent = ({
22
66
  disabled = false,
67
+ iconSize = 's20',
23
68
  onChange,
24
69
  selectedIndex,
70
+ spacing,
25
71
  testID,
26
72
  values,
27
73
  }: SegmentedControlProps) => {
28
74
  const theme = useAppTheme()
29
75
  const insideHost = useInsideHost()
76
+ const segments = useMemo(() => values.map(segmentOf), [values])
77
+ const iconPoints = spacingTokens[iconSize]
78
+
79
+ // Every segment the same width, like UIKit's: Material's would size each to its label. With `spacing`, as tall as
80
+ // the content and that space, and never shorter than Material's own segment.
81
+ const segmentModifiers = useMemo(() => {
82
+ if (spacing === undefined) {
83
+ return [weight(1)]
84
+ }
85
+
86
+ const content = Math.max(theme.typography.subheadlineMedium.lineHeight, iconPoints)
87
+ return [weight(1), height(Math.max(MATERIAL_HEIGHT, content + 2 * spacingTokens[spacing]))]
88
+ }, [iconPoints, spacing, theme])
30
89
 
31
90
  // Every segment keeps the same outline, the selected one too: an accent border would run its fill out over the line
32
91
  // the others draw inside, and make it look taller than its neighbours.
@@ -56,24 +115,24 @@ export const SegmentedControl: SegmentedControlComponent = ({
56
115
  }
57
116
 
58
117
  haptics.selection()
59
- onChange?.(index, values[index] ?? '')
118
+ onChange?.(index, segments[index]?.label ?? '')
60
119
  },
61
- [onChange, selectedIndex, values]
120
+ [onChange, segments, selectedIndex]
62
121
  )
63
122
 
64
123
  const row = (
65
124
  <SingleChoiceSegmentedButtonRow modifiers={rowModifiers}>
66
- {values.map((label, index) => (
125
+ {segments.map((segment, index) => (
67
126
  <SegmentedButton
68
127
  colors={colors}
69
128
  enabled={!disabled}
70
129
  key={index}
71
- modifiers={SEGMENT}
130
+ modifiers={segmentModifiers}
72
131
  onClick={() => select(index)}
73
132
  selected={index === selectedIndex}
74
133
  >
75
134
  <SegmentedButton.Label>
76
- <Text style={theme.typography.subheadlineMedium}>{label}</Text>
135
+ <SegmentLabel iconSize={iconPoints} segment={segment} />
77
136
  </SegmentedButton.Label>
78
137
  </SegmentedButton>
79
138
  ))}
@@ -1,10 +1,12 @@
1
1
  import {RNHostView} from '@expo/ui/swift-ui'
2
+ import {iconResourceName} from '@hzblj/expo-native-icons'
2
3
  import {SegmentedControl as NativeSegmentedControl} from '@hzblj/kara-native/swift-ui'
3
- import {useAppTheme} from '@hzblj/kara-theme'
4
+ import {spacing as spacingTokens, useAppTheme} from '@hzblj/kara-theme'
4
5
  import {useCallback, useMemo} from 'react'
5
6
 
6
7
  import {useInsideHost} from '../../host-context'
7
8
  import type {SegmentedControlComponent, SegmentedControlProps} from './segmented-control.types'
9
+ import {imageUri, segmentOf} from './segments'
8
10
 
9
11
  const MATCH_HEIGHT_ONLY = {horizontal: false, vertical: true}
10
12
 
@@ -12,12 +14,16 @@ const MATCH_HEIGHT_ONLY = {horizontal: false, vertical: true}
12
14
  * kara's own `UISegmentedControl`, with the pill in the accent and white text on it — the system control tints only the
13
15
  * pill, and SwiftUI's segmented picker not even that. A plain `UIView` in React Native layout: it needs no host, takes
14
16
  * the width it is given and UIKit's height. Inside a `Screen` or `Island` it is carried into that host by `RNHostView`
15
- * (see `useInsideHost`), the host's width and its own height. UIKit plays the control's own feedback.
17
+ * (see `useInsideHost`), the host's width and its own height. UIKit plays the control's own feedback. A segment shows a
18
+ * title or an image, never both, so an icon segment is drawn natively as one image of the icon and the label, in the
19
+ * label's colour; an image segment is the image alone, loaded and drawn by kara-native at `iconSize`.
16
20
  */
17
21
  export const SegmentedControl: SegmentedControlComponent = ({
18
22
  disabled = false,
23
+ iconSize = 's20',
19
24
  onChange,
20
25
  selectedIndex,
26
+ spacing,
21
27
  testID,
22
28
  values,
23
29
  }: SegmentedControlProps) => {
@@ -25,15 +31,26 @@ export const SegmentedControl: SegmentedControlComponent = ({
25
31
  const insideHost = useInsideHost()
26
32
  const face = theme.typography.footnoteMedium
27
33
 
34
+ const segments = useMemo(() => values.map(segmentOf), [values])
35
+
28
36
  // The native control speaks in values; a segment's is its index, so two labels may read the same.
29
- const options = useMemo(() => values.map((label, index) => ({label, value: String(index)})), [values])
37
+ const options = useMemo(
38
+ () =>
39
+ segments.map((segment, index) => ({
40
+ icon: segment.icon ? iconResourceName(segment.icon) : undefined,
41
+ image: imageUri(segment.image),
42
+ label: segment.label,
43
+ value: String(index),
44
+ })),
45
+ [segments]
46
+ )
30
47
 
31
48
  const onValueChange = useCallback(
32
49
  (value: string) => {
33
50
  const index = Number(value)
34
- onChange?.(index, values[index] ?? '')
51
+ onChange?.(index, segments[index]?.label ?? '')
35
52
  },
36
- [onChange, values]
53
+ [onChange, segments]
37
54
  )
38
55
 
39
56
  const control = (
@@ -43,11 +60,13 @@ export const SegmentedControl: SegmentedControlComponent = ({
43
60
  disabled={disabled}
44
61
  fontFamily={face.fontFamily}
45
62
  fontSize={face.fontSize}
63
+ iconSize={spacingTokens[iconSize]}
46
64
  inactiveTextColor={theme.color('content.primary')}
47
65
  onValueChange={onValueChange}
48
66
  options={options}
49
67
  selectedValue={String(selectedIndex)}
50
68
  testID={testID}
69
+ verticalPadding={spacing === undefined ? undefined : spacingTokens[spacing]}
51
70
  />
52
71
  )
53
72
 
@@ -1,14 +1,34 @@
1
+ import type {SpaceSize} from '@hzblj/kara-theme'
1
2
  import type {ReactNode} from 'react'
3
+ import type {ImageSourcePropType} from 'react-native'
4
+
5
+ import type {IconName} from '../icon'
6
+
7
+ /** A segment that shows more than its label. */
8
+ export type SegmentedControlSegment = {
9
+ /** One of the app's icons, before the label and in the label's colour, the selected segment's or the others'. */
10
+ icon?: IconName
11
+ /**
12
+ * An image in its own colours instead of the label, such as a carrier's logo: a `require()`d file, or `{uri}` for a
13
+ * URL (raster or SVG). The label stays what screen readers say, and is shown instead if the image cannot be loaded.
14
+ */
15
+ image?: ImageSourcePropType
16
+ /** What the segment says, and what `onChange` reports. */
17
+ label: string
18
+ }
2
19
 
3
20
  /**
4
21
  * A row of mutually exclusive segments, the selected one in the accent: kara's own `UISegmentedControl` on iOS,
5
22
  * Material's single-choice segmented buttons on Android. It needs no `Screen` or `Island`: iOS is a plain view, Android
6
23
  * brings its own host. Inside one it joins that host, so it can share a stack with other native views. It takes the
7
- * width it is given; the height is the platform's.
24
+ * width it is given; the height is the platform's, or taller with `spacing`. A segment can lead with one of the app's
25
+ * icons or be an image, a logo, both loaded natively (SDWebImage on iOS, androidsvg on Android, as Footshop does).
8
26
  */
9
27
  export type SegmentedControlProps = {
10
28
  /** Stops the user changing the segment, and dims the control. */
11
29
  disabled?: boolean
30
+ /** The height of the segments' icons and images, a spacing token. `s20` by default. */
31
+ iconSize?: SpaceSize
12
32
  /**
13
33
  * Called with the index and label of the segment the user picked, never for a tap on the one already selected.
14
34
  * Android plays `selection`; iOS's control plays its own feedback.
@@ -16,10 +36,15 @@ export type SegmentedControlProps = {
16
36
  onChange?: (index: number, value: string) => void
17
37
  /** The selected segment. Controlled: pass back what `onChange` reports. */
18
38
  selectedIndex: number
39
+ /**
40
+ * Space above and below the segments' content, a spacing token: a taller control, for bigger icons or an easier
41
+ * target. Unset keeps the platform's own height, and the control is never shorter than that.
42
+ */
43
+ spacing?: SpaceSize
19
44
  /** Test identifier, on the whole control. Reach a segment by its label. */
20
45
  testID?: string
21
- /** The segments' labels, in order. */
22
- values: string[]
46
+ /** The segments, in order: a label, or a segment with an icon or an image. */
47
+ values: (string | SegmentedControlSegment)[]
23
48
  }
24
49
 
25
50
  export type SegmentedControlComponent = (props: SegmentedControlProps) => ReactNode
@@ -0,0 +1,16 @@
1
+ import {Image, type ImageSourcePropType} from 'react-native'
2
+
3
+ import type {SegmentedControlSegment} from './segmented-control.types'
4
+
5
+ /** A plain label is a segment with nothing else. */
6
+ export const segmentOf = (value: string | SegmentedControlSegment): SegmentedControlSegment =>
7
+ typeof value === 'string' ? {label: value} : value
8
+
9
+ /** What the native loaders take: a `require()`d file's packager or file URL, or the URI itself. */
10
+ export const imageUri = (source: ImageSourcePropType | undefined): string | undefined => {
11
+ if (source === undefined) {
12
+ return undefined
13
+ }
14
+
15
+ return Image.resolveAssetSource(Array.isArray(source) ? source[0] : source)?.uri
16
+ }