@lotics/ui 7.14.0 → 7.16.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/AGENTS.md CHANGED
@@ -50,7 +50,9 @@ Pick by capability, not by name. (→ the source file for the API.)
50
50
  `useOptionList` engine; `Picker` is the only one that's a native dropdown.
51
51
  - **Pick member(s)** — `MemberSelect` (a `Select` that renders each option as a `MemberChip`,
52
52
  single or multi) — the ready member picker; pass it the roster (`members={useMembers().members}`).
53
- Don't re-wire `Select` + `renderOptionContent` + a directory by hand. To edit a `select_member`
53
+ Don't re-wire `Select` + `renderOptionContent` + a directory by hand. For an assignee FILTER that
54
+ also matches unassigned records, pass `unassignedLabel` → it prepends a muted "unassigned" option
55
+ (value `MEMBER_UNASSIGNED`), so you never hand-roll a mixed member+none `Select`. To edit a `select_member`
54
56
  field IN PLACE (chip at rest → picker on click) use its inline-edit twin `InlineMemberSelect`.
55
57
  - **A person / member (display)** — `MemberChip` (avatar + name + optional secondary line) — the
56
58
  ONE way to show a member inline: a picker option, an assignee, a `select_member` value. Pure:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "7.14.0",
3
+ "version": "7.16.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./tokens": "./src/tokens.ts",
@@ -8,6 +8,12 @@ import {
8
8
  type PickerOnClose,
9
9
  } from "./picker";
10
10
  import { MemberChip } from "./member_chip";
11
+ import { Text } from "./text";
12
+
13
+ /** The `value` of {@link MemberSelect}'s "unassigned" option (present only when
14
+ * `unassignedLabel` is set). Check a `MemberSelect` value against it to detect
15
+ * "no member assigned" — e.g. `value.includes(MEMBER_UNASSIGNED)` in a filter. */
16
+ export const MEMBER_UNASSIGNED = "__unassigned__";
11
17
 
12
18
  /** A candidate member for {@link MemberSelect}. Shaped to accept a `useMembers`
13
19
  * row from `@lotics/app-sdk` (or any roster) directly. */
@@ -35,6 +41,13 @@ interface MemberSelectProps<MULTI extends boolean = false> {
35
41
  /** Show an in-menu search box to filter the roster; default false. */
36
42
  searchable?: boolean;
37
43
  includeEmptyOption?: boolean;
44
+ /**
45
+ * When set, prepend a selectable "unassigned" option (value {@link MEMBER_UNASSIGNED}),
46
+ * rendered as muted text — for an assignee FILTER that also matches "no one assigned".
47
+ * Distinct from `includeEmptyOption` (a single-select CLEAR row that unsets the value):
48
+ * this is a real, filterable value the consumer detects in `value`.
49
+ */
50
+ unassignedLabel?: string;
38
51
  enableSelectAll?: boolean;
39
52
  /** Label for the "select all" link in multi-select mode. Pass a translated string. */
40
53
  selectAllLabel?: string;
@@ -61,16 +74,20 @@ interface MemberSelectProps<MULTI extends boolean = false> {
61
74
  * ```
62
75
  */
63
76
  export function MemberSelect<MULTI extends boolean = false>(props: MemberSelectProps<MULTI>) {
64
- const { members, ...picker } = props;
77
+ const { members, unassignedLabel, ...picker } = props;
65
78
 
66
79
  const byId = useMemo(() => new Map(members.map((m) => [m.id, m])), [members]);
67
80
  const options = useMemo<PickerOption<string>[]>(
68
- () => members.map((m) => ({ value: m.id, label: m.name || m.email || m.id })),
69
- [members],
81
+ () => [
82
+ ...(unassignedLabel ? [{ value: MEMBER_UNASSIGNED, label: unassignedLabel }] : []),
83
+ ...members.map((m) => ({ value: m.id, label: m.name || m.email || m.id })),
84
+ ],
85
+ [members, unassignedLabel],
70
86
  );
71
87
 
72
88
  const renderOptionContent = useCallback(
73
89
  (option: PickerOption<string>) => {
90
+ if (option.value === MEMBER_UNASSIGNED) return <Text size="sm" color="muted">{option.label}</Text>;
74
91
  const member = byId.get(option.value);
75
92
  if (!member) return null;
76
93
  return <MemberChip name={member.name || member.email} image={member.image} />;
package/src/table.tsx CHANGED
@@ -80,30 +80,35 @@ export function Table(props: TableProps) {
80
80
 
81
81
  return (
82
82
  <TableContext.Provider value={{ columns, leading, trailing }}>
83
- <View style={styles.headerBand}>
84
- {leading > 0 ? <View style={{ width: leading }}>{selectAll}</View> : null}
85
- {columns.map((col) => (
86
- <View key={col.key} style={colStyle(col)}>
87
- {col.label ? (
88
- col.sortable && onSort ? (
89
- <SortHeader label={col.label} sortKey={col.key} sort={sort ?? null} onSort={onSort} align={col.align} labels={sortLabels} />
90
- ) : (
91
- <Text size="xs" color="muted" transform="uppercase" numberOfLines={1}>
92
- {col.label}
93
- </Text>
94
- )
95
- ) : null}
96
- </View>
97
- ))}
98
- {trailing > 0 ? <View style={{ width: trailing }} /> : null}
99
- </View>
100
- <View style={styles.body}>
101
- {rows.map((row, i) => (
102
- <View key={i}>
103
- {i > 0 ? <Divider /> : null}
104
- {row}
105
- </View>
106
- ))}
83
+ {/* ONE layout node: without this wrapper the header band + body land as two
84
+ direct flex children of the app's container, and a parent column `gap`
85
+ (the standard section spacing) opens a hole between the header and rows. */}
86
+ <View>
87
+ <View style={styles.headerBand}>
88
+ {leading > 0 ? <View style={{ width: leading }}>{selectAll}</View> : null}
89
+ {columns.map((col) => (
90
+ <View key={col.key} style={colStyle(col)}>
91
+ {col.label ? (
92
+ col.sortable && onSort ? (
93
+ <SortHeader label={col.label} sortKey={col.key} sort={sort ?? null} onSort={onSort} align={col.align} labels={sortLabels} />
94
+ ) : (
95
+ <Text size="xs" color="muted" transform="uppercase" numberOfLines={1}>
96
+ {col.label}
97
+ </Text>
98
+ )
99
+ ) : null}
100
+ </View>
101
+ ))}
102
+ {trailing > 0 ? <View style={{ width: trailing }} /> : null}
103
+ </View>
104
+ <View style={styles.body}>
105
+ {rows.map((row, i) => (
106
+ <View key={i}>
107
+ {i > 0 ? <Divider /> : null}
108
+ {row}
109
+ </View>
110
+ ))}
111
+ </View>
107
112
  </View>
108
113
  </TableContext.Provider>
109
114
  );