@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 +25 -12
- package/package.json +1 -1
- package/src/date_range_filter_field.tsx +3 -4
- package/src/focus_ring_pressable.tsx +25 -7
- package/src/inline_edit.tsx +15 -7
- package/src/select.tsx +10 -10
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
|
|
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
|
|
551
|
-
|
|
552
|
-
|
|
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
|
-
- **
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
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
|
@@ -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 {
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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
|
|
14
|
-
* every `PressableProps` (onPress, accessibilityRole/Label/State,
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
-
|
|
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
|
}
|
package/src/inline_edit.tsx
CHANGED
|
@@ -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 {
|
|
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
|
-
* `
|
|
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
|
-
<
|
|
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
|
-
|
|
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
|
-
</
|
|
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 {
|
|
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
|
-
//
|
|
221
|
-
//
|
|
222
|
-
//
|
|
223
|
-
//
|
|
224
|
-
// (
|
|
225
|
-
|
|
226
|
-
|
|
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 }
|
|
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
|
-
</
|
|
270
|
+
</FocusRingPressable>
|
|
271
271
|
);
|
|
272
272
|
}
|
|
273
273
|
|