@lotics/ui 3.0.0 → 3.2.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "3.0.0",
3
+ "version": "3.2.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./tokens": "./src/tokens.ts",
@@ -127,6 +127,7 @@
127
127
  "./stepper": "./src/stepper.tsx",
128
128
  "./step_progress": "./src/step_progress.tsx",
129
129
  "./tabs": "./src/tabs.tsx",
130
+ "./segmented_control": "./src/segmented_control.tsx",
130
131
  "./auto_sizer": "./src/auto_sizer.tsx",
131
132
  "./animation_horizontal_slide": "./src/animation_horizontal_slide.tsx",
132
133
  "./group_avatar": "./src/group_avatar.tsx",
package/src/button.tsx CHANGED
@@ -22,6 +22,10 @@ interface ButtonPropsBase {
22
22
  icon?: IconName;
23
23
  alignSelf?: "flex-start" | "flex-end" | "center" | "stretch" | "auto";
24
24
  color?: ButtonColor;
25
+ /** Corner shape. `pill` (default) is the fully-rounded capsule — keep it for
26
+ * icon-only and toolbar buttons, and to preserve existing screens. `rounded`
27
+ * is a 10px radius — use it for labelled CTAs / action buttons. */
28
+ shape?: "pill" | "rounded";
25
29
  loading?: boolean;
26
30
  disabled?: boolean;
27
31
  tooltip?: string | UseTooltipOptions;
@@ -49,6 +53,7 @@ export function Button(props: ButtonProps) {
49
53
  alignSelf,
50
54
  title,
51
55
  color,
56
+ shape = "pill",
52
57
  style,
53
58
  disabled,
54
59
  tooltip,
@@ -108,8 +113,11 @@ export function Button(props: ButtonProps) {
108
113
  disabled={disabledOrLoading}
109
114
  // @ts-ignore hovered is a react-native-web extension not in base RN types
110
115
  style={({ pressed, hovered }) => {
116
+ const isPrimary = color === "primary" && !disabled;
117
+ const restPrimary = isPrimary && !pressed && !hovered;
111
118
  return [
112
119
  {
120
+ borderRadius: shape === "rounded" ? 10 : 999,
113
121
  backgroundColor: disabled
114
122
  ? getButtonDisabledBackgroundColor(color)
115
123
  : pressed
@@ -118,6 +126,12 @@ export function Button(props: ButtonProps) {
118
126
  ? getButtonHoverColor(color)
119
127
  : getButtonBackgroundColor(color),
120
128
  },
129
+ // Subtle depth on the primary: a soft drop shadow lifts it off the
130
+ // surface and a whisper of top highlight + a gentle vertical shade (the
131
+ // app-icon technique) catch the light — premium, not skeuomorphic. The
132
+ // lift stays on hover/press; only the gradient drops so the wash shows.
133
+ isPrimary && { boxShadow: "inset 0 1px 0 rgba(255,255,255,0.08), 0 1px 2px rgba(0,0,0,0.18)" },
134
+ restPrimary && { backgroundImage: `linear-gradient(180deg, ${colors.zinc["700"]} 0%, ${colors.zinc["900"]} 100%)` },
121
135
  styles.button,
122
136
  style,
123
137
  alignSelf && { alignSelf },
@@ -228,7 +242,6 @@ const styles = StyleSheet.create({
228
242
  justifyContent: "center",
229
243
  height: 40,
230
244
  paddingHorizontal: 10,
231
- borderRadius: 999,
232
245
  // react-native-web extensions
233
246
  ...({ touchAction: "manipulation", cursor: "pointer", transitionDuration: "0.1s", transitionProperty: "background-color" } as ViewStyle),
234
247
  },
@@ -5,27 +5,35 @@ import { PressableHighlight } from "./pressable_highlight";
5
5
  interface CardSelectItemProps {
6
6
  children: React.ReactNode;
7
7
  onPress: () => void;
8
+ /** Persistent selection — when true, the ring stays on (the current item in a
9
+ * list with one active selection: an org picker's current org, a switcher's
10
+ * open item). Reads as `aria-pressed`. */
11
+ selected?: boolean;
8
12
  testID?: string;
13
+ accessibilityLabel?: string;
9
14
  style?: StyleProp<ViewStyle>;
10
15
  }
11
16
 
12
17
  /**
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.
18
+ * A bordered, card-shaped button — the selectable item used on auth screens
19
+ * (organization picker, login choices) and in entity switchers. Always
20
+ * interactive: a real focusable `button` that shows a 2px ring on hover/press,
21
+ * matching the global keyboard focus ring. Pass `selected` to keep that ring on
22
+ * for the current item in a single-selection list. For a static surface use Card.
17
23
  */
18
24
  export function CardSelectItem(props: CardSelectItemProps) {
19
- const { children, onPress, testID, style } = props;
25
+ const { children, onPress, selected = false, testID, accessibilityLabel, style } = props;
20
26
 
21
27
  return (
22
28
  <PressableHighlight
23
29
  testID={testID}
24
30
  accessibilityRole="button"
31
+ accessibilityLabel={accessibilityLabel}
32
+ aria-pressed={selected}
25
33
  onPress={onPress}
26
34
  style={(state: PressableStateCallbackType) => {
27
35
  const hovered = (state as { hovered?: boolean }).hovered;
28
- const active = hovered || state.pressed;
36
+ const active = selected || hovered || state.pressed;
29
37
  return [styles.container, active && styles.ring, style];
30
38
  }}
31
39
  >
package/src/combobox.tsx CHANGED
@@ -10,7 +10,7 @@ import {
10
10
  import { useCallback, useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react";
11
11
  import { colors } from "./colors";
12
12
  import { Text } from "./text";
13
- import { Icon } from "./icon";
13
+ import { Icon, type IconName } from "./icon";
14
14
  import { TextInputField } from "./text_input_field";
15
15
  import { MenuButton } from "./menu_button";
16
16
  import { ActivityIndicator } from "./activity_indicator";
@@ -58,6 +58,11 @@ export interface ComboboxProps<T extends string = string, D = unknown> {
58
58
  recentOptions?: PickerOption<T, D>[];
59
59
  loading?: boolean;
60
60
  searchDebounceMs?: number;
61
+ /** Leading icon inside the input. OMIT (the default) for a SELECT — no leading
62
+ * glyph, a trailing chevron — so a "pick a code/record" combobox reads as a
63
+ * picker, not a free-text search box. Opt IN to the search-box look explicitly
64
+ * with `icon="search"` (leading glyph, no chevron). Multi-select shows neither. */
65
+ icon?: IconName;
61
66
  placeholder?: string;
62
67
  recentsLabel?: string;
63
68
  emptyText?: string;
@@ -109,6 +114,7 @@ export function Combobox<T extends string = string, D = unknown>(props: Combobox
109
114
  recentOptions,
110
115
  loading = false,
111
116
  searchDebounceMs = 200,
117
+ icon,
112
118
  placeholder,
113
119
  recentsLabel = "Recent",
114
120
  emptyText = "No results",
@@ -157,11 +163,14 @@ export function Combobox<T extends string = string, D = unknown>(props: Combobox
157
163
  return options.filter((o) => (o.label ?? o.value).toLowerCase().includes(q));
158
164
  }, [isServer, searching, options, multi, reflectSelection, single, query]);
159
165
 
160
- // Free-entry row, appended when the query matches no option label exactly.
166
+ // Free-entry row, appended when the query matches no option exactly. Match
167
+ // both label AND value — a query equal to an option's value (e.g. a code typed
168
+ // in full) must NOT also offer "Add <code>", or the two share a key.
161
169
  const customRow = useMemo((): PickerOption<T, D> | null => {
162
170
  if (!allowCustom || !searching) return null;
163
171
  const q = query.trim();
164
- const exact = options.some((o) => (o.label ?? o.value).toLowerCase() === q.toLowerCase());
172
+ const ql = q.toLowerCase();
173
+ const exact = options.some((o) => (o.label ?? o.value).toLowerCase() === ql || String(o.value).toLowerCase() === ql);
165
174
  if (exact) return null;
166
175
  const label = customOptionLabel ? customOptionLabel(q) : `Add "${q}"`;
167
176
  if (label === null) return null;
@@ -257,6 +266,10 @@ export function Combobox<T extends string = string, D = unknown>(props: Combobox
257
266
  const showList = open && !disabled && (searching || list.length > 0);
258
267
  const activeOptionId =
259
268
  activeIndex >= 0 && activeIndex < list.length ? optionId(activeIndex) : undefined;
269
+ // Select mode (no leading icon): show a trailing chevron so the field reads as
270
+ // a picker, not a free-text search. The clear ✕ owns the right slot when
271
+ // present, so suppress the chevron then.
272
+ const showChevron = !multi && !icon && !clearable;
260
273
 
261
274
  return (
262
275
  <View style={style}>
@@ -281,7 +294,7 @@ export function Combobox<T extends string = string, D = unknown>(props: Combobox
281
294
  <TextInputField
282
295
  ref={inputRef}
283
296
  testID={testID}
284
- icon={multi ? undefined : "search"}
297
+ icon={multi ? undefined : icon}
285
298
  value={query}
286
299
  clearable={!multi && clearable}
287
300
  clearLabel={clearLabel}
@@ -308,13 +321,18 @@ export function Combobox<T extends string = string, D = unknown>(props: Combobox
308
321
  autoFocus={autoFocus}
309
322
  autoCapitalize="none"
310
323
  autoCorrect={false}
311
- style={multi ? styles.multiInput : undefined}
324
+ style={multi ? styles.multiInput : showChevron ? styles.selectInput : undefined}
312
325
  role="combobox"
313
326
  aria-expanded={showList}
314
327
  aria-controls={listboxId}
315
328
  aria-activedescendant={activeOptionId}
316
329
  aria-autocomplete="list"
317
330
  />
331
+ {showChevron ? (
332
+ <View style={styles.chevron}>
333
+ <Icon name="chevrons-up-down" size={16} color={colors.zinc["400"]} />
334
+ </View>
335
+ ) : null}
318
336
  </View>
319
337
  <Popover
320
338
  open={showList}
@@ -421,6 +439,19 @@ const styles = StyleSheet.create({
421
439
  borderWidth: 0,
422
440
  height: 28,
423
441
  },
442
+ // Select mode: room on the right for the trailing chevron.
443
+ selectInput: {
444
+ paddingRight: 32,
445
+ },
446
+ chevron: {
447
+ position: "absolute",
448
+ right: 10,
449
+ top: 0,
450
+ bottom: 0,
451
+ justifyContent: "center",
452
+ pointerEvents: "none",
453
+ },
454
+
424
455
  chip: {
425
456
  flexDirection: "row",
426
457
  alignItems: "center",
@@ -0,0 +1,205 @@
1
+ import { useCallback, useRef } from "react";
2
+ import { StyleSheet, View, type StyleProp, type ViewStyle } from "react-native";
3
+ import { PressableHighlight } from "./pressable_highlight";
4
+ import { Text } from "./text";
5
+ import { colors, withAlpha } from "./colors";
6
+
7
+ export interface SegmentOption<T extends string> {
8
+ label: string;
9
+ value: T;
10
+ testID?: string;
11
+ }
12
+
13
+ interface SegmentedControlProps<T extends string> {
14
+ /** Accessible name of the group (it renders no visible label of its own). */
15
+ accessibilityLabel: string;
16
+ /** 2–4 mutually-exclusive peers. Above ~4, use Picker — segments stop fitting. */
17
+ options: SegmentOption<T>[];
18
+ value: T;
19
+ onValueChange: (value: T) => void;
20
+ /** Disables the whole control (e.g. while a mode-specific action runs). */
21
+ disabled?: boolean;
22
+ /** The track hugs its content by default (`alignSelf: flex-start`); pass
23
+ * `{ alignSelf: "stretch" }` to fill the parent's width. */
24
+ style?: StyleProp<ViewStyle>;
25
+ }
26
+
27
+ /**
28
+ * A segmented control: 2–4 visible peer segments in an inset track, the
29
+ * selected one raised as a white card. Semantically a **radiogroup** — a
30
+ * single choice among peers, all visible — NOT a tablist (no panels) and not
31
+ * a Switch (not on/off). Reach for it on a binary/few-way *mode* switch where
32
+ * both options should be one-tap and on screen (a generator mode, a list/grid
33
+ * view, a day/week/month range). Above ~4 options, use `Picker`.
34
+ *
35
+ * Keyboard follows the WAI-ARIA radio pattern: roving tabindex (only the
36
+ * selected segment is a tab stop), arrows move focus AND select (wrapping),
37
+ * Space/Enter select the focused segment.
38
+ */
39
+ export function SegmentedControl<T extends string>(props: SegmentedControlProps<T>) {
40
+ const { accessibilityLabel, options, value, onValueChange, disabled = false, style } = props;
41
+ const segmentRefs = useRef<Array<View | null>>([]);
42
+
43
+ const select = useCallback(
44
+ (index: number) => {
45
+ const option = options[index];
46
+ if (!option || option.value === value) return;
47
+ onValueChange(option.value);
48
+ },
49
+ [options, value, onValueChange],
50
+ );
51
+
52
+ // Arrows wrap and select per the radio pattern; Space/Enter select the
53
+ // focused segment. Focus follows selection so the new segment stays reachable.
54
+ const handleKeyDown = useCallback(
55
+ (event: { key: string; preventDefault?: () => void }, index: number) => {
56
+ if (disabled) return;
57
+ const last = options.length - 1;
58
+ let next = index;
59
+ switch (event.key) {
60
+ case "ArrowRight":
61
+ case "ArrowDown":
62
+ next = index === last ? 0 : index + 1;
63
+ break;
64
+ case "ArrowLeft":
65
+ case "ArrowUp":
66
+ next = index === 0 ? last : index - 1;
67
+ break;
68
+ case "Enter":
69
+ case " ":
70
+ event.preventDefault?.();
71
+ select(index);
72
+ return;
73
+ default:
74
+ return;
75
+ }
76
+ event.preventDefault?.();
77
+ onValueChange(options[next].value);
78
+ segmentRefs.current[next]?.focus();
79
+ },
80
+ [disabled, options, onValueChange, select],
81
+ );
82
+
83
+ // Roving tabindex: the selected segment is the tab stop. When `value` matches
84
+ // no option, the first segment is the fallback so the group stays reachable.
85
+ const selectedIndex = options.findIndex((option) => option.value === value);
86
+ const tabStopIndex = selectedIndex === -1 ? 0 : selectedIndex;
87
+
88
+ return (
89
+ <View
90
+ style={[styles.track, disabled && styles.trackDisabled, style]}
91
+ accessibilityRole="radiogroup"
92
+ aria-label={accessibilityLabel}
93
+ aria-disabled={disabled}
94
+ >
95
+ {options.map((option, index) => (
96
+ <Segment
97
+ ref={(node: View | null) => {
98
+ segmentRefs.current[index] = node;
99
+ }}
100
+ key={option.value}
101
+ option={option}
102
+ selected={option.value === value}
103
+ isTabStop={index === tabStopIndex}
104
+ disabled={disabled}
105
+ onPress={() => select(index)}
106
+ onKeyDown={(event) => handleKeyDown(event, index)}
107
+ />
108
+ ))}
109
+ </View>
110
+ );
111
+ }
112
+
113
+ interface SegmentProps<T extends string> {
114
+ ref: (node: View | null) => void;
115
+ option: SegmentOption<T>;
116
+ selected: boolean;
117
+ isTabStop: boolean;
118
+ disabled: boolean;
119
+ onPress: () => void;
120
+ onKeyDown: (event: { key: string; preventDefault?: () => void }) => void;
121
+ }
122
+
123
+ function Segment<T extends string>(props: SegmentProps<T>) {
124
+ const { ref, option, selected, isTabStop, disabled, onPress, onKeyDown } = props;
125
+
126
+ return (
127
+ <PressableHighlight
128
+ ref={ref}
129
+ // Own the per-state background: the track is zinc-100, so an unselected
130
+ // segment hovering must lift TOWARD the selected white (a half-white wash),
131
+ // not to PressableHighlight's default zinc-100 (invisible on the track).
132
+ style={(state) => {
133
+ const hovered = (state as { hovered?: boolean }).hovered;
134
+ return [
135
+ styles.segment,
136
+ selected
137
+ ? styles.segmentSelected
138
+ : {
139
+ backgroundColor: state.pressed
140
+ ? colors.white
141
+ : hovered
142
+ ? withAlpha(colors.white, 0.5)
143
+ : "transparent",
144
+ },
145
+ ];
146
+ }}
147
+ onPress={disabled ? undefined : onPress}
148
+ onKeyDown={onKeyDown}
149
+ testID={option.testID}
150
+ accessibilityRole="radio"
151
+ accessibilityLabel={option.label}
152
+ aria-checked={selected}
153
+ aria-disabled={disabled}
154
+ // Roving tabindex: only the selected segment (or the fallback) is a tab
155
+ // stop; the rest are reached with arrow keys.
156
+ focusable={isTabStop && !disabled}
157
+ >
158
+ {(state) => {
159
+ const hovered = (state as { hovered?: boolean }).hovered;
160
+ return (
161
+ <Text
162
+ size="sm"
163
+ weight={selected ? "medium" : "regular"}
164
+ color={selected || hovered ? "zinc-900" : "zinc-700"}
165
+ numberOfLines={1}
166
+ userSelect="none"
167
+ >
168
+ {option.label}
169
+ </Text>
170
+ );
171
+ }}
172
+ </PressableHighlight>
173
+ );
174
+ }
175
+
176
+ const styles = StyleSheet.create({
177
+ track: {
178
+ flexDirection: "row",
179
+ alignSelf: "flex-start",
180
+ gap: 2,
181
+ padding: 3,
182
+ borderRadius: 10,
183
+ backgroundColor: colors.zinc["100"],
184
+ },
185
+ trackDisabled: {
186
+ opacity: 0.55,
187
+ },
188
+ segment: {
189
+ // Grow to share a stretched track equally, but keep an `auto` basis so a
190
+ // hug-content track (the common toolbar case) sizes each segment to its
191
+ // label instead of collapsing it (flex-basis 0 → truncated text).
192
+ flexGrow: 1,
193
+ flexBasis: "auto",
194
+ minHeight: 30,
195
+ paddingVertical: 6,
196
+ paddingHorizontal: 14,
197
+ borderRadius: 7,
198
+ alignItems: "center",
199
+ justifyContent: "center",
200
+ },
201
+ segmentSelected: {
202
+ backgroundColor: colors.white,
203
+ boxShadow: colors.border_shadow,
204
+ },
205
+ });