@lotics/ui 7.0.0 → 7.1.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
@@ -171,7 +171,7 @@ This is the most common thing to get right. Match the JOB to the pattern:
171
171
  ### Inline edit — the preferred way to edit an existing record
172
172
  When the whole record is editable (a detail/record screen, dense settings), don't wrap it in a
173
173
  form mode or a preview↔edit card — make each VALUE inline-editable: it reads as plain text,
174
- hover tints it (no pencil that shifts), click swaps the input in **at the same height** (zero
174
+ hover reveals its border (it's a field — no grey tint, no pencil that shifts), click swaps the input in **at the same height** (zero
175
175
  reflow, the whole point), and it commits on blur (Enter saves, Escape reverts) or via
176
176
  `controls="buttons"` (✓ primary / ✕). One per type — `InlineTextInput` · `InlineNumberInput`
177
177
  (`format` for currency/units) · `InlineSelect` (plain options OR `renderOptionContent`; floats an
@@ -547,9 +547,10 @@ was only ever about the review surfaces' sparkles/severity glyphs, not functiona
547
547
  resets the native outline. Every interactive control paints its OWN ring; nothing rings unless it
548
548
  opts in. Three ways, pick by base:
549
549
  - **`FocusRingPressable`** (`@lotics/ui/focus_ring_pressable`) — a `Pressable` that rings on keyboard
550
- focus; the default for a raw interactive element (cell, tile, nav button, menu option). Forwards
551
- all `PressableProps` (onPress, accessibilityRole/Label/State, ref, style). Reach for this before
552
- hand-rolling a Pressable + the hook.
550
+ focus, WITHOUT the hover wash; the base for any control whose hover affordance is its BORDER not a wash
551
+ (inputs/selects, the inline editors, cells, tiles, nav buttons, menu options). Forwards all
552
+ `PressableProps`; its state-fn `style` exposes `hovered`/`focusVisible` so the control paints its own
553
+ border-hover. Reach for this before hand-rolling a Pressable + the hook.
553
554
  - **`PressableHighlight`** — set the opt-in **`focusRing`** prop (or read `state.focusVisible` from
554
555
  its style-fn / children for a bespoke treatment, e.g. `CardSelectItem` which rings on hover/press/focus).
555
556
  - **`useFocusRing`** (`@lotics/ui/use_focus_ring`) — the underlying hook for inputs / custom surfaces.
@@ -562,14 +563,26 @@ was only ever about the review surfaces' sparkles/severity glyphs, not functiona
562
563
  its open state so it reads identically to a keyboard-focused control. **Coverage is a contract:** EVERY
563
564
  interactive control must ring (a focusable control with no focus treatment is a bug — the per-Pressable
564
565
  audit is the gate); a surface that shouldn't ring is a non-control → `tabIndex={-1}`, not a missing ring.
565
- - **A trigger's hover/focus is a contract too build it on `PressableHighlight`, never a raw `Pressable`.**
566
- Every popover/menu/select anchor (`Select`, `FilterChip`, `Switcher`, the inline editors, a custom
567
- `PopoverTrigger`) rides `PressableHighlight` (or `FocusRingPressable`): it paints the hover **wash** +
568
- focus ring AROUND the caller's `style`, so a borderless in-cell override (`style={{ borderColor:
569
- "transparent" }}` on a `Select`) keeps the wash and reads like its inline neighbours. A raw `Pressable`
570
- that hand-rolls its own hover is the bug a caller's `borderColor` then erases the affordance. Layer the
571
- hover-border BEFORE `style` (a transparent edge suppresses it wash only) and the open ring (`FOCUS_RING`)
572
- on the open state.
566
+ - **Hover intensifies a control's OWN resting signature — never a foreign affordance. Pick the base by WHAT THE SURFACE IS; never a raw `Pressable`.** The one rule behind every interactive control:
567
+ - **Fields** (type into / pick from) — `TextInputField`, `Picker`, `Select`, `Combobox`'s input,
568
+ `NumberInput`, `DateField`/`TimePicker`, `DateRangeFilterField`, the inline editors
569
+ (`InlineSelect`/`InlineDatePicker` via `InlineEditView`) signature is a BORDER hover DARKENS it to
570
+ `HOVER_BORDER` (a borderless in-cell field REVEALS one). Build on **`FocusRingPressable`** (ring + a11y
571
+ + a `state.hovered`/`focusVisible` style-fn, NO wash); layer the hover-border AFTER `style` so it wins
572
+ over the caller's resting edge; put the open ring (`FOCUS_RING`) on the open state. **A field NEVER
573
+ greys its content.**
574
+ - **Actions** — `Button`, `IconButton` — signature is a FILL → hover DARKENS the fill (own colour logic).
575
+ Never a border, never a wash.
576
+ - **Pills / toggles** — `Chip`/`ChipGroup`/`FilterChip` (via `chipSurfaceStyle`), `SegmentedControl`,
577
+ `Tabs`, `Switcher`, `RadioPicker` — signature is a pill SURFACE → hover WASHES it (white→zinc-100);
578
+ selected adds a ring/fill.
579
+ - **Surfaces** — `PressableRow`, `MenuButton`, list/menu items, `CardSelectItem`, `Card`, `Accordion` —
580
+ signature is a row/card SURFACE → hover WASHES it. Build on **`PressableHighlight`** — the grey wash IS
581
+ the affordance (its whole job; it's in the name).
582
+ - The bug this prevents: a FIELD built on `PressableHighlight` inherits the wash AND its own border →
583
+ it greys *and* animates its edge while its siblings only border-hover (`Select`/`DateRangeFilterField`/
584
+ the inline editors did exactly this pre-7.x). Reach for `PressableHighlight` on a bordered/input control
585
+ and you've made it.
573
586
  - **Cards are banded — and composable** (all from `@lotics/ui/card`):
574
587
  ```tsx
575
588
  <Card style={{ padding: 0 }}>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "7.0.0",
3
+ "version": "7.1.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./tokens": "./src/tokens.ts",
@@ -5,7 +5,7 @@ import { colors } from "./colors";
5
5
  import { CONTROL_RADIUS, HOVER_BORDER, CONTROL_TRANSITION } from "./control_surface";
6
6
  import { Icon } from "./icon";
7
7
  import { Button } from "./button";
8
- import { PressableHighlight } from "./pressable_highlight";
8
+ import { FocusRingPressable } from "./focus_ring_pressable";
9
9
  import { Popover, PopoverTrigger, PopoverContent, PopoverFooter } from "./popover";
10
10
  import { DateFilter, DateFilterValue, DateFilterLabels } from "./date_filter";
11
11
  import { formatDate } from "./format_date";
@@ -120,8 +120,7 @@ export function DateRangeFilterField(props: DateRangeFilterFieldProps) {
120
120
  return (
121
121
  <Popover open={open} onOpenChange={setOpen} side="bottom" align="start">
122
122
  <PopoverTrigger>
123
- <PressableHighlight
124
- focusRing
123
+ <FocusRingPressable
125
124
  testID={testID}
126
125
  accessibilityRole="button"
127
126
  accessibilityLabel={labels.selectDateRange}
@@ -143,7 +142,7 @@ export function DateRangeFilterField(props: DateRangeFilterFieldProps) {
143
142
  {display}
144
143
  </Text>
145
144
  <Icon name="chevron-down" size={14} color={colors.zinc[400]} />
146
- </PressableHighlight>
145
+ </FocusRingPressable>
147
146
  </PopoverTrigger>
148
147
  <PopoverContent disableBodyScroll>
149
148
  <DateFilter
@@ -2,20 +2,37 @@ import { Pressable, type PressableProps, type StyleProp, type View, type ViewSty
2
2
  import { type Ref } from "react";
3
3
  import { FOCUS_RING } from "./control_surface";
4
4
  import { useFocusRing, composeHandler } from "./use_focus_ring";
5
+ import { type PressableHighlightState } from "./pressable_highlight";
5
6
 
6
- export interface FocusRingPressableProps extends PressableProps {
7
+ export interface FocusRingPressableProps extends Omit<PressableProps, "style"> {
7
8
  ref?: Ref<View>;
9
+ /**
10
+ * Narrows the state-function `style` to `PressableHighlightState`, so a control can
11
+ * read the web-only `hovered` (to paint its OWN border-hover) and `focusVisible`,
12
+ * exactly like `PressableHighlight` — the wash-free base still hands controls the
13
+ * flags they need to drive their edge.
14
+ */
15
+ style?: StyleProp<ViewStyle> | ((state: PressableHighlightState) => StyleProp<ViewStyle>);
16
+ /**
17
+ * `"none"` on a value/text surface (an inline-edit box) — a pressable is a button,
18
+ * not a selection surface, so a drag-click edits instead of starting a text
19
+ * selection. Exposed because RN types only carry `userSelect` on `TextStyle` while
20
+ * react-native-web applies it to any element.
21
+ */
22
+ userSelect?: "auto" | "none";
8
23
  }
9
24
 
10
25
  /**
11
26
  * A `Pressable` that paints the kit `FOCUS_RING` on keyboard focus — the raw-control
12
27
  * counterpart to `PressableHighlight`'s `focusRing` prop, for interactive elements
13
- * that DON'T want the hover wash (cells, tiles, nav buttons, menu options). Forwards
14
- * every `PressableProps` (onPress, accessibilityRole/Label/State, ref, …) and merges
15
- * the ring with the caller's `style` (object or state-function) + onFocus/onBlur.
16
- * Reach for this before hand-rolling a one-off Pressable + `useFocusRing`.
28
+ * that DON'T want the hover wash (the inputs/selects, cells, tiles, nav buttons, menu
29
+ * options). Forwards every `PressableProps` (onPress, accessibilityRole/Label/State,
30
+ * ref, …); its state-function `style` exposes `hovered`/`focusVisible` so a control
31
+ * paints its own border-hover, and it merges the focus ring + onFocus/onBlur. Reach
32
+ * for this for ANY interactive control whose hover affordance is its border (never a
33
+ * wash) — `PressableHighlight` is only for rows/menus/cards.
17
34
  */
18
- export function FocusRingPressable({ ref, style, onFocus, onBlur, ...rest }: FocusRingPressableProps) {
35
+ export function FocusRingPressable({ ref, style, onFocus, onBlur, userSelect, ...rest }: FocusRingPressableProps) {
19
36
  const { focusVisible, focusProps } = useFocusRing();
20
37
  return (
21
38
  <Pressable
@@ -25,7 +42,8 @@ export function FocusRingPressable({ ref, style, onFocus, onBlur, ...rest }: Foc
25
42
  onBlur={composeHandler(onBlur ?? undefined, focusProps.onBlur)}
26
43
  style={(state) =>
27
44
  [
28
- typeof style === "function" ? style(state) : style,
45
+ userSelect ? ({ userSelect } as ViewStyle) : null,
46
+ typeof style === "function" ? style({ ...state, focusVisible }) : style,
29
47
  focusVisible ? { boxShadow: FOCUS_RING } : null,
30
48
  ] as StyleProp<ViewStyle>
31
49
  }
@@ -3,9 +3,9 @@ import { View, StyleSheet, type GestureResponderEvent } from "react-native";
3
3
  import { Text } from "./text";
4
4
  import { IconButton } from "./icon_button";
5
5
  import { ActivityIndicator } from "./activity_indicator";
6
- import { PressableHighlight } from "./pressable_highlight";
6
+ import { FocusRingPressable } from "./focus_ring_pressable";
7
7
  import { colors } from "./colors";
8
- import { FOCUS_RING, CONTROL_RADIUS } from "./control_surface";
8
+ import { FOCUS_RING, CONTROL_RADIUS, HOVER_BORDER, CONTROL_TRANSITION } from "./control_surface";
9
9
  import { fontFamilyRegular, getInputTextStyle } from "./text_utils";
10
10
 
11
11
  /** The kit's standard control height (TextInputField, NumberInput, Picker, …).
@@ -124,7 +124,8 @@ interface InlineEditViewProps {
124
124
 
125
125
  /**
126
126
  * The view-mode box of an inline-editable field: the value as plain text in a
127
- * `PressableHighlight` (hover tints it; the pointer cursor signals it's
127
+ * `FocusRingPressable` (an input, so it hovers via its BORDER — the transparent
128
+ * edge reveals on hover, never a grey wash; the pointer cursor signals it's
128
129
  * editable), at the kit's control height with a transparent border — so
129
130
  * swapping to an input, or floating a dropdown above it, never shifts the
130
131
  * layout. Used by `InlineEditFrame`, and as the overlay trigger for the
@@ -141,15 +142,17 @@ export function InlineEditView(props: InlineEditViewProps) {
141
142
  }
142
143
  : undefined;
143
144
  return (
144
- <PressableHighlight
145
- focusRing
145
+ <FocusRingPressable
146
146
  ref={ref}
147
147
  disabled={disabled}
148
148
  onPress={handlePress}
149
149
  accessibilityRole="button"
150
150
  accessibilityLabel={accessibilityLabel}
151
151
  userSelect="none"
152
- style={[styles.view, active && styles.viewActive]}
152
+ // An inline-edit field is an input — it hovers via its BORDER, not a grey
153
+ // wash: the transparent edge reveals on hover (the kit's hover-border),
154
+ // then fills + rings when open. Suppressed while open (the ring leads).
155
+ style={(state) => [styles.view, CONTROL_TRANSITION, active && styles.viewActive, !active && state.hovered && !disabled && styles.viewHovered]}
153
156
  >
154
157
  {typeof display === "string" ? (
155
158
  <Text numberOfLines={1} style={[viewTextStyle, display ? null : styles.placeholder, struck ? styles.struck : null]}>
@@ -159,7 +162,7 @@ export function InlineEditView(props: InlineEditViewProps) {
159
162
  <View style={styles.viewNode}>{display}</View>
160
163
  )}
161
164
  {trailing != null ? trailing : null}
162
- </PressableHighlight>
165
+ </FocusRingPressable>
163
166
  );
164
167
  }
165
168
 
@@ -250,6 +253,11 @@ const styles = StyleSheet.create({
250
253
  borderColor: colors.border,
251
254
  boxShadow: FOCUS_RING,
252
255
  },
256
+ // Hover (not open): reveal the kit hover-border on the transparent edge — the
257
+ // input-family affordance, no grey wash.
258
+ viewHovered: {
259
+ borderColor: HOVER_BORDER,
260
+ },
253
261
  placeholder: { color: colors.zinc[400] },
254
262
  struck: { textDecorationLine: "line-through", color: colors.zinc[500] },
255
263
  viewNode: { flex: 1, minWidth: 0 },
package/src/select.tsx CHANGED
@@ -2,7 +2,7 @@ import { StyleSheet, View, type GestureResponderEvent, type StyleProp, type View
2
2
  import { useState, useCallback, useMemo, type ReactNode, type Ref } from "react";
3
3
  import { colors } from "./colors";
4
4
  import { FOCUS_RING, CONTROL_RADIUS, HOVER_BORDER, CONTROL_TRANSITION } from "./control_surface";
5
- import { PressableHighlight } from "./pressable_highlight";
5
+ import { FocusRingPressable } from "./focus_ring_pressable";
6
6
  import { Text } from "./text";
7
7
  import { Popover, PopoverTrigger, PopoverContent } from "./popover";
8
8
  import { OptionList } from "./option_list";
@@ -217,13 +217,13 @@ function SelectTrigger<T extends string>({
217
217
  const hasSelection = selectedItems.length > 0;
218
218
 
219
219
  return (
220
- // PressableHighlight owns the hover wash + keyboard focus ring (layered AROUND
221
- // the caller's `style`, so a borderless override — `borderColor:"transparent"`
222
- // for an in-cell Select keeps the wash and reads like its inline neighbours).
223
- // The hover-border sits BEFORE `style` so a transparent border suppresses it
224
- // (wash only), while a bordered Select darkens its edge.
225
- <PressableHighlight
226
- focusRing
220
+ // The wash-free control base (FOCUS_RING + a11y, NO grey wash) — a Select is an
221
+ // input, so its hover affordance is its BORDER, never a content wash. The
222
+ // hover-border sits AFTER `style` so it wins over the caller's resting border:
223
+ // a bordered Select darkens its edge, and a borderless in-cell Select
224
+ // (`borderColor:"transparent"`) REVEALS one both reading as "interactive",
225
+ // neither greying its content.
226
+ <FocusRingPressable
227
227
  ref={ref}
228
228
  testID={testID}
229
229
  // Without a role this Pressable renders as an unfocusable <div> on web — the
@@ -233,7 +233,7 @@ function SelectTrigger<T extends string>({
233
233
  accessibilityRole="button"
234
234
  accessibilityLabel={accessibilityLabel}
235
235
  accessibilityState={{ expanded: open, disabled }}
236
- style={(state) => [styles.pressable, CONTROL_TRANSITION, open && styles.opened, disabled && styles.disabled, state.hovered && !disabled && { borderColor: HOVER_BORDER }, style]}
236
+ style={(state) => [styles.pressable, CONTROL_TRANSITION, open && styles.opened, disabled && styles.disabled, style, state.hovered && !disabled && { borderColor: HOVER_BORDER }]}
237
237
  onPress={!disabled ? onPress : undefined}
238
238
  disabled={disabled}
239
239
  >
@@ -267,7 +267,7 @@ function SelectTrigger<T extends string>({
267
267
  {placeholder}
268
268
  </Text>
269
269
  )}
270
- </PressableHighlight>
270
+ </FocusRingPressable>
271
271
  );
272
272
  }
273
273