@lotics/ui 11.7.4 → 11.8.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 +1 -1
- package/docs/catalog.md +16 -3
- package/docs/data_entry.md +25 -2
- package/docs/templates.md +41 -0
- package/package.json +1 -1
- package/src/date_field.tsx +26 -0
- package/src/date_picker.tsx +2 -0
- package/src/date_picker_value.test.ts +37 -0
- package/src/date_picker_value.ts +30 -0
- package/src/date_segments.test.ts +45 -0
- package/src/date_segments.ts +11 -0
- package/src/date_segments_field.tsx +58 -5
- package/src/inline_date_picker.tsx +201 -67
- package/src/inline_edit.tsx +43 -3
- package/src/inline_focus.test.ts +39 -0
- package/src/inline_focus.ts +40 -0
- package/src/interaction_modality.ts +38 -0
- package/src/locale.tsx +2 -2
- package/src/use_focus_ring.ts +3 -26
package/AGENTS.md
CHANGED
|
@@ -25,7 +25,7 @@ doc before building any screen, **never from memory**. Exact props are the shipp
|
|
|
25
25
|
| [docs/data_entry.md](./docs/data_entry.md) | Which editing pattern for which job — inline edit, fieldset forms, find-or-create (`Combobox`), line items, handoffs, phased records, billing, tags, dispositions, attachments, stage gates. |
|
|
26
26
|
| [docs/ai_patterns.md](./docs/ai_patterns.md) | AI proposes, the human decides — composer, live run feed (`AgentRun`), review-before-apply, findings, provenance, confidence; the UI half of the SDK's [ai doc](../app-sdk/docs/ai.md). |
|
|
27
27
|
| [docs/composition.md](./docs/composition.md) | The design-language contract — canvas + content column, heading altitude, banded cards, register vs inset rows, master-detail `Drawer`, view controls, color discipline, typography, whitespace. |
|
|
28
|
-
| [docs/templates.md](./docs/templates.md) | The map of `examples/tpl_*.tsx` — what shape each template solves and which to start from (copy + adapt, never import). |
|
|
28
|
+
| [docs/templates.md](./docs/templates.md) | The map of `examples/tpl_*.tsx` — what shape each template solves and which to start from (copy + adapt, never import) — plus the record-surface composition rules (pipeline order, static shape, decision budget). |
|
|
29
29
|
|
|
30
30
|
## Iron rules
|
|
31
31
|
|
package/docs/catalog.md
CHANGED
|
@@ -516,7 +516,9 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
516
516
|
### Dates & times
|
|
517
517
|
|
|
518
518
|
- **`date_picker`** — `DatePicker` (+ `DatePickerPanel`, `DatePickerLabels`): the field-form
|
|
519
|
-
date (and datetime) picker.
|
|
519
|
+
date (and datetime) picker. The trigger is the segmented, locale-ordered `DateField`
|
|
520
|
+
(typed digits auto-advance; a typed separator advances a single-digit day/month) plus the
|
|
521
|
+
calendar popover.
|
|
520
522
|
- **`date_calendar`** — `Calendar`: the bare month grid — `mode="single"` or `"range"`
|
|
521
523
|
(`{start,end}` — two months side by side on desktop), month/year pickers + arrows,
|
|
522
524
|
localized weekday/month names via BCP-47 `locale`, `firstDayOfWeek` (default Monday),
|
|
@@ -534,11 +536,15 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
534
536
|
`INLINE_CONTROL_HEIGHT` (40) + `inlineValueTextStyle`: the engine custom inline editors
|
|
535
537
|
join through — a view ⇄ edit toggle, a draft buffer, async `onSave` with the spinner
|
|
536
538
|
INSIDE the control and inline error, commit on blur (Enter saves, Escape reverts) or
|
|
537
|
-
`controls="buttons"`;
|
|
539
|
+
`controls="buttons"`; KEYBOARD focus on the closed view opens edit mode with the input
|
|
540
|
+
focused (type → Tab → type — see the data-entry keyboard contract), pointer focus never
|
|
541
|
+
does; `background="tint" | "transparent"` (the zinc-50 editability chip vs
|
|
538
542
|
flat for dense uniformly-editable surfaces).
|
|
539
543
|
- **`inline_text_input`**, **`inline_number_input`** (`format` for currency/units),
|
|
540
544
|
**`inline_select`**, **`inline_member_select`**, **`inline_date_picker`**
|
|
541
|
-
(`format="datetime"`, `optionalTime
|
|
545
|
+
(`format="datetime"`, `optionalTime`; keyboard focus opens the TYPED segmented `DateField`
|
|
546
|
+
— locale field order, separator advances, Alt+ArrowDown floats the calendar; click still
|
|
547
|
+
opens the calendar popover), **`inline_time_picker`** — the Inline\* per-field
|
|
542
548
|
editors; `InlineSelect`/`InlineMemberSelect` render the resting value like its option —
|
|
543
549
|
`renderOptionContent` by default, `renderSelected` to override — a chip/badge at rest, not
|
|
544
550
|
just text.
|
|
@@ -925,5 +931,12 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
925
931
|
input.
|
|
926
932
|
- **`use_focus_ring`** — `useFocusRing`: keyboard-aware focus state for painting a control's
|
|
927
933
|
own ring.
|
|
934
|
+
- **`interaction_modality`** — the document-level keyboard-vs-pointer tracker behind
|
|
935
|
+
`useFocusRing` and the inline editors' focus-opens-edit: `getInteractionModality()` read
|
|
936
|
+
inside a focus handler tells a Tab-focus from a click-focus (the `:focus-visible` signal).
|
|
937
|
+
- **`inline_focus`** — `shouldOpenOnFocus` + `FOCUS_OPEN_SUPPRESS_MS`: the inline
|
|
938
|
+
keyboard-entry gate — keyboard focus opens a closed editor, pointer focus never does, and
|
|
939
|
+
a programmatic focus restore inside the suppression window doesn't re-open the editor it
|
|
940
|
+
just closed.
|
|
928
941
|
- **`json_panel`** — `JsonPanel` (`{title, value}`) + `stringifyData`: a labeled monospace
|
|
929
942
|
panel for raw/JSON payloads (debug & developer surfaces).
|
package/docs/data_entry.md
CHANGED
|
@@ -29,8 +29,31 @@ in [the templates](./templates.md) (`examples/tpl_*.tsx`).
|
|
|
29
29
|
When the whole record is editable (a detail/record screen, dense settings), don't wrap it in a
|
|
30
30
|
form mode or a preview↔edit card — make each VALUE inline-editable: it reads as a value on a quiet
|
|
31
31
|
chip, hover reveals the input-family border (no extra grey wash, no pencil icon that shifts
|
|
32
|
-
layout), click swaps the input in **at the same height** (zero reflow, the whole
|
|
33
|
-
commits on blur (Enter saves, Escape reverts) or via `controls="buttons"` (✓
|
|
32
|
+
layout), click OR keyboard focus swaps the input in **at the same height** (zero reflow, the whole
|
|
33
|
+
point), and it commits on blur (Enter saves, Escape reverts) or via `controls="buttons"` (✓ / ✕).
|
|
34
|
+
|
|
35
|
+
### Keyboard entry — type → Tab → type
|
|
36
|
+
|
|
37
|
+
Bulk entry never needs the mouse. KEYBOARD focus (Tab / Shift+Tab) landing on a closed
|
|
38
|
+
input-swap editor — `InlineTextInput`, `InlineNumberInput`, `InlineTimePicker`,
|
|
39
|
+
`InlineDatePicker` — opens edit mode immediately with the input focused; commit-on-blur then
|
|
40
|
+
makes Tab itself the commit, so the chain is type → Tab → type with the next editor already
|
|
41
|
+
open. Pointer focus never auto-opens (mousedown records "pointer" modality before focus fires
|
|
42
|
+
— `interaction_modality.ts`), so the click path is exactly what it always was. When an editor
|
|
43
|
+
closes while its input still holds focus (Enter/Escape), it returns focus to its resting view
|
|
44
|
+
— never `<body>` — so the next Tab continues from the field.
|
|
45
|
+
|
|
46
|
+
**Typed dates.** `InlineDatePicker`'s keyboard mode is the kit's segmented `DateField`: type
|
|
47
|
+
the date in the locale's own field order (dd/MM/yyyy where the locale says so — pass
|
|
48
|
+
`locale`), digits auto-advance, and a typed separator (`/` `.` `-`) advances a single-digit
|
|
49
|
+
day/month; Enter or blur commits, Escape reverts, Alt+ArrowDown floats the calendar. The
|
|
50
|
+
calendar popover stays the pointer path (click the resting value, as ever) — it is no longer
|
|
51
|
+
the only path. A PARTIAL entry never commits and never clears the stored value: the field
|
|
52
|
+
stays in edit mode showing the `datePicker.invalidDate` inline error until fixed or Escaped.
|
|
53
|
+
|
|
54
|
+
The POPOVER editors (`InlineSelect` / `InlineMemberSelect` / `InlineTagSelect`) deliberately
|
|
55
|
+
do NOT auto-open on focus — a popup opening on Tab arrival traps traversal; per the WAI-ARIA
|
|
56
|
+
combobox contract they open on Enter/Space from the keyboard.
|
|
34
57
|
|
|
35
58
|
One per type:
|
|
36
59
|
|
package/docs/templates.md
CHANGED
|
@@ -48,6 +48,47 @@ the package index is [../AGENTS.md](../AGENTS.md).
|
|
|
48
48
|
| AI ranks answers — look-up-and-explain | `tpl_lookup` |
|
|
49
49
|
| AI over a record's documents (extract / cross-check / generate) | `tpl_documents` |
|
|
50
50
|
|
|
51
|
+
## Composition rules — how a record surface is assembled
|
|
52
|
+
|
|
53
|
+
Laws for any screen where a record's data is edited and a primary action produces its output
|
|
54
|
+
(a document, a message, an export) — `tpl_record` is the worked example.
|
|
55
|
+
|
|
56
|
+
1. **Pipeline order.** Data sections run top→bottom in the order work flows (intake/scan near
|
|
57
|
+
the top when AI-driven); the OUTPUT section — the primary action plus everything that
|
|
58
|
+
configures it — comes LAST. The primary action is the output step of the pipeline, never
|
|
59
|
+
header chrome.
|
|
60
|
+
2. **Static shape.** The page never changes shape from output-selection state. Conditional
|
|
61
|
+
rendering is DATA-driven only (a control appears because two stored values differ), never
|
|
62
|
+
selection-driven. Relevance is an advisory `Callout` with a jump-to-section action — warn,
|
|
63
|
+
never hide, never hard-block.
|
|
64
|
+
3. **Colocation & ownership.** Every output option (selections, print/export switches) lives in
|
|
65
|
+
the output section beside its action; a per-entity selection carries its owner's name in its
|
|
66
|
+
heading. Persisted record DATA never lives there — data belongs in data sections. Misfiled
|
|
67
|
+
data announces itself as callout special-cases; the exception disappearing is the sign the
|
|
68
|
+
model is right.
|
|
69
|
+
4. **Resolved state.** Controls show the EFFECTIVE state, never raw storage: a derived/suggested
|
|
70
|
+
selection renders checked; the first manual change persists the full explicit set; a
|
|
71
|
+
reset-to-auto affordance appears only in explicit mode. What's shown = what happens.
|
|
72
|
+
5. **Decision budget.** Every user decision earns its place — derive it, default it, or
|
|
73
|
+
conditionally render it; ask only the underivable. Suggestions are one-tap-save chips; a
|
|
74
|
+
placeholder states the honest effective fallback (what actually happens when empty), never
|
|
75
|
+
an example that lies.
|
|
76
|
+
6. **Header = identity + provenance.** Id, the key identifier, created-at. No metrics or badges
|
|
77
|
+
duplicating data a section already owns.
|
|
78
|
+
7. **Destructive placement.** Solid `danger`, bottom-left after the entity's fields, ONE
|
|
79
|
+
convention page-wide, confirmed by `Alert`. Never in a heading row, never one-tap.
|
|
80
|
+
8. **Create-then-refine.** An add is one click creating a draft edited in place — drafts render
|
|
81
|
+
as empty inline editors with placeholders, never as fake data. No type-a-name-then-click
|
|
82
|
+
forms.
|
|
83
|
+
9. **Optimistic feedback.** Every edit echoes locally, saves in the background, reconciles on
|
|
84
|
+
refetch, reverts + surfaces on error. Silent success, loud failure — no persistent "saved"
|
|
85
|
+
chrome.
|
|
86
|
+
10. **Chrome discipline.** Kit spacing scale only; ONE divider rule (between sibling blocks,
|
|
87
|
+
never doubled with section hairlines, none trailing); no state-echo labels or filler
|
|
88
|
+
captions — explanations live in `info` popovers and empty states (→ [composition grammar
|
|
89
|
+
§Microcopy](./composition.md)). A "why" question about a control is answered by tracing
|
|
90
|
+
its consequences, not by swapping the control.
|
|
91
|
+
|
|
51
92
|
## Analytics
|
|
52
93
|
|
|
53
94
|
Read-mostly screens that answer a question about a population, then open doors into the
|
package/package.json
CHANGED
package/src/date_field.tsx
CHANGED
|
@@ -25,6 +25,15 @@ export interface DateFieldProps {
|
|
|
25
25
|
onActivate?: () => void;
|
|
26
26
|
/** Fires once focus leaves the whole field. */
|
|
27
27
|
onBlur?: () => void;
|
|
28
|
+
/** Focus the first segment on mount (an inline editor entered by keyboard). */
|
|
29
|
+
autoFocus?: boolean;
|
|
30
|
+
/** Escape pressed in a segment — an inline editor cancels its session. */
|
|
31
|
+
onEscape?: () => void;
|
|
32
|
+
/** Alt+ArrowDown pressed in a segment — open the calendar popover. */
|
|
33
|
+
onOpenPicker?: () => void;
|
|
34
|
+
/** Reports whether ANY part holds a partial entry (typed but not yet a
|
|
35
|
+
* complete valid date) — an inline editor blocks its commit on it. */
|
|
36
|
+
onIncompleteChange?: (incomplete: boolean) => void;
|
|
28
37
|
/** Rendered inside the border, after the segments (the calendar button). */
|
|
29
38
|
rightSlot?: React.ReactNode;
|
|
30
39
|
/** Ref to the frame, used to anchor the popover. */
|
|
@@ -51,12 +60,17 @@ export function DateField(props: DateFieldProps) {
|
|
|
51
60
|
testID,
|
|
52
61
|
onActivate,
|
|
53
62
|
onBlur,
|
|
63
|
+
autoFocus,
|
|
64
|
+
onEscape,
|
|
65
|
+
onOpenPicker,
|
|
66
|
+
onIncompleteChange,
|
|
54
67
|
rightSlot,
|
|
55
68
|
triggerRef,
|
|
56
69
|
style,
|
|
57
70
|
} = props;
|
|
58
71
|
|
|
59
72
|
const [focused, setFocused] = useState(false);
|
|
73
|
+
const incompleteParts = useRef<boolean[]>([]);
|
|
60
74
|
const { hovered, hoverProps } = useHover();
|
|
61
75
|
const blurTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
62
76
|
|
|
@@ -89,6 +103,14 @@ export function DateField(props: DateFieldProps) {
|
|
|
89
103
|
if (!disabled) onActivate?.();
|
|
90
104
|
}, [disabled, onActivate]);
|
|
91
105
|
|
|
106
|
+
const handleIncomplete = useCallback(
|
|
107
|
+
(index: number, incomplete: boolean) => {
|
|
108
|
+
incompleteParts.current[index] = incomplete;
|
|
109
|
+
onIncompleteChange?.(incompleteParts.current.some(Boolean));
|
|
110
|
+
},
|
|
111
|
+
[onIncompleteChange],
|
|
112
|
+
);
|
|
113
|
+
|
|
92
114
|
const config = useMemo(() => dateSegmentsConfig(locale ?? "en-US", hasTime), [locale, hasTime]);
|
|
93
115
|
|
|
94
116
|
const isEmpty = parts.every((part) => !part);
|
|
@@ -122,9 +144,13 @@ export function DateField(props: DateFieldProps) {
|
|
|
122
144
|
config={config}
|
|
123
145
|
segmentLabels={segmentLabels}
|
|
124
146
|
disabled={disabled}
|
|
147
|
+
autoFocus={autoFocus && index === 0}
|
|
125
148
|
accessibilityLabel={partLabels?.[index]}
|
|
126
149
|
onFocus={handleSegmentFocus}
|
|
127
150
|
onBlur={handleSegmentBlur}
|
|
151
|
+
onEscape={onEscape}
|
|
152
|
+
onOpenPicker={onOpenPicker}
|
|
153
|
+
onIncompleteChange={onIncompleteChange ? (inc) => handleIncomplete(index, inc) : undefined}
|
|
128
154
|
/>
|
|
129
155
|
</React.Fragment>
|
|
130
156
|
))}
|
package/src/date_picker.tsx
CHANGED
|
@@ -50,6 +50,8 @@ export interface DatePickerLabels extends SegmentLabels {
|
|
|
50
50
|
endTime: string;
|
|
51
51
|
/** Footer action: commit and close the popover. */
|
|
52
52
|
done: string;
|
|
53
|
+
/** Inline error when a typed date is left incomplete/invalid (never committed). */
|
|
54
|
+
invalidDate: string;
|
|
53
55
|
/** Quick action: add a time to a date-only value (`optionalTime`). */
|
|
54
56
|
addTime: string;
|
|
55
57
|
/** Accessible name + tooltip: drop the time, keeping the date (`optionalTime`). */
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
parsePart,
|
|
4
4
|
partsToIso,
|
|
5
5
|
parseText,
|
|
6
|
+
resolveDateCommit,
|
|
6
7
|
timeText,
|
|
7
8
|
splitTime,
|
|
8
9
|
isoToDate,
|
|
@@ -165,3 +166,39 @@ describe("parseTimeString", () => {
|
|
|
165
166
|
expect(parseTimeString("")).toBeNull();
|
|
166
167
|
});
|
|
167
168
|
});
|
|
169
|
+
|
|
170
|
+
describe("resolveDateCommit — the inline editor's commit contract", () => {
|
|
171
|
+
it("a typed complete date commits the value", () => {
|
|
172
|
+
expect(
|
|
173
|
+
resolveDateCommit({ draft: "2026-03-15", value: "2026-01-01", incomplete: false, clearable: false }),
|
|
174
|
+
).toEqual({ kind: "save", value: "2026-03-15" });
|
|
175
|
+
// …including onto an empty field.
|
|
176
|
+
expect(
|
|
177
|
+
resolveDateCommit({ draft: "2026-03-15", value: null, incomplete: false, clearable: false }),
|
|
178
|
+
).toEqual({ kind: "save", value: "2026-03-15" });
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("a partial entry never commits and never clears the stored value", () => {
|
|
182
|
+
expect(
|
|
183
|
+
resolveDateCommit({ draft: "2026-01-01", value: "2026-01-01", incomplete: true, clearable: true }),
|
|
184
|
+
).toEqual({ kind: "invalid" });
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("an unchanged draft exits without a write (a touched-but-equal date is not a diff)", () => {
|
|
188
|
+
expect(
|
|
189
|
+
resolveDateCommit({ draft: "2026-01-01", value: "2026-01-01", incomplete: false, clearable: true }),
|
|
190
|
+
).toEqual({ kind: "none" });
|
|
191
|
+
expect(resolveDateCommit({ draft: "", value: null, incomplete: false, clearable: true })).toEqual({
|
|
192
|
+
kind: "none",
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("an emptied entry clears only when the field is clearable", () => {
|
|
197
|
+
expect(
|
|
198
|
+
resolveDateCommit({ draft: "", value: "2026-01-01", incomplete: false, clearable: true }),
|
|
199
|
+
).toEqual({ kind: "clear" });
|
|
200
|
+
expect(
|
|
201
|
+
resolveDateCommit({ draft: "", value: "2026-01-01", incomplete: false, clearable: false }),
|
|
202
|
+
).toEqual({ kind: "none" });
|
|
203
|
+
});
|
|
204
|
+
});
|
package/src/date_picker_value.ts
CHANGED
|
@@ -123,6 +123,36 @@ export function dropTime(iso: string): string {
|
|
|
123
123
|
return p ? partsToIso(p, false) : "";
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
+
/** What committing an inline date edit session should do. */
|
|
127
|
+
export type InlineDateCommit =
|
|
128
|
+
| { kind: "save"; value: string }
|
|
129
|
+
| { kind: "clear" }
|
|
130
|
+
| { kind: "none" }
|
|
131
|
+
| { kind: "invalid" };
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Resolve an inline date editor's commit (blur / Enter / popover close).
|
|
135
|
+
* `draft` is the last COMPLETE value the session produced ("" when emptied);
|
|
136
|
+
* `incomplete` is true while the segments hold a partial entry (a typed day
|
|
137
|
+
* with no year). Partial input never commits and never wipes the stored value —
|
|
138
|
+
* the caller shows the error affordance and keeps the entry ("invalid"). An
|
|
139
|
+
* emptied draft clears only when the field is clearable; otherwise it reverts
|
|
140
|
+
* silently, matching the calendar's own Clear on a required date.
|
|
141
|
+
*/
|
|
142
|
+
export function resolveDateCommit(opts: {
|
|
143
|
+
draft: string;
|
|
144
|
+
value: string | null;
|
|
145
|
+
incomplete: boolean;
|
|
146
|
+
clearable: boolean;
|
|
147
|
+
}): InlineDateCommit {
|
|
148
|
+
const { draft, value, incomplete, clearable } = opts;
|
|
149
|
+
if (incomplete) return { kind: "invalid" };
|
|
150
|
+
const stored = value ?? "";
|
|
151
|
+
if (draft === stored) return { kind: "none" };
|
|
152
|
+
if (draft === "") return clearable && stored ? { kind: "clear" } : { kind: "none" };
|
|
153
|
+
return { kind: "save", value: draft };
|
|
154
|
+
}
|
|
155
|
+
|
|
126
156
|
const TIME_RE = /^(\d{1,2}):(\d{2})$/;
|
|
127
157
|
|
|
128
158
|
/**
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
from12h,
|
|
8
8
|
typeDigit,
|
|
9
9
|
incrementSegment,
|
|
10
|
+
separatorAdvances,
|
|
10
11
|
setHourField,
|
|
11
12
|
withDayPeriod,
|
|
12
13
|
displayValue,
|
|
@@ -101,6 +102,50 @@ describe("typeDigit", () => {
|
|
|
101
102
|
});
|
|
102
103
|
});
|
|
103
104
|
|
|
105
|
+
describe("typed date entry — the inline keyboard path", () => {
|
|
106
|
+
// Simulate the segment engine's typing pipeline: each keystroke flows through
|
|
107
|
+
// typeDigit in the locale's own field order; the buffer then resolves through
|
|
108
|
+
// segmentsToValue. This is the outcome contract behind "type 15/03/2026 into
|
|
109
|
+
// an inline date editor and it commits".
|
|
110
|
+
it("a typed dd/MM/yyyy date composes the committed ISO value (vi-VN order)", () => {
|
|
111
|
+
expect(fieldOrder(getDateLayout("vi-VN", false))).toEqual(["day", "month", "year"]);
|
|
112
|
+
const day = typeDigit("day", typeDigit("day", "", "1", false).text, "5", false);
|
|
113
|
+
expect(day).toEqual({ text: "15", value: 15, complete: true });
|
|
114
|
+
const month = typeDigit("month", typeDigit("month", "", "0", false).text, "3", false);
|
|
115
|
+
expect(month).toEqual({ text: "03", value: 3, complete: true });
|
|
116
|
+
let yearText = "";
|
|
117
|
+
for (const digit of ["2", "0", "2", "6"]) yearText = typeDigit("year", yearText, digit, false).text;
|
|
118
|
+
const buffer = { year: Number(yearText), month: month.value, day: day.value, hour: null, minute: null };
|
|
119
|
+
expect(segmentsToValue(buffer, false)).toBe("2026-03-15");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("a single-digit d/M/yyyy entry advances on the separator and still commits", () => {
|
|
123
|
+
// "1/1/2026": neither "1" can auto-advance (a second digit could still
|
|
124
|
+
// fit — "12", "11") — the typed "/" is the explicit advance.
|
|
125
|
+
const day = typeDigit("day", "", "1", false);
|
|
126
|
+
expect(day.complete).toBe(false);
|
|
127
|
+
expect(separatorAdvances("/", day.text !== "")).toBe(true);
|
|
128
|
+
const month = typeDigit("month", "", "1", false);
|
|
129
|
+
expect(month.complete).toBe(false);
|
|
130
|
+
expect(separatorAdvances("/", month.text !== "")).toBe(true);
|
|
131
|
+
const buffer = { year: 2026, month: month.value, day: day.value, hour: null, minute: null };
|
|
132
|
+
expect(segmentsToValue(buffer, false)).toBe("2026-01-01");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("separators advance only off a segment that has content", () => {
|
|
136
|
+
for (const key of ["/", ".", "-", ",", ":", " "]) {
|
|
137
|
+
expect(separatorAdvances(key, true)).toBe(true);
|
|
138
|
+
expect(separatorAdvances(key, false)).toBe(false);
|
|
139
|
+
}
|
|
140
|
+
expect(separatorAdvances("a", true)).toBe(false);
|
|
141
|
+
expect(separatorAdvances("ArrowDown", true)).toBe(false);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("an impossible typed date never composes a value", () => {
|
|
145
|
+
expect(segmentsToValue({ year: 2026, month: 2, day: 30, hour: null, minute: null }, false)).toBeNull();
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
104
149
|
describe("incrementSegment", () => {
|
|
105
150
|
it("wraps within range", () => {
|
|
106
151
|
expect(incrementSegment("month", 12, 1, false)).toBe(1);
|
package/src/date_segments.ts
CHANGED
|
@@ -230,6 +230,17 @@ export function typeDigit(
|
|
|
230
230
|
return { text, value, complete };
|
|
231
231
|
}
|
|
232
232
|
|
|
233
|
+
/**
|
|
234
|
+
* A typed separator ("15/3", "15.3.2026") advances to the next segment — the
|
|
235
|
+
* forgiveness that makes single-digit entry work: "1" alone can't auto-advance
|
|
236
|
+
* (it might be "12"), so the separator is the typist's explicit "done here".
|
|
237
|
+
* Only advances off a segment that HAS content (committed or in-progress);
|
|
238
|
+
* a leading separator is a no-op, matching native date inputs.
|
|
239
|
+
*/
|
|
240
|
+
export function separatorAdvances(key: string, segmentHasContent: boolean): boolean {
|
|
241
|
+
return segmentHasContent && (key === "/" || key === "." || key === "-" || key === "," || key === " " || key === ":");
|
|
242
|
+
}
|
|
243
|
+
|
|
233
244
|
/** Step a numeric segment up/down, wrapping within its range. */
|
|
234
245
|
export function incrementSegment(
|
|
235
246
|
type: SegmentType,
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
fieldOrder,
|
|
22
22
|
incrementSegment,
|
|
23
23
|
placeholderFor,
|
|
24
|
+
separatorAdvances,
|
|
24
25
|
setHourField,
|
|
25
26
|
to12h,
|
|
26
27
|
typeDigit,
|
|
@@ -44,6 +45,16 @@ export interface DateSegmentsProps {
|
|
|
44
45
|
onFocus?: () => void;
|
|
45
46
|
/** Fires when a segment in this group loses focus. */
|
|
46
47
|
onBlur?: () => void;
|
|
48
|
+
/** Escape pressed in a segment (web) — an inline editor cancels its session. */
|
|
49
|
+
onEscape?: () => void;
|
|
50
|
+
/** Alt+ArrowDown pressed in a segment (web) — open the calendar popover
|
|
51
|
+
* (the segment-field convention; plain ArrowDown steps the spinbutton). */
|
|
52
|
+
onOpenPicker?: () => void;
|
|
53
|
+
/** Reports whether the buffer holds a PARTIAL entry — non-empty but not yet a
|
|
54
|
+
* complete valid date (a typed day with no year). Complete values emit
|
|
55
|
+
* through `onChange`; an emptied buffer emits `""`; between the two, this is
|
|
56
|
+
* the only signal — an inline editor blocks its commit on it. */
|
|
57
|
+
onIncompleteChange?: (incomplete: boolean) => void;
|
|
47
58
|
style?: StyleProp<ViewStyle>;
|
|
48
59
|
}
|
|
49
60
|
|
|
@@ -127,6 +138,9 @@ export function DateSegments(props: DateSegmentsProps) {
|
|
|
127
138
|
accessibilityLabel,
|
|
128
139
|
onFocus,
|
|
129
140
|
onBlur,
|
|
141
|
+
onEscape,
|
|
142
|
+
onOpenPicker,
|
|
143
|
+
onIncompleteChange,
|
|
130
144
|
style,
|
|
131
145
|
} = props;
|
|
132
146
|
|
|
@@ -147,27 +161,43 @@ export function DateSegments(props: DateSegmentsProps) {
|
|
|
147
161
|
|
|
148
162
|
const lastEmitted = useRef<string>(value);
|
|
149
163
|
const fieldRefs = useRef<Partial<Record<SegmentType, RNTextInput | null>>>({});
|
|
164
|
+
const lastIncomplete = useRef(false);
|
|
165
|
+
|
|
166
|
+
const reportIncomplete = useCallback(
|
|
167
|
+
(incomplete: boolean) => {
|
|
168
|
+
if (incomplete === lastIncomplete.current) return;
|
|
169
|
+
lastIncomplete.current = incomplete;
|
|
170
|
+
onIncompleteChange?.(incomplete);
|
|
171
|
+
},
|
|
172
|
+
[onIncompleteChange],
|
|
173
|
+
);
|
|
150
174
|
|
|
151
175
|
useEffect(() => {
|
|
152
176
|
if (value !== lastEmitted.current) {
|
|
153
177
|
lastEmitted.current = value;
|
|
154
178
|
setBuffer(config.toBuffer(value));
|
|
155
179
|
setActiveText("");
|
|
180
|
+
// An outside value (calendar pick, parent reset) is never partial.
|
|
181
|
+
reportIncomplete(false);
|
|
156
182
|
}
|
|
157
|
-
}, [value, config]);
|
|
183
|
+
}, [value, config, reportIncomplete]);
|
|
158
184
|
|
|
159
185
|
const commit = useCallback(
|
|
160
186
|
(next: SegmentBuffer) => {
|
|
161
187
|
const out = config.toValue(next);
|
|
162
188
|
if (out !== null) {
|
|
163
189
|
lastEmitted.current = out;
|
|
190
|
+
reportIncomplete(false);
|
|
164
191
|
onChange(out);
|
|
165
192
|
} else if (config.isEmpty(next)) {
|
|
166
193
|
lastEmitted.current = "";
|
|
194
|
+
reportIncomplete(false);
|
|
167
195
|
onChange("");
|
|
196
|
+
} else {
|
|
197
|
+
reportIncomplete(true);
|
|
168
198
|
}
|
|
169
199
|
},
|
|
170
|
-
[config, onChange],
|
|
200
|
+
[config, onChange, reportIncomplete],
|
|
171
201
|
);
|
|
172
202
|
|
|
173
203
|
const focusField = useCallback((type: SegmentType | undefined) => {
|
|
@@ -267,12 +297,26 @@ export function DateSegments(props: DateSegmentsProps) {
|
|
|
267
297
|
|
|
268
298
|
const handleKeyPress = useCallback(
|
|
269
299
|
(type: SegmentType, e: NativeSyntheticEvent<TextInputKeyPressEventData>) => {
|
|
270
|
-
|
|
300
|
+
// On web the nativeEvent is the DOM KeyboardEvent — RN's type only carries
|
|
301
|
+
// `key`, so widen minimally at this boundary for the modifier read.
|
|
302
|
+
const native = e.nativeEvent as TextInputKeyPressEventData & { altKey?: boolean };
|
|
303
|
+
const key = native.key;
|
|
271
304
|
if (/^[0-9]$/.test(key)) {
|
|
272
305
|
e.preventDefault();
|
|
273
306
|
handleDigit(type, key);
|
|
274
307
|
return;
|
|
275
308
|
}
|
|
309
|
+
// A typed separator ("15/3/2026", "14:30") advances past a segment that
|
|
310
|
+
// has content — the explicit "done" for a single-digit day/month that
|
|
311
|
+
// can't auto-advance on its own.
|
|
312
|
+
const hasContent =
|
|
313
|
+
(activeType === type && activeText !== "") || fieldNumeric(type, buffer, layout.hour12) != null;
|
|
314
|
+
if (separatorAdvances(key, hasContent)) {
|
|
315
|
+
e.preventDefault();
|
|
316
|
+
setActiveText("");
|
|
317
|
+
focusNext(type);
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
276
320
|
switch (key) {
|
|
277
321
|
case "ArrowUp":
|
|
278
322
|
e.preventDefault();
|
|
@@ -280,7 +324,16 @@ export function DateSegments(props: DateSegmentsProps) {
|
|
|
280
324
|
break;
|
|
281
325
|
case "ArrowDown":
|
|
282
326
|
e.preventDefault();
|
|
283
|
-
|
|
327
|
+
// Alt+ArrowDown opens the calendar (the segmented-field convention);
|
|
328
|
+
// plain ArrowDown keeps the spinbutton step.
|
|
329
|
+
if (native.altKey && onOpenPicker) onOpenPicker();
|
|
330
|
+
else handleStep(type, -1);
|
|
331
|
+
break;
|
|
332
|
+
case "Escape":
|
|
333
|
+
if (onEscape) {
|
|
334
|
+
e.preventDefault();
|
|
335
|
+
onEscape();
|
|
336
|
+
}
|
|
284
337
|
break;
|
|
285
338
|
case "ArrowLeft":
|
|
286
339
|
e.preventDefault();
|
|
@@ -307,7 +360,7 @@ export function DateSegments(props: DateSegmentsProps) {
|
|
|
307
360
|
}
|
|
308
361
|
}
|
|
309
362
|
},
|
|
310
|
-
[handleDigit, handleStep, handleBackspace, focusPrev, focusNext, setHalfDay],
|
|
363
|
+
[activeType, activeText, buffer, layout.hour12, handleDigit, handleStep, handleBackspace, focusPrev, focusNext, setHalfDay, onEscape, onOpenPicker],
|
|
311
364
|
);
|
|
312
365
|
|
|
313
366
|
const handleChangeText = useCallback(
|
|
@@ -1,24 +1,28 @@
|
|
|
1
|
-
import { useCallback, useRef, useState } from "react";
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
2
|
import { View } from "react-native";
|
|
3
3
|
import { Text } from "./text";
|
|
4
4
|
import { Icon } from "./icon";
|
|
5
5
|
import { colors } from "./colors";
|
|
6
6
|
import { Popover, PopoverTrigger, PopoverContent } from "./popover";
|
|
7
7
|
import { DatePickerPanel } from "./date_picker";
|
|
8
|
-
import {
|
|
8
|
+
import { DateField } from "./date_field";
|
|
9
|
+
import { isoHasTime, resolveDateCommit } from "./date_picker_value";
|
|
9
10
|
import { formatDate } from "./format_date";
|
|
10
11
|
import { ActivityIndicator } from "./activity_indicator";
|
|
11
12
|
import { type InlineEditBackground, InlineEditView } from "./inline_edit";
|
|
12
13
|
import { useLoticsLocale } from "./locale";
|
|
14
|
+
import { getInteractionModality } from "./interaction_modality";
|
|
15
|
+
import { shouldOpenOnFocus } from "./inline_focus";
|
|
13
16
|
|
|
14
17
|
export interface InlineDatePickerProps {
|
|
15
18
|
/** ISO date (`2026-05-22`) or datetime (`2026-05-22T14:30`). */
|
|
16
19
|
value: string | null;
|
|
17
20
|
onSave: (next: string) => void | Promise<void>;
|
|
18
21
|
/** Unset the date to empty. Provide it to make the value clearable: the
|
|
19
|
-
* calendar's own "Clear" button
|
|
20
|
-
* (without `onClear` an empty
|
|
21
|
-
* emptied). Kept separate from `onSave` (whose next
|
|
22
|
+
* calendar's own "Clear" button and an emptied typed entry then unset the
|
|
23
|
+
* field through this callback (without `onClear` an empty result is ignored —
|
|
24
|
+
* a required date can't be emptied). Kept separate from `onSave` (whose next
|
|
25
|
+
* is a non-empty string). */
|
|
22
26
|
onClear?: () => void | Promise<void>;
|
|
23
27
|
/** "date" (default) or "datetime". */
|
|
24
28
|
format?: "date" | "datetime";
|
|
@@ -26,7 +30,7 @@ export interface InlineDatePickerProps {
|
|
|
26
30
|
* until a time is added). Ignored when `format` is "datetime" (time always on). */
|
|
27
31
|
optionalTime?: boolean;
|
|
28
32
|
placeholder?: string;
|
|
29
|
-
/** BCP-47 locale for the calendar and the
|
|
33
|
+
/** BCP-47 locale for the calendar, the displayed date, and the typed segment order. */
|
|
30
34
|
locale?: string;
|
|
31
35
|
disabled?: boolean;
|
|
32
36
|
accessibilityLabel?: string;
|
|
@@ -35,105 +39,235 @@ export interface InlineDatePickerProps {
|
|
|
35
39
|
}
|
|
36
40
|
|
|
37
41
|
/**
|
|
38
|
-
* An inline-editable date / datetime
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
* dismissing without a change reverts.
|
|
42
|
+
* An inline-editable date / datetime with TWO entry paths:
|
|
43
|
+
*
|
|
44
|
+
* - **Pointer** (unchanged): clicking the resting value floats the calendar
|
|
45
|
+
* (`DatePickerPanel`) in a popover anchored to the view; the selection
|
|
46
|
+
* commits when the panel closes, dismissing without a change reverts.
|
|
47
|
+
* - **Keyboard** (typed): Tab/keyboard focus swaps the value for the kit's
|
|
48
|
+
* segmented `DateField` with the first segment focused — type the date in the
|
|
49
|
+
* locale's own order (dd/MM/yyyy where the locale says so; separators advance
|
|
50
|
+
* a single-digit day/month), Enter or blur commits, Escape reverts, and
|
|
51
|
+
* Alt+ArrowDown still opens the calendar. A partial entry never commits and
|
|
52
|
+
* never clears the stored value — the error affordance shows instead.
|
|
53
|
+
*
|
|
54
|
+
* The row never changes height in either mode.
|
|
43
55
|
*/
|
|
44
56
|
export function InlineDatePicker(props: InlineDatePickerProps) {
|
|
45
57
|
const { value, onSave, onClear, format = "date", optionalTime, placeholder, locale, disabled, accessibilityLabel , background } = props;
|
|
46
|
-
const
|
|
58
|
+
const locales = useLoticsLocale();
|
|
59
|
+
const inlineLabels = locales.inline;
|
|
60
|
+
const dateLabels = locales.datePicker;
|
|
61
|
+
|
|
62
|
+
const [editing, setEditing] = useState(false);
|
|
47
63
|
const [open, setOpen] = useState(false);
|
|
48
|
-
|
|
64
|
+
// Canonical session draft ("" when empty) — written by the segments AND the
|
|
65
|
+
// calendar panel, so both paths commit through the same resolution.
|
|
66
|
+
const [draft, setDraft] = useState<string>(value ?? "");
|
|
49
67
|
const [saving, setSaving] = useState(false);
|
|
50
68
|
const [error, setError] = useState<string | null>(null);
|
|
51
|
-
|
|
52
|
-
// the close
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
69
|
+
|
|
70
|
+
// The latest draft, read at commit time (state can be stale in the close
|
|
71
|
+
// handler's closure).
|
|
72
|
+
const draftRef = useRef<string>(value ?? "");
|
|
73
|
+
// True while the segments hold a partial typed entry (no complete value).
|
|
74
|
+
const incompleteRef = useRef(false);
|
|
75
|
+
// A commit in flight — a trailing blur/close must not commit twice.
|
|
76
|
+
const committing = useRef(false);
|
|
77
|
+
// Timestamp of the last programmatic focus restore (ours or the popover's) —
|
|
78
|
+
// that focus must not re-open the typing session. See `inline_focus.ts`.
|
|
79
|
+
const suppressedAt = useRef<number | null>(null);
|
|
80
|
+
// The popover anchor AND the focus-restore target: the resting view in view
|
|
81
|
+
// mode (via `PopoverTrigger`), the `DateField` frame while editing.
|
|
82
|
+
const anchorRef = useRef<View>(null);
|
|
83
|
+
const wasEditing = useRef(false);
|
|
84
|
+
|
|
85
|
+
const startSession = useCallback(() => {
|
|
86
|
+
draftRef.current = value ?? "";
|
|
87
|
+
setDraft(value ?? "");
|
|
88
|
+
incompleteRef.current = false;
|
|
89
|
+
setError(null);
|
|
90
|
+
}, [value]);
|
|
91
|
+
|
|
92
|
+
const updateDraft = useCallback((next: string) => {
|
|
93
|
+
draftRef.current = next;
|
|
94
|
+
setDraft(next);
|
|
95
|
+
setError(null);
|
|
96
|
+
}, []);
|
|
97
|
+
|
|
98
|
+
const commitSession = useCallback(async () => {
|
|
99
|
+
if (committing.current) return;
|
|
100
|
+
const decision = resolveDateCommit({
|
|
101
|
+
draft: draftRef.current,
|
|
102
|
+
value,
|
|
103
|
+
incomplete: incompleteRef.current,
|
|
104
|
+
clearable: !!onClear,
|
|
105
|
+
});
|
|
106
|
+
if (decision.kind === "invalid") {
|
|
107
|
+
// Partial typed entry: never commit, never wipe the stored value — show
|
|
108
|
+
// the error and keep the session (the entry stays fixable).
|
|
109
|
+
setError(dateLabels.invalidDate);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (decision.kind === "none") {
|
|
113
|
+
setEditing(false);
|
|
72
114
|
return;
|
|
73
115
|
}
|
|
116
|
+
committing.current = true;
|
|
74
117
|
setSaving(true);
|
|
75
118
|
setError(null);
|
|
76
119
|
try {
|
|
77
|
-
|
|
120
|
+
if (decision.kind === "clear") {
|
|
121
|
+
if (onClear) await onClear();
|
|
122
|
+
} else {
|
|
123
|
+
await onSave(decision.value);
|
|
124
|
+
}
|
|
125
|
+
setEditing(false);
|
|
78
126
|
} catch (e) {
|
|
79
|
-
|
|
127
|
+
// Stay in the session so the entry isn't lost — show the error.
|
|
128
|
+
setError(e instanceof Error && e.message ? e.message : inlineLabels.saveError);
|
|
80
129
|
} finally {
|
|
130
|
+
committing.current = false;
|
|
81
131
|
setSaving(false);
|
|
82
132
|
}
|
|
83
|
-
}, [value, onSave, onClear,
|
|
133
|
+
}, [value, onSave, onClear, dateLabels.invalidDate, inlineLabels.saveError]);
|
|
134
|
+
|
|
135
|
+
const closeAndCommit = useCallback(() => {
|
|
136
|
+
setOpen(false);
|
|
137
|
+
// The popover restores focus to its trigger on close — that programmatic
|
|
138
|
+
// focus must not immediately re-open the typing session.
|
|
139
|
+
suppressedAt.current = Date.now();
|
|
140
|
+
void commitSession();
|
|
141
|
+
}, [commitSession]);
|
|
84
142
|
|
|
85
|
-
const
|
|
143
|
+
const handleOpenChange = useCallback(
|
|
86
144
|
(next: boolean) => {
|
|
87
145
|
if (next) {
|
|
88
|
-
|
|
89
|
-
|
|
146
|
+
// A fresh popover session from the resting view (the typing session,
|
|
147
|
+
// when editing, is already running on the same draft).
|
|
148
|
+
if (!editing) startSession();
|
|
90
149
|
setOpen(true);
|
|
91
150
|
} else {
|
|
92
|
-
|
|
93
|
-
|
|
151
|
+
// Commit on panel close: a single-date pick and "Today" auto-close
|
|
152
|
+
// through onRequestClose → closeAndCommit; outside-click/Escape land here.
|
|
153
|
+
closeAndCommit();
|
|
94
154
|
}
|
|
95
155
|
},
|
|
96
|
-
[
|
|
156
|
+
[editing, startSession, closeAndCommit],
|
|
97
157
|
);
|
|
98
158
|
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
//
|
|
159
|
+
// Keyboard focus on the resting value opens the TYPING session (popover shut,
|
|
160
|
+
// first segment focused). Pointer focus never lands here — mousedown records
|
|
161
|
+
// "pointer" modality before focus fires — so a click still opens the calendar
|
|
162
|
+
// exactly once, through the popover trigger press.
|
|
163
|
+
const handleViewFocus = useCallback(() => {
|
|
164
|
+
if (disabled) return;
|
|
165
|
+
if (!shouldOpenOnFocus(getInteractionModality(), suppressedAt.current, Date.now())) return;
|
|
166
|
+
startSession();
|
|
167
|
+
setEditing(true);
|
|
168
|
+
}, [disabled, startSession]);
|
|
169
|
+
|
|
170
|
+
// Pointer down in the segments area, or Alt+ArrowDown in a segment.
|
|
171
|
+
const openPicker = useCallback(() => {
|
|
172
|
+
if (!disabled) setOpen(true);
|
|
173
|
+
}, [disabled]);
|
|
174
|
+
|
|
175
|
+
// Commit once focus leaves the whole field — unless the calendar is open
|
|
176
|
+
// (it steals focus while it floats; its close commits instead).
|
|
177
|
+
const handleFieldBlur = useCallback(() => {
|
|
178
|
+
if (open) return;
|
|
179
|
+
void commitSession();
|
|
180
|
+
}, [open, commitSession]);
|
|
181
|
+
|
|
182
|
+
const handleEscape = useCallback(() => {
|
|
183
|
+
// With the calendar open, Escape belongs to the popover (its document-level
|
|
184
|
+
// handler closes it and commits through onOpenChange).
|
|
185
|
+
if (open) return;
|
|
186
|
+
setError(null);
|
|
187
|
+
setEditing(false);
|
|
188
|
+
}, [open]);
|
|
189
|
+
|
|
190
|
+
const handleIncompleteChange = useCallback((incomplete: boolean) => {
|
|
191
|
+
incompleteRef.current = incomplete;
|
|
192
|
+
}, []);
|
|
193
|
+
|
|
194
|
+
// When the typing session closes while a segment still holds focus (Enter,
|
|
195
|
+
// Escape), the unmount drops focus to <body> and the next Tab would restart
|
|
196
|
+
// from the top of the page. Return focus to the resting view, arming the
|
|
197
|
+
// suppression window so the restore doesn't re-open the session. A Tab-away
|
|
198
|
+
// blur-commit leaves focus on the next field, so this never steals it back.
|
|
199
|
+
useEffect(() => {
|
|
200
|
+
if (wasEditing.current && !editing && typeof document !== "undefined" && document.activeElement === document.body) {
|
|
201
|
+
suppressedAt.current = Date.now();
|
|
202
|
+
anchorRef.current?.focus();
|
|
203
|
+
}
|
|
204
|
+
wasEditing.current = editing;
|
|
205
|
+
}, [editing]);
|
|
206
|
+
|
|
207
|
+
// `format` is the field config (date vs datetime); the display/segments only need
|
|
208
|
+
// whether a time shows. In optionalTime mode the value's own shape decides — the
|
|
209
|
+
// resting view follows the stored value, the segments follow the session draft
|
|
210
|
+
// (the panel's "Add time" grows the segments live).
|
|
102
211
|
const showTime = optionalTime ? isoHasTime(value) : format === "datetime";
|
|
103
212
|
const display = formatDate(value, { time: showTime, locale, emptyLabel: "" });
|
|
213
|
+
const fieldHasTime = optionalTime ? isoHasTime(draft) : format === "datetime";
|
|
214
|
+
|
|
215
|
+
const trailing = saving ? (
|
|
216
|
+
<ActivityIndicator size={16} color={colors.zinc[400]} />
|
|
217
|
+
) : (
|
|
218
|
+
<Icon name={format === "datetime" ? "calendar-clock" : "calendar"} size={18} color={colors.zinc[400]} />
|
|
219
|
+
);
|
|
104
220
|
|
|
105
221
|
return (
|
|
106
222
|
<View>
|
|
107
|
-
<Popover open={open && !disabled} onOpenChange={
|
|
108
|
-
|
|
109
|
-
<
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
223
|
+
<Popover open={open && !disabled} onOpenChange={handleOpenChange} triggerRef={anchorRef} side="bottom" align="start">
|
|
224
|
+
{editing ? (
|
|
225
|
+
<DateField
|
|
226
|
+
triggerRef={anchorRef}
|
|
227
|
+
parts={[draft]}
|
|
228
|
+
onPartChange={(_, next) => updateDraft(next)}
|
|
229
|
+
hasTime={fieldHasTime}
|
|
230
|
+
segmentLabels={dateLabels}
|
|
231
|
+
locale={locale}
|
|
113
232
|
disabled={disabled}
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
233
|
+
placeholder={placeholder}
|
|
234
|
+
partLabels={accessibilityLabel ? [accessibilityLabel] : undefined}
|
|
235
|
+
autoFocus
|
|
236
|
+
onBlur={handleFieldBlur}
|
|
237
|
+
onEscape={handleEscape}
|
|
238
|
+
onOpenPicker={openPicker}
|
|
239
|
+
onIncompleteChange={handleIncompleteChange}
|
|
240
|
+
onActivate={openPicker}
|
|
241
|
+
rightSlot={trailing}
|
|
120
242
|
/>
|
|
121
|
-
|
|
243
|
+
) : (
|
|
244
|
+
<PopoverTrigger>
|
|
245
|
+
<InlineEditView
|
|
246
|
+
background={background}
|
|
247
|
+
display={display}
|
|
248
|
+
placeholder={placeholder}
|
|
249
|
+
disabled={disabled}
|
|
250
|
+
active={open && !disabled}
|
|
251
|
+
accessibilityLabel={accessibilityLabel}
|
|
252
|
+
onFocus={handleViewFocus}
|
|
253
|
+
// A rest affordance (like the select's chevron): a calendar glyph marks
|
|
254
|
+
// the field as a tappable date control even when empty — no hover / pointer
|
|
255
|
+
// cursor needed, so it reads as interactive on touch.
|
|
256
|
+
trailing={trailing}
|
|
257
|
+
/>
|
|
258
|
+
</PopoverTrigger>
|
|
259
|
+
)}
|
|
122
260
|
<PopoverContent>
|
|
123
261
|
<DatePickerPanel
|
|
124
262
|
value={draft}
|
|
125
|
-
onValueChange={
|
|
126
|
-
draftRef.current = v;
|
|
127
|
-
setDraft(v);
|
|
128
|
-
}}
|
|
263
|
+
onValueChange={updateDraft}
|
|
129
264
|
format={format}
|
|
130
265
|
optionalTime={optionalTime}
|
|
131
266
|
locale={locale}
|
|
132
267
|
// Commit on panel close. A single-date pick and the "Today" button
|
|
133
|
-
// auto-close through here —
|
|
134
|
-
//
|
|
135
|
-
|
|
136
|
-
onRequestClose={() => onOpenChange(false)}
|
|
268
|
+
// auto-close through here — a bare setOpen(false) would be a
|
|
269
|
+
// controlled close the Popover never reports, silently dropping it.
|
|
270
|
+
onRequestClose={closeAndCommit}
|
|
137
271
|
/>
|
|
138
272
|
</PopoverContent>
|
|
139
273
|
</Popover>
|
package/src/inline_edit.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useCallback, useRef, useState, type ReactNode, type Ref } from "react";
|
|
1
|
+
import { useCallback, useEffect, useRef, useState, type ReactNode, type Ref } from "react";
|
|
2
2
|
import { View, StyleSheet, type GestureResponderEvent, type TextStyle } from "react-native";
|
|
3
3
|
import { Text } from "./text";
|
|
4
4
|
import { IconButton } from "./icon_button";
|
|
@@ -8,6 +8,8 @@ import { FocusRingPressable } from "./focus_ring_pressable";
|
|
|
8
8
|
import { colors } from "./colors";
|
|
9
9
|
import { FOCUS_RING, CONTROL_RADIUS, HOVER_BORDER, CONTROL_TRANSITION } from "./control_surface";
|
|
10
10
|
import { fontFamilyRegular, getInputTextStyle } from "./text_utils";
|
|
11
|
+
import { getInteractionModality } from "./interaction_modality";
|
|
12
|
+
import { shouldOpenOnFocus } from "./inline_focus";
|
|
11
13
|
|
|
12
14
|
/** The kit's standard control height (TextInputField, NumberInput, Picker, …).
|
|
13
15
|
* The view box matches it — same height, padding, and a 1px transparent border
|
|
@@ -132,6 +134,11 @@ interface InlineEditViewProps {
|
|
|
132
134
|
/** True while the field's popover (select/date) is open: wears the 2px active
|
|
133
135
|
* ring so a mouse-opened trigger reads like a focused input (no `:focus-visible`). */
|
|
134
136
|
active?: boolean;
|
|
137
|
+
/** Raw focus on the resting view, any modality. The input-swap editors use it
|
|
138
|
+
* (via `InlineEditFrame`) to open edit mode on KEYBOARD focus; the popover
|
|
139
|
+
* editors leave it unset — focus never auto-opens an overlay (the WAI-ARIA
|
|
140
|
+
* combobox contract: the popup opens on Enter/Space, not on Tab arrival). */
|
|
141
|
+
onFocus?: () => void;
|
|
135
142
|
/** Strike + mute the resting value (a completed/superseded item that stays editable). */
|
|
136
143
|
struck?: boolean;
|
|
137
144
|
/** Resting surface — see {@link InlineEditBackground}. Default "tint". */
|
|
@@ -150,7 +157,7 @@ interface InlineEditViewProps {
|
|
|
150
157
|
* select/date inline editors (it forwards ref + onPress to a `PopoverTrigger`).
|
|
151
158
|
*/
|
|
152
159
|
export function InlineEditView(props: InlineEditViewProps) {
|
|
153
|
-
const { display, placeholder, onPress, disabled, accessibilityLabel, trailing, active, struck, background = "tint", ref } = props;
|
|
160
|
+
const { display, placeholder, onPress, disabled, accessibilityLabel, trailing, active, struck, background = "tint", onFocus, ref } = props;
|
|
154
161
|
// Stop the press here so an inline editor nested in a pressable row (a task
|
|
155
162
|
// row that expands on press) edits the field instead of triggering the row.
|
|
156
163
|
const handlePress = onPress
|
|
@@ -164,6 +171,7 @@ export function InlineEditView(props: InlineEditViewProps) {
|
|
|
164
171
|
ref={ref}
|
|
165
172
|
disabled={disabled}
|
|
166
173
|
onPress={handlePress}
|
|
174
|
+
onFocus={onFocus}
|
|
167
175
|
accessibilityRole="button"
|
|
168
176
|
accessibilityLabel={accessibilityLabel}
|
|
169
177
|
userSelect="none"
|
|
@@ -187,7 +195,9 @@ export function InlineEditView(props: InlineEditViewProps) {
|
|
|
187
195
|
/**
|
|
188
196
|
* The shared shell of an inline-editable INPUT (text, number). View mode is an
|
|
189
197
|
* `InlineEditView`; on press it swaps to the input at the same height — no
|
|
190
|
-
* layout shift — plus the optional save/cancel controls.
|
|
198
|
+
* layout shift — plus the optional save/cancel controls. KEYBOARD focus on the
|
|
199
|
+
* closed view also opens edit mode (type → Tab → type through a form); pointer
|
|
200
|
+
* focus never does — the press handler owns the click path. Compose it with
|
|
191
201
|
* `useInlineEdit`. (Overlay-based editors — select, date — use `InlineEditView`
|
|
192
202
|
* directly as a `Popover` trigger instead.)
|
|
193
203
|
*/
|
|
@@ -211,13 +221,43 @@ export function InlineEditFrame(props: InlineEditFrameProps) {
|
|
|
211
221
|
affordance,
|
|
212
222
|
} = props;
|
|
213
223
|
|
|
224
|
+
const viewRef = useRef<View>(null);
|
|
225
|
+
const suppressedAt = useRef<number | null>(null);
|
|
226
|
+
const wasEditing = useRef(editing);
|
|
227
|
+
|
|
228
|
+
// Keyboard focus opens edit mode immediately; the input's own `autoFocus`
|
|
229
|
+
// then moves focus into it. Pointer focus never lands here (mousedown records
|
|
230
|
+
// "pointer" modality before focus fires), so a click still opens exactly once,
|
|
231
|
+
// through onPress.
|
|
232
|
+
const handleViewFocus = useCallback(() => {
|
|
233
|
+
if (disabled) return;
|
|
234
|
+
if (!shouldOpenOnFocus(getInteractionModality(), suppressedAt.current, Date.now())) return;
|
|
235
|
+
onBegin();
|
|
236
|
+
}, [disabled, onBegin]);
|
|
237
|
+
|
|
238
|
+
// When edit mode closes while its input (or a ✓/✕ button) still holds focus —
|
|
239
|
+
// Enter, Escape, the buttons — the unmount drops focus to <body> and the next
|
|
240
|
+
// Tab would restart from the top of the page. Return focus to the view button,
|
|
241
|
+
// arming the suppression window so the programmatic focus doesn't re-open the
|
|
242
|
+
// editor it just closed. A Tab-away blur-commit leaves focus on the next field
|
|
243
|
+
// (not <body>), so this never steals focus back.
|
|
244
|
+
useEffect(() => {
|
|
245
|
+
if (wasEditing.current && !editing && typeof document !== "undefined" && document.activeElement === document.body) {
|
|
246
|
+
suppressedAt.current = Date.now();
|
|
247
|
+
viewRef.current?.focus();
|
|
248
|
+
}
|
|
249
|
+
wasEditing.current = editing;
|
|
250
|
+
}, [editing]);
|
|
251
|
+
|
|
214
252
|
if (!editing) {
|
|
215
253
|
return (
|
|
216
254
|
<InlineEditView
|
|
255
|
+
ref={viewRef}
|
|
217
256
|
background={background}
|
|
218
257
|
display={display}
|
|
219
258
|
placeholder={placeholder}
|
|
220
259
|
onPress={onBegin}
|
|
260
|
+
onFocus={handleViewFocus}
|
|
221
261
|
disabled={disabled}
|
|
222
262
|
accessibilityLabel={accessibilityLabel}
|
|
223
263
|
struck={struck}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, it, expect } from "vitest";
|
|
3
|
+
import { ensureModalityListeners, getInteractionModality } from "./interaction_modality";
|
|
4
|
+
import { FOCUS_OPEN_SUPPRESS_MS, shouldOpenOnFocus } from "./inline_focus";
|
|
5
|
+
|
|
6
|
+
describe("shouldOpenOnFocus — the inline keyboard-entry gate", () => {
|
|
7
|
+
it("keyboard focus opens the editor", () => {
|
|
8
|
+
expect(shouldOpenOnFocus("keyboard", null, 1_000)).toBe(true);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("pointer focus never opens the editor — the click path stays press-driven", () => {
|
|
12
|
+
expect(shouldOpenOnFocus("pointer", null, 1_000)).toBe(false);
|
|
13
|
+
// …even outside any suppression window.
|
|
14
|
+
expect(shouldOpenOnFocus("pointer", 100, 100_000)).toBe(false);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("a programmatic focus restore inside the suppression window does not re-open", () => {
|
|
18
|
+
const restoredAt = 5_000;
|
|
19
|
+
expect(shouldOpenOnFocus("keyboard", restoredAt, restoredAt)).toBe(false);
|
|
20
|
+
expect(shouldOpenOnFocus("keyboard", restoredAt, restoredAt + FOCUS_OPEN_SUPPRESS_MS - 1)).toBe(false);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("suppression expires — a later Tab arrival opens again", () => {
|
|
24
|
+
const restoredAt = 5_000;
|
|
25
|
+
expect(shouldOpenOnFocus("keyboard", restoredAt, restoredAt + FOCUS_OPEN_SUPPRESS_MS)).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("interaction modality tracker", () => {
|
|
30
|
+
it("records keyboard on keydown and pointer on mousedown (Tab-vs-click focus)", () => {
|
|
31
|
+
ensureModalityListeners();
|
|
32
|
+
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab" }));
|
|
33
|
+
expect(getInteractionModality()).toBe("keyboard");
|
|
34
|
+
document.dispatchEvent(new MouseEvent("mousedown"));
|
|
35
|
+
expect(getInteractionModality()).toBe("pointer");
|
|
36
|
+
document.dispatchEvent(new KeyboardEvent("keydown", { key: "a" }));
|
|
37
|
+
expect(getInteractionModality()).toBe("keyboard");
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// Inline editor focus-entry policy
|
|
3
|
+
//
|
|
4
|
+
// The keyboard-entry contract for the input-swap inline editors (text, number,
|
|
5
|
+
// time, date): a KEYBOARD focus landing on the closed editor opens edit mode
|
|
6
|
+
// immediately with the input focused — a typist gets type → Tab → type without
|
|
7
|
+
// pressing Enter on every field. Pointer focus never opens here: mousedown
|
|
8
|
+
// records "pointer" modality before the focus event fires, and the press
|
|
9
|
+
// handler already owns the click path — so a click can never double-trigger.
|
|
10
|
+
//
|
|
11
|
+
// A PROGRAMMATIC focus restore (the editor returning focus to its closed view
|
|
12
|
+
// after Enter/Escape, or a popover restoring focus to its trigger on close)
|
|
13
|
+
// arms a short suppression window first, so the restored focus doesn't
|
|
14
|
+
// immediately re-open the editor it just closed.
|
|
15
|
+
//
|
|
16
|
+
// Pure logic, kept RN-free so it can be unit-tested directly.
|
|
17
|
+
// =============================================================================
|
|
18
|
+
|
|
19
|
+
import { type InteractionModality } from "./interaction_modality";
|
|
20
|
+
|
|
21
|
+
/** How long (ms) after a programmatic focus restore a focus event is treated as
|
|
22
|
+
* the restore itself rather than a fresh keyboard arrival. Restores run
|
|
23
|
+
* synchronously (effect cleanup) or within a frame — 250ms covers both with
|
|
24
|
+
* margin while never swallowing a real Tab press later. */
|
|
25
|
+
export const FOCUS_OPEN_SUPPRESS_MS = 250;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Should a focus event on a CLOSED inline editor open edit mode?
|
|
29
|
+
* `suppressedAt` is the timestamp of the last programmatic focus restore
|
|
30
|
+
* (null when none) — the caller keeps it in a ref and stamps it right before
|
|
31
|
+
* calling `.focus()`.
|
|
32
|
+
*/
|
|
33
|
+
export function shouldOpenOnFocus(
|
|
34
|
+
modality: InteractionModality,
|
|
35
|
+
suppressedAt: number | null,
|
|
36
|
+
now: number,
|
|
37
|
+
): boolean {
|
|
38
|
+
if (modality !== "keyboard") return false;
|
|
39
|
+
return suppressedAt === null || now - suppressedAt >= FOCUS_OPEN_SUPPRESS_MS;
|
|
40
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// Interaction modality — the document-level keyboard-vs-pointer tracker
|
|
3
|
+
//
|
|
4
|
+
// `:focus-visible` is a MODALITY heuristic: a control reacts to keyboard focus
|
|
5
|
+
// but not pointer focus. One focus event carries no modality, so it can only be
|
|
6
|
+
// read at the document level: a single module-level tracker records the last
|
|
7
|
+
// interaction. Consumed by `useFocusRing` (paint the ring on keyboard focus
|
|
8
|
+
// only) and the inline editors (keyboard focus opens edit mode — type → Tab →
|
|
9
|
+
// type — while pointer focus defers to the press handler). Web-only — on
|
|
10
|
+
// native / SSR there is no `document`, so it stays "pointer" and everything
|
|
11
|
+
// keyboard-gated stays off.
|
|
12
|
+
// =============================================================================
|
|
13
|
+
|
|
14
|
+
export type InteractionModality = "keyboard" | "pointer";
|
|
15
|
+
|
|
16
|
+
let lastModality: InteractionModality = "pointer";
|
|
17
|
+
let listenersInstalled = false;
|
|
18
|
+
|
|
19
|
+
export function ensureModalityListeners(): void {
|
|
20
|
+
if (listenersInstalled) return;
|
|
21
|
+
if (typeof document === "undefined") return;
|
|
22
|
+
listenersInstalled = true;
|
|
23
|
+
// Capture phase so the modality is recorded BEFORE any control's focus handler
|
|
24
|
+
// runs. Installed once for the app's lifetime (the browser's own `:focus-visible`
|
|
25
|
+
// heuristic listens the same way) — per-mount add/remove would be the bug.
|
|
26
|
+
const opts = { capture: true, passive: true } as const;
|
|
27
|
+
document.addEventListener("keydown", () => { lastModality = "keyboard"; }, opts);
|
|
28
|
+
document.addEventListener("pointerdown", () => { lastModality = "pointer"; }, opts);
|
|
29
|
+
document.addEventListener("mousedown", () => { lastModality = "pointer"; }, opts);
|
|
30
|
+
document.addEventListener("touchstart", () => { lastModality = "pointer"; }, opts);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The modality of the most recent user interaction. Read it inside a focus
|
|
34
|
+
* handler to tell a Tab-focus ("keyboard") from a click-focus ("pointer") —
|
|
35
|
+
* the same signal the browser's `:focus-visible` uses. */
|
|
36
|
+
export function getInteractionModality(): InteractionModality {
|
|
37
|
+
return lastModality;
|
|
38
|
+
}
|
package/src/locale.tsx
CHANGED
|
@@ -107,7 +107,7 @@ export const en: LoticsLocale = {
|
|
|
107
107
|
descending: ", descending",
|
|
108
108
|
},
|
|
109
109
|
optionList: { selectAll: "Select all", deselectAll: "Deselect all", clear: "Clear", noResults: "No results", recent: "Recent", searchPlaceholder: "Search…" },
|
|
110
|
-
datePicker: { today: "Today", now: "Now", clear: "Clear", done: "Done", openCalendar: "Open calendar", time: "Time", startTime: "Start time", endTime: "End time", addTime: "Add time", removeTime: "Remove time", year: "Year", month: "Month", day: "Day", hour: "Hour", minute: "Minute", dayPeriod: "AM/PM" },
|
|
110
|
+
datePicker: { today: "Today", now: "Now", clear: "Clear", done: "Done", openCalendar: "Open calendar", time: "Time", startTime: "Start time", endTime: "End time", addTime: "Add time", removeTime: "Remove time", year: "Year", month: "Month", day: "Day", hour: "Hour", minute: "Minute", dayPeriod: "AM/PM", invalidDate: "Enter a complete date" },
|
|
111
111
|
calendar: { previousMonth: "Previous month", nextMonth: "Next month" },
|
|
112
112
|
filterChip: { clear: "Clear" },
|
|
113
113
|
floatingActionBar: { clear: "Clear" },
|
|
@@ -187,7 +187,7 @@ export const vi: LoticsLocale = {
|
|
|
187
187
|
descending: " (giảm dần)",
|
|
188
188
|
},
|
|
189
189
|
optionList: { selectAll: "Chọn tất cả", deselectAll: "Bỏ chọn tất cả", clear: "Xóa", noResults: "Không có kết quả", recent: "Gần đây", searchPlaceholder: "Tìm…" },
|
|
190
|
-
datePicker: { today: "Hôm nay", now: "Bây giờ", clear: "Xóa", done: "Xong", openCalendar: "Mở lịch", time: "Giờ", startTime: "Giờ bắt đầu", endTime: "Giờ kết thúc", addTime: "Thêm giờ", removeTime: "Bỏ giờ", year: "Năm", month: "Tháng", day: "Ngày", hour: "Giờ", minute: "Phút", dayPeriod: "SA/CH" },
|
|
190
|
+
datePicker: { today: "Hôm nay", now: "Bây giờ", clear: "Xóa", done: "Xong", openCalendar: "Mở lịch", time: "Giờ", startTime: "Giờ bắt đầu", endTime: "Giờ kết thúc", addTime: "Thêm giờ", removeTime: "Bỏ giờ", year: "Năm", month: "Tháng", day: "Ngày", hour: "Giờ", minute: "Phút", dayPeriod: "SA/CH", invalidDate: "Nhập ngày đầy đủ" },
|
|
191
191
|
calendar: { previousMonth: "Tháng trước", nextMonth: "Tháng sau" },
|
|
192
192
|
filterChip: { clear: "Xóa" },
|
|
193
193
|
floatingActionBar: { clear: "Bỏ chọn" },
|
package/src/use_focus_ring.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useCallback, useState } from "react";
|
|
2
|
+
import { ensureModalityListeners, getInteractionModality } from "./interaction_modality";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Combine two optional event handlers into one. Either may be undefined.
|
|
@@ -17,31 +18,6 @@ export function composeHandler<E>(
|
|
|
17
18
|
};
|
|
18
19
|
}
|
|
19
20
|
|
|
20
|
-
// `:focus-visible` is a MODALITY heuristic: a control shows its ring on keyboard
|
|
21
|
-
// focus but not pointer focus. The kit no longer carries a global `:focus-visible`
|
|
22
|
-
// CSS rule, so every control paints its own ring — and needs that same signal. One
|
|
23
|
-
// focus event carries no modality, so it can only be read at the document level:
|
|
24
|
-
// a single module-level tracker records the last interaction. Web-only — on native
|
|
25
|
-
// / SSR there is no `document`, so it stays "pointer" and the ring is keyboard-gated
|
|
26
|
-
// off (text-like inputs opt back in with `always`).
|
|
27
|
-
type Modality = "keyboard" | "pointer";
|
|
28
|
-
let lastModality: Modality = "pointer";
|
|
29
|
-
let listenersInstalled = false;
|
|
30
|
-
|
|
31
|
-
function ensureModalityListeners(): void {
|
|
32
|
-
if (listenersInstalled) return;
|
|
33
|
-
if (typeof document === "undefined") return;
|
|
34
|
-
listenersInstalled = true;
|
|
35
|
-
// Capture phase so the modality is recorded BEFORE any control's focus handler
|
|
36
|
-
// runs. Installed once for the app's lifetime (the browser's own `:focus-visible`
|
|
37
|
-
// heuristic listens the same way) — per-mount add/remove would be the bug.
|
|
38
|
-
const opts = { capture: true, passive: true } as const;
|
|
39
|
-
document.addEventListener("keydown", () => { lastModality = "keyboard"; }, opts);
|
|
40
|
-
document.addEventListener("pointerdown", () => { lastModality = "pointer"; }, opts);
|
|
41
|
-
document.addEventListener("mousedown", () => { lastModality = "pointer"; }, opts);
|
|
42
|
-
document.addEventListener("touchstart", () => { lastModality = "pointer"; }, opts);
|
|
43
|
-
}
|
|
44
|
-
|
|
45
21
|
export interface UseFocusRingOptions {
|
|
46
22
|
/**
|
|
47
23
|
* Ring on ANY focus, not just keyboard focus. For text-like inputs, which the
|
|
@@ -69,12 +45,13 @@ export interface FocusRingState {
|
|
|
69
45
|
* Replaces the kit's removed global `:focus-visible` CSS rule: each interactive
|
|
70
46
|
* primitive calls this and renders `FOCUS_RING` (`control_surface.ts`) when
|
|
71
47
|
* `focusVisible`. App authors do the same for their own raw focusable elements.
|
|
48
|
+
* The keyboard-vs-pointer signal lives in `interaction_modality.ts`.
|
|
72
49
|
*/
|
|
73
50
|
export function useFocusRing(options?: UseFocusRingOptions): FocusRingState {
|
|
74
51
|
ensureModalityListeners();
|
|
75
52
|
const [focused, setFocused] = useState(false);
|
|
76
53
|
const onFocus = useCallback(() => setFocused(true), []);
|
|
77
54
|
const onBlur = useCallback(() => setFocused(false), []);
|
|
78
|
-
const focusVisible = focused && (options?.always === true ||
|
|
55
|
+
const focusVisible = focused && (options?.always === true || getInteractionModality() === "keyboard");
|
|
79
56
|
return { focusVisible, focused, focusProps: { onFocus, onBlur } };
|
|
80
57
|
}
|