@studio-piot/native-ui 0.16.0 → 0.20.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 (118) hide show
  1. package/README.md +13 -7
  2. package/dist/components/Choice/Choice.d.ts +6 -4
  3. package/dist/components/Choice/Choice.d.ts.map +1 -1
  4. package/dist/components/Choice/Choice.js +11 -5
  5. package/dist/components/Choice/Choice.js.map +1 -1
  6. package/dist/components/Choice/choiceLayout.d.ts +16 -0
  7. package/dist/components/Choice/choiceLayout.d.ts.map +1 -1
  8. package/dist/components/Choice/choiceLayout.js +20 -1
  9. package/dist/components/Choice/choiceLayout.js.map +1 -1
  10. package/dist/components/Choice/choiceThemeRoles.d.ts +1 -0
  11. package/dist/components/Choice/choiceThemeRoles.d.ts.map +1 -1
  12. package/dist/components/Choice/choiceThemeRoles.js +1 -0
  13. package/dist/components/Choice/choiceThemeRoles.js.map +1 -1
  14. package/dist/components/Choice/types.d.ts +7 -0
  15. package/dist/components/Choice/types.d.ts.map +1 -1
  16. package/dist/components/DateTime/DateTimePresenter.android.d.ts +4 -0
  17. package/dist/components/DateTime/DateTimePresenter.android.d.ts.map +1 -0
  18. package/dist/components/DateTime/DateTimePresenter.android.js +47 -0
  19. package/dist/components/DateTime/DateTimePresenter.android.js.map +1 -0
  20. package/dist/components/DateTime/DateTimePresenter.d.ts +4 -0
  21. package/dist/components/DateTime/DateTimePresenter.d.ts.map +1 -0
  22. package/dist/components/DateTime/DateTimePresenter.ios.d.ts +9 -0
  23. package/dist/components/DateTime/DateTimePresenter.ios.d.ts.map +1 -0
  24. package/dist/components/DateTime/DateTimePresenter.ios.js +69 -0
  25. package/dist/components/DateTime/DateTimePresenter.ios.js.map +1 -0
  26. package/dist/components/DateTime/DateTimePresenter.js +8 -0
  27. package/dist/components/DateTime/DateTimePresenter.js.map +1 -0
  28. package/dist/components/DateTime/FieldDateTime.d.ts +8 -0
  29. package/dist/components/DateTime/FieldDateTime.d.ts.map +1 -0
  30. package/dist/components/DateTime/FieldDateTime.js +90 -0
  31. package/dist/components/DateTime/FieldDateTime.js.map +1 -0
  32. package/dist/components/DateTime/dateTimeThemeRoles.d.ts +13 -0
  33. package/dist/components/DateTime/dateTimeThemeRoles.d.ts.map +1 -0
  34. package/dist/components/DateTime/dateTimeThemeRoles.js +15 -0
  35. package/dist/components/DateTime/dateTimeThemeRoles.js.map +1 -0
  36. package/dist/components/DateTime/dateTimeValue.d.ts +11 -0
  37. package/dist/components/DateTime/dateTimeValue.d.ts.map +1 -0
  38. package/dist/components/DateTime/dateTimeValue.js +40 -0
  39. package/dist/components/DateTime/dateTimeValue.js.map +1 -0
  40. package/dist/components/DateTime/index.d.ts +5 -0
  41. package/dist/components/DateTime/index.d.ts.map +1 -0
  42. package/dist/components/DateTime/index.js +10 -0
  43. package/dist/components/DateTime/index.js.map +1 -0
  44. package/dist/components/DateTime/types.d.ts +57 -0
  45. package/dist/components/DateTime/types.d.ts.map +1 -0
  46. package/dist/components/DateTime/types.js +3 -0
  47. package/dist/components/DateTime/types.js.map +1 -0
  48. package/dist/components/Select/Select.d.ts.map +1 -1
  49. package/dist/components/Select/Select.js +2 -9
  50. package/dist/components/Select/Select.js.map +1 -1
  51. package/dist/components/Tabs/Tabs.d.ts +8 -0
  52. package/dist/components/Tabs/Tabs.d.ts.map +1 -0
  53. package/dist/components/Tabs/Tabs.js +124 -0
  54. package/dist/components/Tabs/Tabs.js.map +1 -0
  55. package/dist/components/Tabs/index.d.ts +4 -0
  56. package/dist/components/Tabs/index.d.ts.map +1 -0
  57. package/dist/components/Tabs/index.js +8 -0
  58. package/dist/components/Tabs/index.js.map +1 -0
  59. package/dist/components/Tabs/tabsLayout.d.ts +13 -0
  60. package/dist/components/Tabs/tabsLayout.d.ts.map +1 -0
  61. package/dist/components/Tabs/tabsLayout.js +20 -0
  62. package/dist/components/Tabs/tabsLayout.js.map +1 -0
  63. package/dist/components/Tabs/tabsThemeRoles.d.ts +8 -0
  64. package/dist/components/Tabs/tabsThemeRoles.d.ts.map +1 -0
  65. package/dist/components/Tabs/tabsThemeRoles.js +10 -0
  66. package/dist/components/Tabs/tabsThemeRoles.js.map +1 -0
  67. package/dist/components/Tabs/types.d.ts +30 -0
  68. package/dist/components/Tabs/types.d.ts.map +1 -0
  69. package/dist/components/Tabs/types.js +3 -0
  70. package/dist/components/Tabs/types.js.map +1 -0
  71. package/dist/components/index.d.ts +1 -0
  72. package/dist/components/index.d.ts.map +1 -1
  73. package/dist/components/index.js +5 -2
  74. package/dist/components/index.js.map +1 -1
  75. package/dist/date-time.d.ts +6 -0
  76. package/dist/date-time.d.ts.map +1 -0
  77. package/dist/date-time.js +22 -0
  78. package/dist/date-time.js.map +1 -0
  79. package/dist/index.d.ts +2 -1
  80. package/dist/index.d.ts.map +1 -1
  81. package/dist/index.js +5 -2
  82. package/dist/index.js.map +1 -1
  83. package/dist/layout/SheetContainer.d.ts +6 -0
  84. package/dist/layout/SheetContainer.d.ts.map +1 -0
  85. package/dist/layout/SheetContainer.js +14 -0
  86. package/dist/layout/SheetContainer.js.map +1 -0
  87. package/docs/components/choice.md +26 -3
  88. package/docs/components/field-date-time.md +89 -0
  89. package/docs/components/tabs.md +85 -0
  90. package/package.json +16 -2
  91. package/src/components/Choice/Choice.tsx +17 -6
  92. package/src/components/Choice/choiceLayout.test.ts +36 -1
  93. package/src/components/Choice/choiceLayout.ts +25 -0
  94. package/src/components/Choice/choiceThemeRoles.test.ts +1 -0
  95. package/src/components/Choice/choiceThemeRoles.ts +1 -0
  96. package/src/components/Choice/types.ts +7 -0
  97. package/src/components/DateTime/DateTimePresenter.android.tsx +76 -0
  98. package/src/components/DateTime/DateTimePresenter.ios.tsx +122 -0
  99. package/src/components/DateTime/DateTimePresenter.tsx +6 -0
  100. package/src/components/DateTime/FieldDateTime.tsx +164 -0
  101. package/src/components/DateTime/dateTimeThemeRoles.test.ts +18 -0
  102. package/src/components/DateTime/dateTimeThemeRoles.ts +13 -0
  103. package/src/components/DateTime/dateTimeValue.test.ts +49 -0
  104. package/src/components/DateTime/dateTimeValue.ts +36 -0
  105. package/src/components/DateTime/index.ts +4 -0
  106. package/src/components/DateTime/types.ts +58 -0
  107. package/src/components/Select/Select.tsx +1 -9
  108. package/src/components/Tabs/Tabs.tsx +148 -0
  109. package/src/components/Tabs/index.ts +3 -0
  110. package/src/components/Tabs/tabsLayout.test.ts +36 -0
  111. package/src/components/Tabs/tabsLayout.ts +23 -0
  112. package/src/components/Tabs/tabsThemeRoles.test.ts +14 -0
  113. package/src/components/Tabs/tabsThemeRoles.ts +8 -0
  114. package/src/components/Tabs/types.ts +31 -0
  115. package/src/components/index.ts +7 -0
  116. package/src/date-time.ts +5 -0
  117. package/src/index.ts +8 -1
  118. package/src/layout/SheetContainer.tsx +11 -0
@@ -0,0 +1,148 @@
1
+ import { useEffect, useRef } from "react";
2
+ import { Pressable, StyleSheet, Text, View } from "react-native";
3
+ import Animated, {
4
+ Easing,
5
+ useAnimatedStyle,
6
+ useSharedValue,
7
+ withTiming,
8
+ } from "react-native-reanimated";
9
+ import { useResolvedFieldColor } from "../Field/useResolvedFieldColor";
10
+ import { resolveTabIndex, tabIndicatorFrame } from "./tabsLayout";
11
+ import { TABS_THEME_ROLES } from "./tabsThemeRoles";
12
+ import type { TabsProps } from "./types";
13
+
14
+ const SLIDE_MS = 300;
15
+ const ICON_SIZE = 17;
16
+ const INDICATOR_HEIGHT = 2;
17
+
18
+ /**
19
+ * Underline tabs that switch between views of one screen, e.g. Planned /
20
+ * Completed. Equal-width labels on a hairline, with an underline that slides
21
+ * to the active tab. Controlled: `value` + `onChange`.
22
+ */
23
+ export function Tabs<T extends string = string>({
24
+ value,
25
+ onChange,
26
+ tabs,
27
+ inset = 0,
28
+ borderColor: borderColorProp,
29
+ labelColor: labelColorProp,
30
+ activeLabelColor: activeLabelColorProp,
31
+ indicatorColor: indicatorColorProp,
32
+ style,
33
+ }: TabsProps<T>) {
34
+ const borderColor = useResolvedFieldColor(borderColorProp, TABS_THEME_ROLES.borderColor);
35
+ const labelColor = useResolvedFieldColor(labelColorProp, TABS_THEME_ROLES.labelColor);
36
+ const activeLabelColor = useResolvedFieldColor(
37
+ activeLabelColorProp,
38
+ TABS_THEME_ROLES.activeLabelColor,
39
+ );
40
+ const indicatorColor = useResolvedFieldColor(
41
+ indicatorColorProp,
42
+ TABS_THEME_ROLES.indicatorColor,
43
+ );
44
+
45
+ const index = resolveTabIndex(value, tabs);
46
+ const width = useRef(0);
47
+ const laidOut = useRef(false);
48
+ const xSV = useSharedValue(0);
49
+ const widthSV = useSharedValue(0);
50
+ const opacitySV = useSharedValue(0);
51
+
52
+ const place = (animated: boolean) => {
53
+ const frame = tabIndicatorFrame(index, tabs.length, width.current, inset);
54
+ if (!frame) {
55
+ opacitySV.value = withTiming(0, { duration: 160 });
56
+ return;
57
+ }
58
+ widthSV.value = frame.width;
59
+ opacitySV.value = 1;
60
+ xSV.value = animated
61
+ ? withTiming(frame.x, { duration: SLIDE_MS, easing: Easing.out(Easing.cubic) })
62
+ : frame.x;
63
+ };
64
+
65
+ useEffect(() => {
66
+ // place() reads the latest index and width.
67
+ if (laidOut.current) place(true);
68
+ }, [index, tabs.length, inset]);
69
+
70
+ const indicatorStyle = useAnimatedStyle(() => ({
71
+ width: widthSV.value,
72
+ opacity: opacitySV.value,
73
+ transform: [{ translateX: xSV.value }],
74
+ }));
75
+
76
+ if (tabs.length === 0) return null;
77
+
78
+ return (
79
+ <View
80
+ accessibilityRole="tablist"
81
+ onLayout={(event) => {
82
+ width.current = event.nativeEvent.layout.width;
83
+ place(laidOut.current);
84
+ laidOut.current = true;
85
+ }}
86
+ style={[styles.row, { borderBottomColor: borderColor, paddingHorizontal: inset }, style]}
87
+ >
88
+ {tabs.map((tab, i) => {
89
+ const active = i === index;
90
+ const color = active ? activeLabelColor : labelColor;
91
+ return (
92
+ <Pressable
93
+ key={tab.value}
94
+ accessibilityRole="tab"
95
+ accessibilityState={{ selected: active, disabled: tab.disabled }}
96
+ disabled={tab.disabled}
97
+ onPress={() => onChange(tab.value)}
98
+ style={({ pressed }) => [
99
+ styles.tab,
100
+ { opacity: tab.disabled ? 0.4 : pressed ? 0.6 : 1 },
101
+ ]}
102
+ >
103
+ {tab.icon ? (
104
+ <View
105
+ accessibilityElementsHidden
106
+ importantForAccessibility="no-hide-descendants"
107
+ >
108
+ {tab.icon({ color, size: ICON_SIZE })}
109
+ </View>
110
+ ) : null}
111
+ <Text numberOfLines={1} style={[styles.label, { color }]}>
112
+ {tab.label}
113
+ </Text>
114
+ </Pressable>
115
+ );
116
+ })}
117
+ <Animated.View
118
+ pointerEvents="none"
119
+ style={[styles.indicator, { backgroundColor: indicatorColor }, indicatorStyle]}
120
+ />
121
+ </View>
122
+ );
123
+ }
124
+
125
+ const styles = StyleSheet.create({
126
+ row: {
127
+ borderBottomWidth: StyleSheet.hairlineWidth,
128
+ flexDirection: "row",
129
+ },
130
+ tab: {
131
+ alignItems: "center",
132
+ flex: 1,
133
+ flexDirection: "row",
134
+ gap: 6,
135
+ justifyContent: "center",
136
+ paddingVertical: 12,
137
+ },
138
+ label: {
139
+ fontSize: 15,
140
+ fontWeight: "600",
141
+ },
142
+ indicator: {
143
+ bottom: 0,
144
+ height: INDICATOR_HEIGHT,
145
+ left: 0,
146
+ position: "absolute",
147
+ },
148
+ });
@@ -0,0 +1,3 @@
1
+ export { Tabs } from "./Tabs";
2
+ export { TABS_THEME_ROLES, type TabsThemeColorProp } from "./tabsThemeRoles";
3
+ export type { TabItem, TabsProps } from "./types";
@@ -0,0 +1,36 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { resolveTabIndex, tabIndicatorFrame } from "./tabsLayout.ts";
4
+
5
+ describe("tabIndicatorFrame", () => {
6
+ it("splits the width evenly and sits under the active tab", () => {
7
+ assert.deepEqual(tabIndicatorFrame(0, 2, 300), { x: 0, width: 150 });
8
+ assert.deepEqual(tabIndicatorFrame(1, 2, 300), { x: 150, width: 150 });
9
+ assert.deepEqual(tabIndicatorFrame(2, 3, 300), { x: 200, width: 100 });
10
+ });
11
+
12
+ it("keeps tabs inside the inset while the row spans the full width", () => {
13
+ // 360 wide with 16 either side: two tabs of 164.
14
+ assert.deepEqual(tabIndicatorFrame(0, 2, 360, 16), { x: 16, width: 164 });
15
+ assert.deepEqual(tabIndicatorFrame(1, 2, 360, 16), { x: 180, width: 164 });
16
+ });
17
+
18
+ it("is hidden with no tabs, no width or no active tab", () => {
19
+ assert.equal(tabIndicatorFrame(0, 0, 300), null);
20
+ assert.equal(tabIndicatorFrame(0, 2, 0), null);
21
+ assert.equal(tabIndicatorFrame(0, 2, 30, 16), null);
22
+ assert.equal(tabIndicatorFrame(-1, 2, 300), null);
23
+ });
24
+ });
25
+
26
+ describe("resolveTabIndex", () => {
27
+ const tabs = [{ value: "planned" }, { value: "completed" }];
28
+
29
+ it("finds the active tab", () => {
30
+ assert.equal(resolveTabIndex("completed", tabs), 1);
31
+ });
32
+
33
+ it("is -1 for an unknown value", () => {
34
+ assert.equal(resolveTabIndex("archived", tabs), -1);
35
+ });
36
+ });
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Where the active tab's indicator sits: an equal share of the width inside
3
+ * `inset` on each side (the row and its hairline still span the full width).
4
+ * Pure so it can be unit-tested.
5
+ */
6
+ export function tabIndicatorFrame(
7
+ index: number,
8
+ count: number,
9
+ width: number,
10
+ inset = 0,
11
+ ): { x: number; width: number } | null {
12
+ const inner = width - inset * 2;
13
+ if (count <= 0 || inner <= 0 || index < 0) return null;
14
+ const tabWidth = inner / count;
15
+ return { x: inset + index * tabWidth, width: tabWidth };
16
+ }
17
+
18
+ export function resolveTabIndex<T extends string>(
19
+ value: T,
20
+ tabs: readonly { value: T }[],
21
+ ): number {
22
+ return tabs.findIndex((tab) => tab.value === value);
23
+ }
@@ -0,0 +1,14 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { TABS_THEME_ROLES } from "./tabsThemeRoles.ts";
4
+
5
+ describe("TABS_THEME_ROLES", () => {
6
+ it("maps colour props to tabs-prefixed roles", () => {
7
+ assert.deepEqual(TABS_THEME_ROLES, {
8
+ borderColor: "tabsBorder",
9
+ labelColor: "tabsLabel",
10
+ activeLabelColor: "tabsActiveLabel",
11
+ indicatorColor: "tabsIndicator",
12
+ });
13
+ });
14
+ });
@@ -0,0 +1,8 @@
1
+ export const TABS_THEME_ROLES = {
2
+ borderColor: "tabsBorder",
3
+ labelColor: "tabsLabel",
4
+ activeLabelColor: "tabsActiveLabel",
5
+ indicatorColor: "tabsIndicator",
6
+ } as const;
7
+
8
+ export type TabsThemeColorProp = keyof typeof TABS_THEME_ROLES;
@@ -0,0 +1,31 @@
1
+ import type { StyleProp, ViewStyle } from "react-native";
2
+ import type { ButtonIcon } from "../Button";
3
+
4
+ export type TabItem<T extends string = string> = {
5
+ value: T;
6
+ label: string;
7
+ /** Left of the label, in the label's colour: `({ color, size }) => ReactNode`. */
8
+ icon?: ButtonIcon;
9
+ disabled?: boolean;
10
+ };
11
+
12
+ export type TabsProps<T extends string = string> = {
13
+ value: T;
14
+ onChange: (value: T) => void;
15
+ tabs: readonly TabItem<T>[];
16
+ /**
17
+ * Space on each side between the row's edges and the tabs. The hairline
18
+ * still spans the full width, so a full-bleed row can line its tabs up
19
+ * with the page content. Default 0.
20
+ */
21
+ inset?: number;
22
+ /** Line under all tabs. Defaults to theme role `tabsBorder`. */
23
+ borderColor?: string;
24
+ /** Inactive label. Defaults to theme role `tabsLabel`. */
25
+ labelColor?: string;
26
+ /** Active label. Defaults to theme role `tabsActiveLabel`. */
27
+ activeLabelColor?: string;
28
+ /** Sliding underline. Defaults to theme role `tabsIndicator`. */
29
+ indicatorColor?: string;
30
+ style?: StyleProp<ViewStyle>;
31
+ };
@@ -120,6 +120,13 @@ export {
120
120
  type TabAccessoryThemeColorProp,
121
121
  type TabAccessoryVariant,
122
122
  } from "./TabAccessoryButton";
123
+ export {
124
+ Tabs,
125
+ TABS_THEME_ROLES,
126
+ type TabItem,
127
+ type TabsProps,
128
+ type TabsThemeColorProp,
129
+ } from "./Tabs";
123
130
  export {
124
131
  Page,
125
132
  PageBleed,
@@ -0,0 +1,5 @@
1
+ /**
2
+ * `@studio-piot/native-ui/date-time`: the date and time field. A separate
3
+ * entry so `@expo/ui`, which it needs, stays optional for apps without it.
4
+ */
5
+ export * from "./components/DateTime";
package/src/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- export const version = "0.16.0";
1
+ export const version = "0.20.0";
2
2
 
3
3
  export * from "./theme";
4
4
  export { SHEET_TOP_RADIUS, screenPaddingHorizontal } from "./layout";
@@ -124,6 +124,13 @@ export {
124
124
  type TabAccessoryThemeColorProp,
125
125
  type TabAccessoryVariant,
126
126
  } from "./components/TabAccessoryButton";
127
+ export {
128
+ Tabs,
129
+ TABS_THEME_ROLES,
130
+ type TabItem,
131
+ type TabsProps,
132
+ type TabsThemeColorProp,
133
+ } from "./components/Tabs";
127
134
  export {
128
135
  Page,
129
136
  PageBleed,
@@ -0,0 +1,11 @@
1
+ import type { ReactNode } from "react";
2
+ import { Platform } from "react-native";
3
+ import { FullWindowOverlay } from "react-native-screens";
4
+
5
+ /** Renders a bottom sheet above React Navigation native modals (iOS). */
6
+ export function SheetContainer({ children }: { children?: ReactNode }) {
7
+ if (Platform.OS === "ios") {
8
+ return <FullWindowOverlay>{children}</FullWindowOverlay>;
9
+ }
10
+ return <>{children}</>;
11
+ }