@lotics/ui 1.26.0 → 2.0.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 ADDED
@@ -0,0 +1,34 @@
1
+ # @lotics/ui
2
+
3
+ Cross-platform (react-native-web) UI primitives with no Lotics domain coupling —
4
+ no i18n, analytics, or domain types. Consumed by `frontend`, `browser_extension`,
5
+ and custom-code apps. Lotics-specific compositions live in `frontend` or
6
+ `@lotics/ui-internal`, not here.
7
+
8
+ The package ships `.tsx` source directly (no build step); consumers import
9
+ subpaths, e.g. `import { Button } from "@lotics/ui/button"`.
10
+
11
+ ## Dev harness
12
+
13
+ A standalone Vite app for developing the primitives in isolation — no Expo app,
14
+ no backend.
15
+
16
+ ```bash
17
+ npm run dev # from packages/ui — serves http://localhost:5173
18
+ ```
19
+
20
+ It lives in `dev/`: `main.tsx` mounts a hash-routed sidebar, `registry.ts` lists
21
+ the showcase pages, and `dev/pages/*` are the per-primitive demos. `vite.config.ts`
22
+ wires the react-native-web aliases (the same ones a consuming app's bundler sets up).
23
+
24
+ To add a page: create `dev/pages/<name>.tsx` exporting a component (use the
25
+ `Page`/`Section`/`Row` helpers from `dev/showcase.tsx`) and add one entry to the
26
+ `pages` array in `dev/registry.ts`.
27
+
28
+ ## Checks
29
+
30
+ ```bash
31
+ npm run typecheck # tsgo (covers src + dev)
32
+ npm run lint # oxlint
33
+ npm run test # vitest
34
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "1.26.0",
3
+ "version": "2.0.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./tokens": "./src/tokens.ts",
@@ -35,8 +35,6 @@
35
35
  "./stacked_progress_bar": "./src/stacked_progress_bar.tsx",
36
36
  "./legend_item": "./src/legend_item.tsx",
37
37
  "./trend_footer": "./src/trend_footer.tsx",
38
- "./chart_area": "./src/chart_area.tsx",
39
- "./chart_bar": "./src/chart_bar.tsx",
40
38
  "./spacing": "./src/spacing.ts",
41
39
  "./theme": "./src/theme.tsx",
42
40
  "./progress_bar": "./src/progress_bar.tsx",
@@ -48,11 +46,15 @@
48
46
  "./text": "./src/text.tsx",
49
47
  "./activity_indicator": "./src/activity_indicator.tsx",
50
48
  "./icon": "./src/icon.tsx",
49
+ "./dynamic_icon": {
50
+ "react-native": "./src/dynamic_icon.tsx",
51
+ "default": "./src/dynamic_icon.web.tsx"
52
+ },
53
+ "./app_icon": "./src/app_icon.tsx",
51
54
  "./tooltip": "./src/tooltip.tsx",
52
55
  "./button": "./src/button.tsx",
53
56
  "./checkbox": "./src/checkbox.tsx",
54
57
  "./combobox": "./src/combobox.tsx",
55
- "./search_select": "./src/search_select.tsx",
56
58
  "./portal": "./src/portal.tsx",
57
59
  "./popover_nav": "./src/popover_nav.tsx",
58
60
  "./popover": "./src/popover.tsx",
@@ -63,7 +65,7 @@
63
65
  "./pressable_highlight": "./src/pressable_highlight.tsx",
64
66
  "./icon_button": "./src/icon_button.tsx",
65
67
  "./info_popover": "./src/info_popover.tsx",
66
- "./select_item": "./src/select_item.tsx",
68
+ "./card_select_item": "./src/card_select_item.tsx",
67
69
  "./badge": "./src/badge.tsx",
68
70
  "./divider": "./src/divider.tsx",
69
71
  "./spacer": "./src/spacer.tsx",
@@ -179,8 +181,7 @@
179
181
  "react-dom": "^19.2.0",
180
182
  "react-native": ">=0.85.0",
181
183
  "react-native-svg": ">=15.0.0",
182
- "react-native-web": ">=0.20.0",
183
- "recharts": ">=3.0.0"
184
+ "react-native-web": ">=0.20.0"
184
185
  },
185
186
  "peerDependenciesMeta": {
186
187
  "@lotics/docx": {
@@ -200,12 +201,10 @@
200
201
  },
201
202
  "react-native-svg": {
202
203
  "optional": true
203
- },
204
- "recharts": {
205
- "optional": true
206
204
  }
207
205
  },
208
206
  "scripts": {
207
+ "dev": "vite",
209
208
  "typecheck": "tsgo --noEmit",
210
209
  "lint": "oxlint",
211
210
  "test": "vitest run"
@@ -213,6 +212,11 @@
213
212
  "devDependencies": {
214
213
  "@lotics/docx": "^0.1.0",
215
214
  "@lotics/xlsx": "^0.1.0",
216
- "recharts": "^3.8.1"
215
+ "@types/react-dom": "~19.2.2",
216
+ "@vitejs/plugin-react": "^4.3.4",
217
+ "lucide-react": "^0.562.0",
218
+ "react-dom": "^19.2.0",
219
+ "react-native-web": "^0.21.0",
220
+ "vite": "^7.2.4"
217
221
  }
218
222
  }
@@ -0,0 +1,59 @@
1
+ import { View, StyleSheet } from "react-native";
2
+ import { DynamicIcon } from "./dynamic_icon";
3
+ import { colors } from "./colors";
4
+
5
+ interface AppIconProps {
6
+ /**
7
+ * The app's icon — a Lucide icon name (the full library, via `DynamicIcon`).
8
+ * Empty/absent renders the neutral default.
9
+ */
10
+ icon?: string | null;
11
+ /** Palette token name (e.g. "blue"); unknown/empty → neutral zinc. */
12
+ themeColor?: string | null;
13
+ size?: "sm" | "md";
14
+ }
15
+
16
+ const DEFAULT_ICON = "layout-grid";
17
+
18
+ /**
19
+ * The app's launcher tile: a brand-tinted square holding the app's Lucide icon
20
+ * (full library) or its uploaded image. Shared by every surface that shows an app
21
+ * (launcher, list, picker) — frontend and extension alike.
22
+ */
23
+ export function AppIcon({ icon, themeColor, size = "sm" }: AppIconProps) {
24
+ const gradientStart = shade(themeColor, "400") ?? colors.zinc["400"];
25
+ const gradientEnd = shade(themeColor, "500") ?? colors.zinc["500"];
26
+ const iconColor = themeColor ? colors.white : colors.zinc["900"];
27
+ const isMd = size === "md";
28
+ const iconSize = isMd ? 20 : 16;
29
+ const name = icon && icon.trim() !== "" ? icon : DEFAULT_ICON;
30
+
31
+ return (
32
+ <View
33
+ style={[
34
+ styles.container,
35
+ isMd ? styles.md : styles.sm,
36
+ // Solid color is the cross-platform base; web layers the gradient on top.
37
+ { backgroundColor: gradientEnd, backgroundImage: `linear-gradient(135deg, ${gradientStart} 0%, ${gradientEnd} 100%)` },
38
+ ]}
39
+ >
40
+ <DynamicIcon name={name} size={iconSize} color={iconColor} />
41
+ </View>
42
+ );
43
+ }
44
+
45
+ /** Safe palette-token → shade lookup (mirrors the option-color resolver). */
46
+ function shade(token: string | null | undefined, level: "400" | "500"): string | undefined {
47
+ if (!token) return undefined;
48
+ const palette = colors[token as keyof typeof colors];
49
+ if (palette && typeof palette === "object" && level in palette) {
50
+ return (palette as Record<string, string>)[level];
51
+ }
52
+ return undefined;
53
+ }
54
+
55
+ const styles = StyleSheet.create({
56
+ container: { borderRadius: 6, alignItems: "center", justifyContent: "center", overflow: "hidden" },
57
+ sm: { width: 32, height: 32 },
58
+ md: { width: 40, height: 40, borderRadius: 8, boxShadow: "0px 2px 4px rgba(0, 0, 0, 0.1)" },
59
+ });
package/src/card.tsx CHANGED
@@ -1,32 +1,19 @@
1
- import { Pressable, StyleProp, StyleSheet, View, ViewStyle } from "react-native";
1
+ import { StyleProp, StyleSheet, View, ViewStyle } from "react-native";
2
2
  import { colors } from "./colors";
3
3
 
4
4
  interface CardProps {
5
5
  children: React.ReactNode;
6
6
  testID?: string;
7
- onPress?: () => void;
8
7
  style?: StyleProp<ViewStyle>;
9
8
  }
10
9
 
10
+ /**
11
+ * A bordered presentational surface — view only. It never handles interaction:
12
+ * for a pressable/selectable card use CardSelectItem (a real focusable button),
13
+ * or compose PressableHighlight for a bespoke action.
14
+ */
11
15
  export function Card(props: CardProps) {
12
- const { children, testID, onPress, style } = props;
13
-
14
- if (onPress) {
15
- return (
16
- <Pressable
17
- testID={testID}
18
- onPress={() => {
19
- onPress();
20
- }}
21
- style={(state) => {
22
- const hovered = (state as { hovered?: boolean }).hovered;
23
- return [styles.container, hovered && styles.hovered, style];
24
- }}
25
- >
26
- {children}
27
- </Pressable>
28
- );
29
- }
16
+ const { children, testID, style } = props;
30
17
 
31
18
  return (
32
19
  <View testID={testID} style={[styles.container, style]}>
@@ -55,7 +42,4 @@ const styles = StyleSheet.create({
55
42
  "0 1px 2px 0 rgba(38,38,38,0.06), 0 4px 12px -2px rgba(38,38,38,0.06)",
56
43
  } as ViewStyle),
57
44
  },
58
- hovered: {
59
- borderColor: colors.zinc["900"],
60
- },
61
45
  });
@@ -0,0 +1,52 @@
1
+ import { PressableStateCallbackType, StyleProp, StyleSheet, ViewStyle } from "react-native";
2
+ import { colors } from "./colors";
3
+ import { PressableHighlight } from "./pressable_highlight";
4
+
5
+ interface CardSelectItemProps {
6
+ children: React.ReactNode;
7
+ onPress: () => void;
8
+ testID?: string;
9
+ style?: StyleProp<ViewStyle>;
10
+ }
11
+
12
+ /**
13
+ * A bordered, card-shaped button — the selectable row used on auth screens
14
+ * (organization picker, login choices). Always interactive: a real focusable
15
+ * `button` that shows a 2px ring on hover/press, matching the global keyboard
16
+ * focus ring. For a static surface use Card instead.
17
+ */
18
+ export function CardSelectItem(props: CardSelectItemProps) {
19
+ const { children, onPress, testID, style } = props;
20
+
21
+ return (
22
+ <PressableHighlight
23
+ testID={testID}
24
+ accessibilityRole="button"
25
+ onPress={onPress}
26
+ style={(state: PressableStateCallbackType) => {
27
+ const hovered = (state as { hovered?: boolean }).hovered;
28
+ const active = hovered || state.pressed;
29
+ return [styles.container, active && styles.ring, style];
30
+ }}
31
+ >
32
+ {children}
33
+ </PressableHighlight>
34
+ );
35
+ }
36
+
37
+ const styles = StyleSheet.create({
38
+ container: {
39
+ padding: 16,
40
+ borderRadius: 8,
41
+ backgroundColor: colors.background,
42
+ borderColor: colors.border,
43
+ borderWidth: 1,
44
+ },
45
+ // A 2px ring flush against the border (spread 2, no offset/blur) — the same
46
+ // weight and color as the global `:focus-visible` outline, so hover, press,
47
+ // and keyboard focus all read as one consistent ring rather than a thin
48
+ // border-darken. boxShadow keeps it layout-neutral (no 1px→2px reflow).
49
+ ring: {
50
+ ...({ boxShadow: `0 0 0 2px ${colors.zinc["900"]}` } as ViewStyle),
51
+ },
52
+ });
@@ -155,7 +155,6 @@ export function ColumnFilter(props: ColumnFilterProps) {
155
155
  ) : (
156
156
  <PickerMenu
157
157
  multi
158
- enableSearch
159
158
  options={column.options ?? []}
160
159
  value={value?.kind === "select" ? value.selected : []}
161
160
  onValueChange={(selected) => onChange({ kind: "select", selected })}