@lotics/ui 1.25.0 → 1.27.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/package.json +6 -1
- package/src/app_icon.tsx +59 -0
- package/src/dynamic_icon.tsx +20 -0
- package/src/dynamic_icon.web.tsx +40 -0
- package/src/table.web.tsx +17 -9
- package/src/table_types.ts +4 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lotics/ui",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.27.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
"./tokens": "./src/tokens.ts",
|
|
@@ -48,6 +48,11 @@
|
|
|
48
48
|
"./text": "./src/text.tsx",
|
|
49
49
|
"./activity_indicator": "./src/activity_indicator.tsx",
|
|
50
50
|
"./icon": "./src/icon.tsx",
|
|
51
|
+
"./dynamic_icon": {
|
|
52
|
+
"react-native": "./src/dynamic_icon.tsx",
|
|
53
|
+
"default": "./src/dynamic_icon.web.tsx"
|
|
54
|
+
},
|
|
55
|
+
"./app_icon": "./src/app_icon.tsx",
|
|
51
56
|
"./tooltip": "./src/tooltip.tsx",
|
|
52
57
|
"./button": "./src/button.tsx",
|
|
53
58
|
"./checkbox": "./src/checkbox.tsx",
|
package/src/app_icon.tsx
ADDED
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Icon, type IconName } from "./icon";
|
|
2
|
+
|
|
3
|
+
interface DynamicIconProps {
|
|
4
|
+
/** Any Lucide icon name (kebab-case). */
|
|
5
|
+
name: string;
|
|
6
|
+
size?: number;
|
|
7
|
+
color?: string;
|
|
8
|
+
testID?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Native variant of `DynamicIcon`. There's no lazy lucide loader on native, so
|
|
13
|
+
* names resolve against the curated `Icon` set (which falls back to a neutral
|
|
14
|
+
* glyph for names outside it). The web variant (`dynamic_icon.web.tsx`) renders
|
|
15
|
+
* the full Lucide library. The runtime name is a boundary value — `Icon` handles
|
|
16
|
+
* an unknown name gracefully.
|
|
17
|
+
*/
|
|
18
|
+
export function DynamicIcon({ name, size, color, testID }: DynamicIconProps) {
|
|
19
|
+
return <Icon name={name as IconName} size={size} color={color} testID={testID} />;
|
|
20
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { View } from "react-native";
|
|
2
|
+
import { DynamicIcon as LucideDynamicIcon } from "lucide-react/dynamic";
|
|
3
|
+
import { colors } from "./colors";
|
|
4
|
+
|
|
5
|
+
type LucideIconName = React.ComponentProps<typeof LucideDynamicIcon>["name"];
|
|
6
|
+
|
|
7
|
+
interface DynamicIconProps {
|
|
8
|
+
/** Any Lucide icon name (kebab-case). Unknown names render a blank placeholder. */
|
|
9
|
+
name: string;
|
|
10
|
+
size?: number;
|
|
11
|
+
color?: string;
|
|
12
|
+
testID?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Renders any icon from the FULL Lucide library by runtime name. Web variant:
|
|
17
|
+
* lucide-react's lazy `DynamicIcon` code-splits per icon, so only icons actually
|
|
18
|
+
* shown are fetched — the full set costs ~nothing in the initial bundle. The
|
|
19
|
+
* native variant (`dynamic_icon.tsx`) resolves against the curated `Icon` set.
|
|
20
|
+
*
|
|
21
|
+
* Icons are decorative — the enclosing control carries the accessible name
|
|
22
|
+
* (matches `Icon`).
|
|
23
|
+
*/
|
|
24
|
+
export function DynamicIcon({ name, size = 24, color = colors.zinc["900"], testID }: DynamicIconProps) {
|
|
25
|
+
return (
|
|
26
|
+
<View
|
|
27
|
+
testID={testID}
|
|
28
|
+
accessibilityElementsHidden
|
|
29
|
+
importantForAccessibility="no-hide-descendants"
|
|
30
|
+
aria-hidden
|
|
31
|
+
>
|
|
32
|
+
<LucideDynamicIcon
|
|
33
|
+
name={name as LucideIconName}
|
|
34
|
+
size={size}
|
|
35
|
+
color={color}
|
|
36
|
+
fallback={() => <View style={{ width: size, height: size }} />}
|
|
37
|
+
/>
|
|
38
|
+
</View>
|
|
39
|
+
);
|
|
40
|
+
}
|
package/src/table.web.tsx
CHANGED
|
@@ -7,13 +7,22 @@ import type { Column, TableProps } from "./table_types";
|
|
|
7
7
|
export type { SortDir, TableSort, Column, TableProps } from "./table_types";
|
|
8
8
|
|
|
9
9
|
// Web table. Built from raw <div>s (not RN Pressable) so a click-to-expand row
|
|
10
|
-
// can legally contain interactive
|
|
11
|
-
// <button
|
|
12
|
-
//
|
|
13
|
-
//
|
|
10
|
+
// can legally contain interactive controls: the row is a <div role="row">, never
|
|
11
|
+
// a <button>. A row click toggles expansion EXCEPT when it lands on an actual
|
|
12
|
+
// interactive element — those keep their own behaviour. This is the web mirror of
|
|
13
|
+
// RN's responder model (the innermost control wins): only the control suppresses
|
|
14
|
+
// the toggle, so empty space anywhere in the row — including around a control in a
|
|
15
|
+
// wide action column — still expands. No stopPropagation, no nested <button>, no
|
|
16
|
+
// absolute overlay, no dead zones. The detail panel renders full-width below.
|
|
14
17
|
|
|
15
18
|
const CHEVRON_W = 44;
|
|
16
19
|
|
|
20
|
+
// Clicks landing on (or inside) one of these keep their own behaviour instead of
|
|
21
|
+
// toggling the row — the standard interactive HTML tags + ARIA interactive roles,
|
|
22
|
+
// plus an explicit [data-interactive] escape hatch for a non-element control.
|
|
23
|
+
const INTERACTIVE_SELECTOR =
|
|
24
|
+
'a[href], button, input, select, textarea, label, summary, [role="button"], [role="link"], [role="checkbox"], [role="switch"], [role="radio"], [role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"], [role="option"], [role="tab"], [role="slider"], [role="spinbutton"], [contenteditable="true"], [data-interactive]';
|
|
25
|
+
|
|
17
26
|
function colWidth<TRow extends Record<string, unknown>>(col: Column<TRow>): CSSProperties {
|
|
18
27
|
return col.width ? { width: col.width, flexShrink: 0 } : { flex: 1, minWidth: 0 };
|
|
19
28
|
}
|
|
@@ -113,10 +122,10 @@ export function Table<TRow extends Record<string, unknown>>(props: TableProps<TR
|
|
|
113
122
|
onClick={
|
|
114
123
|
pressable
|
|
115
124
|
? (e: React.MouseEvent) => {
|
|
116
|
-
// Whole-row click acts (expand or select), EXCEPT
|
|
117
|
-
// interactive
|
|
118
|
-
//
|
|
119
|
-
if ((e.target as HTMLElement).closest(
|
|
125
|
+
// Whole-row click acts (expand or select), EXCEPT when it lands on an
|
|
126
|
+
// actual interactive element — those keep their own behaviour. The
|
|
127
|
+
// disclosure chevron is the keyboard/AT affordance for expansion.
|
|
128
|
+
if ((e.target as HTMLElement).closest(INTERACTIVE_SELECTOR)) return;
|
|
120
129
|
if (expandable) toggle(key, row);
|
|
121
130
|
else onRowPress?.(row);
|
|
122
131
|
}
|
|
@@ -130,7 +139,6 @@ export function Table<TRow extends Record<string, unknown>>(props: TableProps<TR
|
|
|
130
139
|
<div
|
|
131
140
|
key={col.key as string}
|
|
132
141
|
role="cell"
|
|
133
|
-
data-interactive={col.interactive || undefined}
|
|
134
142
|
style={{
|
|
135
143
|
...bodyCellStyle,
|
|
136
144
|
...colWidth(col),
|
package/src/table_types.ts
CHANGED
|
@@ -17,10 +17,6 @@ export interface Column<TRow extends Record<string, unknown>> {
|
|
|
17
17
|
/** When true (and `onSortChange` is set), the header is pressable + shows a
|
|
18
18
|
* sort arrow when this column is the active `sort`. */
|
|
19
19
|
sortable?: boolean;
|
|
20
|
-
/** Cells whose own controls (buttons, pickers) must NOT toggle the row. The
|
|
21
|
-
* row's expand handler ignores clicks originating inside an interactive cell,
|
|
22
|
-
* so the rest of the row stays click-to-expand. */
|
|
23
|
-
interactive?: boolean;
|
|
24
20
|
renderCell?: (params: { row: TRow; column: Column<TRow> }) => ReactNode;
|
|
25
21
|
}
|
|
26
22
|
|
|
@@ -35,13 +31,14 @@ export interface TableProps<TRow extends Record<string, unknown>> {
|
|
|
35
31
|
* parent owns the actual sorting of `rows` — this only drives the indicator. */
|
|
36
32
|
sort?: TableSort<TRow> | null;
|
|
37
33
|
onSortChange?: (key: keyof TRow) => void;
|
|
38
|
-
/** Click-to-select: pressing a row (except
|
|
39
|
-
* e.g. a picker that returns the chosen row. Mutually exclusive with
|
|
34
|
+
/** Click-to-select: pressing a row (except its interactive controls) calls
|
|
35
|
+
* this — e.g. a picker that returns the chosen row. Mutually exclusive with
|
|
40
36
|
* `renderDetail` (a row either expands or selects); `renderDetail` wins if
|
|
41
37
|
* both are set. Pair with `rowStyle` to highlight the selected row. */
|
|
42
38
|
onRowPress?: (row: TRow) => void;
|
|
43
39
|
/** Render an inline detail panel, full-width below the row. When set, the
|
|
44
|
-
* whole row (except
|
|
40
|
+
* whole row (except its interactive controls) is click-to-expand. The row
|
|
41
|
+
* detects controls by element/role, so cells need no special marking. */
|
|
45
42
|
renderDetail?: (row: TRow) => ReactNode;
|
|
46
43
|
/** Controlled set of expanded row keys. Omit for internal (uncontrolled) state. */
|
|
47
44
|
expandedKeys?: Set<string>;
|