@lotics/ui 17.0.2 → 18.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/MIGRATION.md CHANGED
@@ -4,6 +4,26 @@ Breaking changes, newest first — normally per major, plus the rare minor that
4
4
  anyway (recorded under its exact version). The current contract lives in `AGENTS.md` + `docs/`;
5
5
  this file exists only to move an app from one release to the next.
6
6
 
7
+ ## v18 from 17.x
8
+
9
+ **`TimePicker` is a segmented field, not `<input type="time">`.** A native time input takes
10
+ its 12- vs 24-hour form from the BROWSER's UI locale and ignores the `lang` attribute
11
+ entirely, so a Vietnamese screen on an en-US browser rendered "01:45 PM" beside its own
12
+ 24-hour text. It now uses the same segment engine as `DateField`, with the format derived
13
+ from a `locale` prop (defaulting to the active `LoticsLocaleProvider` locale).
14
+
15
+ The value is unchanged — canonical 24-hour `"HH:mm"` either way. Two prop changes:
16
+
17
+ - **`onKeyDown` is GONE.** It typed the DOM input's key events, which no longer exist. Use
18
+ `onEscape` for cancel; a completed entry emits through `onValueChange` as before.
19
+ - **`onIncompleteChange` is NEW** and matters for inline editors: segments emit only a
20
+ COMPLETE `"HH:mm"`, so a half-typed entry (an hour, no minute) arrives as `""` and would
21
+ otherwise commit as a silent clear. Block the commit on it — `InlineTimePicker` does, and
22
+ surfaces the new `datePicker.invalidTime` label.
23
+
24
+ `DatePickerLabels` gains **`invalidTime`**; a caller that builds the label bag by hand (rather
25
+ than spreading the locale pack) must add it.
26
+
7
27
  ## v17 from 16.x
8
28
 
9
29
  **`Checklist` / `ChecklistRow` are DELETED.** They are replaced by the `Task` compound
package/docs/catalog.md CHANGED
@@ -171,7 +171,7 @@ component — children are the body), `Subsection` (+ `SubsectionHeading`/
171
171
  `SubsectionHeadingTitle` — the named group INSIDE a section, `###` lg-semibold title),
172
172
  `SectionStack` (the flat page's content column — owns the fixed 56px beat + hairline between
173
173
  top-level blocks), `SubsectionStack` (a section's `Subsection` groups — fixed 32px beat,
174
- SPACE-ONLY: titles carry the grouping, hairlines stay at the section level), `SectionCard`, `PageHeader` /
174
+ space-only while the groups are SHORT, `divided` once they run long), `SectionCard`, `PageHeader` /
175
175
  `PageContent`, `Stack`, `Spacer`, `Divider`, `Accordion`, `Tabs`, `SegmentedControl`,
176
176
  `Stepper`, `DangerZone` (the destructive section — delete/archive — set apart at the bottom
177
177
  of a record/settings surface).
@@ -388,9 +388,10 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
388
388
  `@lotics/ui/section` only when you specifically want this collapsible titled block.
389
389
  - **`section_stack`** — `SectionStack` · `SubsectionStack` — stacks that own the
390
390
  between-block law, skipping null children: `SectionStack` = a fixed 56px beat + a hairline
391
- `Divider` between top-level blocks; `SubsectionStack` = a fixed 32px beat, space-only (no
392
- hairline titles carry the grouping, `divided` an opt-in for a rare headingless stack);
393
- stop hand-rolling gap + `<Divider />` pairs.
391
+ `Divider` between top-level blocks; `SubsectionStack` = a fixed 32px beat, space-only while
392
+ the groups are SHORT (the titles carry the grouping, hairlines stay at the section level) and
393
+ `divided` once they run LONG — past a screenful, 32px reads as one more row gap and the next
394
+ title arrives unmarked. Length decides, not headings. Stop hand-rolling gap + `<Divider />` pairs.
394
395
  - **`section_card`** — `SectionCard`: a titled card with a one-line description and an
395
396
  optional hairline-set `footer` (where `TrendFooter` and source notes go).
396
397
  - **`stack`** — `Stack`: gap-spaced row/column; `useSeparator` inserts `Separator`s between
@@ -454,8 +455,13 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
454
455
  - **`action_menu`** — `ActionMenu`: the ⋯ overflow menu (`ActionMenuItem[]`; danger items
455
456
  last).
456
457
  - **`menu_button`** — `MenuButton`: the menu/rail row (icon · title · `right` slot;
457
- `selected`/`focused`/`danger`; `role` menuitem|button|option) — popover menus, outline
458
- rails, section pickers.
458
+ `focused`/`danger`; `role` menuitem|button|option) — popover menus, outline rails, section
459
+ pickers. Its resting highlight has TWO meanings and they are not interchangeable:
460
+ **`selected`** is listbox SELECTION (emitted as `aria-selected`, and only under
461
+ `role="option"` — the attribute is invalid anywhere else), **`current`** is the current
462
+ item of a NAVIGATION set (`aria-current`, global, so any role) — `true`, or the token
463
+ naming the set (`"page"` for a routed pane, `"step"`, `"location"`). An outline rail and a
464
+ section picker take `current`; a picker option takes `selected`.
459
465
  - **`menu_list_item`** — `MenuListItem`: the richer listbox/menu row (title + description +
460
466
  `right`; `selected` vs roving `focused` via `aria-activedescendant`). No fixed height — it
461
467
  GROWS with its content, and `title` takes a node, so it is what `OptionList`/`Combobox` render
@@ -474,11 +480,18 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
474
480
  matches the surface it spans. `TableRow` / `LinkedRecordBox` are the in-kit consumers —
475
481
  reach for it when designing a row those don't cover.
476
482
  - **`linked_record_box`** — `LinkedRecordBox`: a bordered box scoping ANOTHER record's data
477
- (`icon` · `name` · `subtitle` · vertical `facts`), the WHOLE box a keyboard door into its
478
- detail (`onOpen`/`doorLabel`); verbs ride an `actions` slot in a hairline-fenced footer at the
479
- bottom (destructive LEFT, go-to RIGHT the divider draws itself when `actions` is present, so
480
- don't hand-add one). Use it wherever a record points at another (a shipment's customer, an invoice's
481
- party, a case's sibling). It **enforces the a11y contract** copy-pasting got wrong: a
483
+ (`icon` · `name` · `subtitle` · vertical `facts`); verbs ride an `actions` slot in a
484
+ hairline-fenced footer at the bottom (destructive LEFT, go-to RIGHT the divider draws itself
485
+ when `actions` is present, so don't hand-add one). Use it wherever a record points at another (a
486
+ shipment's customer, an invoice's party, a case's sibling). **`onOpen` picks the variant, and
487
+ they are exclusive at the type level**: WITH it (+ the required `doorLabel`) the whole box is a
488
+ keyboard door into the record's detail and `facts` values are TEXT (`""` → "—"); WITHOUT it the
489
+ box is a STATIC card — no door, no tab stop, no pointer, nothing announced as a button — and a
490
+ `facts` value may be a NODE (an inline editor; a node prints as authored, no "—"). Reach for the
491
+ static one when the linked record has NO page of its own: nothing to open, so the box is where
492
+ its values are read and edited, and the only interactive parts are `actions` + the fact nodes.
493
+ Under a door, `actions` is the ONLY place a control may live (a fact editor there would compete
494
+ with the press). The door variant **enforces the a11y contract** copy-pasting got wrong: a
482
495
  container with interactive descendants is never `role="button"` (invalid HTML) — an internal
483
496
  empty door sibling carries the tab stop / name / focus ring, verbs lift above via `zIndex`.
484
497
  The reassign picker / empty state that swaps in for the box is the consumer's.
@@ -495,7 +508,8 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
495
508
  - **`count`** — `Count`: a small circular count bubble (`color` highlight|muted|red).
496
509
  - **`shortcut_badge`** — `ShortcutBadge`: the keycap hint pill — a zinc-50 badge rendering a
497
510
  shortcut from a raw string or `ShortcutDescriptor` (⌘B on Mac, Ctrl+B elsewhere); null on
498
- small screens. `TextInputField shortcut` renders it built-in.
511
+ small screens. `TextInputField shortcut` renders it built-in, while the field is EMPTY —
512
+ it hints at reaching the field, so a value replaces it (with the clear ✕, when `clearable`).
499
513
  - **`keyboard`** — `isMac` · `ShortcutDescriptor` · `formatShortcut` (platform-aware
500
514
  shortcut formatting behind `ShortcutBadge`).
501
515
 
@@ -555,7 +569,10 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
555
569
  - **`text_input_field`** — `TextInputField`: the standard text input (multiline grows via
556
570
  the auto-grow engine; `shortcut` renders a `ShortcutBadge`). Its surface is WHITE on any
557
571
  background — on a tinted panel the input still reads as an open well, never blending into
558
- the tint; override via `style` only when a surface genuinely wants otherwise.
572
+ the tint; override via `style` only when a surface genuinely wants otherwise. It RESERVES
573
+ its own right gutter whenever a trailing affordance is up (the `clearable` ✕, the `shortcut`
574
+ badge), so a long value is held off it — never hand-roll a `paddingRight` to compensate. A
575
+ field with an empty slot reserves nothing, so its text starts and ends where it always did.
559
576
  - **`number_input`** — `NumberInput`: numeric input; `format` for currency/units.
560
577
  - **`search_input`** — `SearchInput`: the search box for toolbars/filters.
561
578
  - **`form_field`** — `FormField` + `useFormField`: label / description / warning / error /
@@ -600,11 +617,19 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
600
617
  localized weekday/month names via BCP-47 `locale`, `firstDayOfWeek` (default Monday),
601
618
  `ref.navigateToMonth`. The engine `DatePicker`/`DateFilter` wrap in field chrome — reach
602
619
  for it bare only when the calendar lives permanently on the surface, not behind a field.
603
- - **`date_filter`** — `DateFilter`: the single/range date+time filter panel — presets
620
+ - **`date_filter`** — `DateFilter`: the date+time period filter panel — presets
604
621
  (`PresetId`), calendar, optional time segments; the body `DateRangeFilterField` opens.
622
+ **One selection rule, no modes**: a click OPENS a range, the next CLOSES it (in either
623
+ order — bounds come back sorted), and a single day is the same day clicked twice
624
+ (`start`/`end` equal — which is how a consumer detects "one day" and how the trigger
625
+ renders it as a single date). Both bounds always show, so a half-picked range names the
626
+ end that is still open.
605
627
  - **`date_range_filter_field`** — `DateRangeFilterField`: the register's period filter field
606
628
  (presets + footer + time-segment a11y; localized via the `dateRange` locale slice).
607
- - **`time_picker`** — `TimePicker`: the time-of-day input.
629
+ - **`time_picker`** — `TimePicker`: the time-of-day field. Segments (hour / minute / AM-PM),
630
+ **12- vs 24-hour derived from `locale`** — never from the browser, which is why this is not
631
+ an `<input type="time">`. The value is always canonical 24-hour `"HH:mm"`; `locale` changes
632
+ only what is displayed and typed.
608
633
 
609
634
  ### Inline editing & record surfaces
610
635
 
@@ -849,8 +874,11 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
849
874
  is open. Lives in the primitives — never call it from a screen.
850
875
  - **`use_section_nav`** — `useSectionNav`: scroll-spy for a LONG record surface with a left
851
876
  outline rail — keys in page order → `{scrollRef, onScroll, register(key)→onLayout,
852
- jumpTo(key), activeKey}`; rail items are `MenuButton`s (`selected={activeKey===key}`);
853
- section wrappers must be DIRECT children of the ScrollView content. On NARROW containers
877
+ jumpTo(key), activeKey}`; rail items are `MenuButton`s (`current={activeKey===key}` — a
878
+ rail item is navigation, never a selected option);
879
+ section wrappers must be DIRECT children of the ScrollView content. The highlight and the
880
+ jump both re-measure the live DOM on web, so content loading in above a section never
881
+ strands them. On NARROW containers
854
882
  the rail becomes a PINNED bar naming the CURRENT section that opens a full-page
855
883
  section-picker `Modal` — never a horizontal tab strip. Worked example:
856
884
  [`tpl_record`](../examples/tpl_record.tsx).
@@ -63,9 +63,12 @@ restyle a heading level per-page.
63
63
  `SubsectionHeading` › `SubsectionHeadingTitle` (`###` — lg semibold, with the same `info` ⓘ
64
64
  affordance as `SectionHeadingTitle`; siblings — a `Badge`, a
65
65
  `SectionHeadingMeta`, an action — ride the heading row's right edge). Sibling subsections stack
66
- in a **`SubsectionStack`** (a fixed 32px beat, SPACE-ONLY subsection titles carry the
67
- grouping; hairlines belong to the SECTION level, one rule per altitude; no margins, no
68
- hand-rolled dividers); a headingless `Subsection` is the
66
+ in a **`SubsectionStack`** (a fixed 32px beat; no margins, no hand-rolled dividers).
67
+ It is SPACE-ONLY while the groups are SHORT the titles carry the grouping and hairlines
68
+ belong to the SECTION level, one rule per altitude — and takes `divided` once the groups run
69
+ LONG: past a screenful of rows the 32px beat reads as one more row gap and the next title
70
+ arrives with nothing marking that a new group started. LENGTH is the discriminator, not
71
+ whether the groups are titled. A headingless `Subsection` is the
69
72
  section's lead group. Group leads INSIDE a subsection's rows are `md` medium at most. Do NOT
70
73
  hand-roll `Text weight="semibold"` group leads inside a section, and never promote a subsection
71
74
  to its own section-level heading just to separate it.
@@ -448,13 +451,19 @@ gate's SCOPE, once** — never prose beside the button, never revealed only on p
448
451
  the band's invisible bottom half must never read as a hole above the description.
449
452
  - **A LINKED record never renders as field rows** — another record's data must not read as
450
453
  fields on THIS one. Use **`LinkedRecordBox`** (`tpl_record`'s Customer/sibling are the worked
451
- examples): a BORDERED, fully pressable box (glyph anchor · identity · facts stacked
452
- vertically) whose press opens the record's detail drawer; the verbs ride its `actions` slot
453
- in a hairline-fenced footer INSIDE the box destructive LEFT in danger, go-to RIGHT. One verb
454
- set across every box of a group (a party wall's five links all read "Gỡ", not a mix of
454
+ examples): a BORDERED box (glyph anchor · identity · facts stacked vertically) whose verbs
455
+ ride its `actions` slot in a hairline-fenced footer INSIDE the box destructive LEFT in
456
+ danger, go-to RIGHT. One verb set across every box of a group (a party wall's five links all read "Gỡ", not a mix of
455
457
  "Đổi"/"Gỡ"): with a clearable link, changing = remove → the empty-state picker re-prompts, so
456
- the destructive verb covers both required anchors and optional adds. Editing the linked record happens on
457
- ITS page, never here.
458
+ the destructive verb covers both required anchors and optional adds.
459
+ **`onOpen` decides whether the box is a DOOR, and the kit makes the two exclusive.** Pass it
460
+ (with `doorLabel`) when the linked record HAS a detail surface: the whole box presses open, its
461
+ facts are TEXT, and editing that record happens on ITS page, never here. OMIT it when the record
462
+ has NO page of its own — the box renders static (no tab stop, no pointer, nothing announced as a
463
+ button), the explicit `actions` verbs are its only affordances, and a `facts` value may then be a
464
+ NODE holding the inline editor, since there is no destination a press could steal. Never wire a
465
+ door "so the box presses": it sends the reader somewhere they did not ask to go and duplicates
466
+ whatever change verb the footer already shows.
458
467
  **The empty / attach state is a find-or-create `Combobox`, never a bare picker** (`tpl_record`'s
459
468
  Customer is the worked example): rows are `renderOptionContent`-rich, NOT a wall of bare names —
460
469
  the premium shape is a **glyph tile (28·radius 7·zinc-100) · the name over a muted metadata line**
@@ -468,13 +477,15 @@ gate's SCOPE, once** — never prose beside the button, never revealed only on p
468
477
  WITHOUT leaving the record. `customOptionPlacement:"top"` keeps the create row visible while the
469
478
  keyboard highlight stays on the first MATCH, so Enter on a partial never makes a duplicate. A
470
479
  linked record seeded by a workflow (not user-attached) skips the picker — show the box or an
471
- `EmptyState`, no create affordance. The component OWNS the press ANATOMY (the error-prone part hand-rolling
472
- got wrong): the box is a `PressableRow` (role-less, non-focusable surface — hover wash spans
480
+ `EmptyState`, no create affordance. The component OWNS the door variant's press ANATOMY (the
481
+ error-prone part hand-rolling got wrong): the box is a `PressableRow` (role-less, non-focusable surface — hover wash spans
473
482
  the nested verbs) with the keyboard DOOR — an EMPTY absolutely-positioned `role="button"`
474
483
  sibling carrying the tab stop, the accessible name and the focus ring — beneath the content,
475
484
  the interior verbs lifted above it via `zIndex: 1`. NEVER `role="button"` on a container with
476
485
  interactive descendants: a button must not contain a button (invalid HTML, a hydration error,
477
- and AT double-announcement).
486
+ and AT double-announcement). That fenced, lifted footer is the ONLY place a control may sit
487
+ inside a door box — a control in the facts column would compete with the press for the same
488
+ click, which is why a node fact typechecks only on the doorless variant.
478
489
 
479
490
  ## Color discipline — solid / tint / ramp + ONE accent
480
491
 
@@ -346,7 +346,9 @@ level up — money never reads as one pot); each closes with a SEQUENTIAL confir
346
346
  its blockers ("Confirm check-in first."). The header status chip carries the phase.
347
347
 
348
348
  A LONG record surface pairs with a LEFT OUTLINE RAIL — `MenuButton` items + `useSectionNav`
349
- (scroll-spy: jump to a section, the highlight follows the scroll); on narrow containers the rail
349
+ (scroll-spy: jump to a section, the highlight follows the scroll); the active item is
350
+ `current={activeKey === key}`, the NAVIGATION state, never `selected`, which is listbox
351
+ selection. On narrow containers the rail
350
352
  becomes a PINNED bar naming the current section that opens a full-page section-picker `Modal`. A
351
353
  per-phase dot on a rail item carries its confirmed state. A direction that is a TYPE (one record
352
354
  per event) is instead a discriminator chosen ONCE at creation — a `SegmentedControl` in the
package/docs/templates.md CHANGED
@@ -392,8 +392,9 @@ billing, and quick-capture templates. Top → bottom:
392
392
  section; the fenced red `DangerZone` card is the section's BODY.
393
393
  - A LEFT OUTLINE RAIL (`MenuButton` + `useSectionNav`) — **the rail is the page's COMPLETE
394
394
  map** (every section including Danger zone; subsections never join it) — headed by the
395
- bare circular `BackButton`; it scroll-spies the active section, and on narrow containers
396
- becomes the pinned current-section bar opening a section-picker `Modal`.
395
+ bare circular `BackButton`; it scroll-spies the active section (`current={activeKey === key}`
396
+ on each item navigation, not selection), and on narrow containers becomes the pinned
397
+ current-section bar opening a section-picker `Modal` whose items carry the same `current`.
397
398
  - **The page layout is the DOCS layout — the reading column is CENTRED IN THE VIEWPORT, and
398
399
  the rail sits in its left gutter.** The `ScrollView` is FULL-WIDTH — **never cap it at
399
400
  `CONTENT_MAX`, or the scroll container becomes the column and the gutters go wheel-dead**
@@ -2552,7 +2552,7 @@ export function TplRecord() {
2552
2552
  key={sec.key}
2553
2553
  icon={sec.icon}
2554
2554
  title={sec.label}
2555
- selected={nav.activeKey === sec.key}
2555
+ current={nav.activeKey === sec.key}
2556
2556
  onPress={() => {
2557
2557
  setSectionsOpen(false);
2558
2558
  nav.jumpTo(sec.key);
@@ -3075,7 +3075,7 @@ export function TplRecord() {
3075
3075
  <BackButton label="Records" onPress={() => {}} />
3076
3076
  <View style={{ gap: 2 }}>
3077
3077
  {SECTIONS.map((sec) => (
3078
- <MenuButton key={sec.key} icon={sec.icon} title={sec.label} selected={nav.activeKey === sec.key} onPress={() => nav.jumpTo(sec.key)} />
3078
+ <MenuButton key={sec.key} icon={sec.icon} title={sec.label} current={nav.activeKey === sec.key} onPress={() => nav.jumpTo(sec.key)} />
3079
3079
  ))}
3080
3080
  </View>
3081
3081
  </View>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "17.0.2",
3
+ "version": "18.1.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./vite": {
package/src/combobox.tsx CHANGED
@@ -360,12 +360,13 @@ export interface ComboboxInputProps {
360
360
  export function ComboboxInput(props: ComboboxInputProps) {
361
361
  const { icon, placeholder, clearable = false, onClear, clearLabel, accessibilityLabel, testID } = props;
362
362
  const ctx = useComboboxContext("ComboboxInput");
363
- // Select mode (no leading icon): the right slot holds the clear when the field
364
- // carries text (a reflected selection or a live query) AND it's clearable;
365
- // otherwise the trailing chevron, so an EMPTY picker still reads as a picker.
366
- // The two never show together replaces the chevron once there's something to
367
- // clear, and pressing it resets the query + reopens the browse list (via
368
- // `handleChangeText("")`) so the consumer's `onClear` lands back on the full set.
363
+ // `showClear` MIRRORS `TextInputField`'s own clear-✕ condition the field
364
+ // carries text (a reflected selection or a live query) AND it's clearable — so
365
+ // the that renders there and the chevron decided here can never both be up.
366
+ // The chevron is what THIS file owns, and it is select-mode only (no leading
367
+ // icon), so an EMPTY picker still reads as a picker; replaces it once there's
368
+ // something to clear, and pressing that resets the query + reopens the browse
369
+ // list (via `handleChangeText("")`) so `onClear` lands back on the full set.
369
370
  const showClear = clearable && ctx.query.length > 0;
370
371
  const showChevron = !icon && !showClear;
371
372
 
@@ -391,9 +392,12 @@ export function ComboboxInput(props: ComboboxInputProps) {
391
392
  autoCorrect={false}
392
393
  accessibilityLabel={accessibilityLabel}
393
394
  style={[
394
- // Select mode reserves the right gutter for whichever trailing affordance
395
- // is up (chevron when empty, when there's text) so neither overlaps it.
396
- !icon ? styles.selectInput : undefined,
395
+ // Only the CHEVRON's gutter belongs here — it is the one trailing
396
+ // affordance this file owns. `TextInputField` reserves its own slot for
397
+ // the clear ✕, so gating this on "select mode" instead would both
398
+ // over-ride that reserve with the wrong width and leave a SEARCH-mode
399
+ // (`icon`) combobox with no gutter at all.
400
+ showChevron ? styles.withChevron : undefined,
397
401
  // Gate on `showList`, not `open`: an empty query with no options has
398
402
  // `open=true` but renders NO dropdown, so a ring tied to `open` would
399
403
  // show with nothing below it AND linger after blur (no popover fires a
@@ -408,7 +412,7 @@ export function ComboboxInput(props: ComboboxInputProps) {
408
412
  />
409
413
  {showChevron ? (
410
414
  <View style={styles.chevron}>
411
- <Icon name="chevrons-up-down" size={16} color={colors.zinc["400"]} />
415
+ <Icon name="chevrons-up-down" size={CHEVRON_SIZE} color={colors.zinc["400"]} />
412
416
  </View>
413
417
  ) : null}
414
418
  </View>
@@ -562,17 +566,23 @@ export function ComboboxFooter(props: { children: ReactNode }) {
562
566
  return <View style={styles.footer}>{props.children}</View>;
563
567
  }
564
568
 
569
+ /** The chevron is absolutely positioned — out of flow — so the text reserves the
570
+ * span it occupies: the offset, the glyph, and a gap (a bare `Icon` carries no
571
+ * interior padding of its own). One source for the offset and the reserve. */
572
+ const CHEVRON_INSET = 10;
573
+ const CHEVRON_SIZE = 16;
574
+ const CHEVRON_GUTTER = CHEVRON_INSET + CHEVRON_SIZE + 6;
575
+
565
576
  const styles = StyleSheet.create({
566
577
  openRing: {
567
578
  boxShadow: FOCUS_RING,
568
579
  },
569
- // Select mode: room on the right for the trailing chevron.
570
- selectInput: {
571
- paddingRight: 32,
580
+ withChevron: {
581
+ paddingRight: CHEVRON_GUTTER,
572
582
  },
573
583
  chevron: {
574
584
  position: "absolute",
575
- right: 10,
585
+ right: CHEVRON_INSET,
576
586
  top: 0,
577
587
  bottom: 0,
578
588
  justifyContent: "center",
@@ -8,6 +8,7 @@ import { IconButton } from "./icon_button";
8
8
  import { FOCUS_RING } from "./control_surface";
9
9
  import { useFocusRing } from "./use_focus_ring";
10
10
  import { useLoticsLocale, useLocaleTag } from "./locale";
11
+ import { nextRangeSelection } from "./date_range_selection";
11
12
 
12
13
  /** Accessible names for the calendar's month-navigation arrows. Backs the
13
14
  * `calendar` locale slice. (The month/weekday NAMES come from `Intl` + the
@@ -529,28 +530,14 @@ function RangeCalendar(props: RangeCalendarInternalProps) {
529
530
  const rightYear = leftMonth === 11 ? leftYear + 1 : leftYear;
530
531
  const rightMonth = leftMonth === 11 ? 0 : leftMonth + 1;
531
532
 
532
- // Track which part of range we're selecting
533
- const [selectingEnd, setSelectingEnd] = useState(false);
534
-
533
+ // Which half of the range a click lands on is READ from the value (a range is
534
+ // open exactly when it has a start and no end), never tracked alongside it —
535
+ // a separate flag can disagree with the value the parent actually holds.
535
536
  const handleDateSelect = useCallback(
536
537
  (date: Date) => {
537
- if (!value.start || (value.start && value.end) || !selectingEnd) {
538
- // Start new selection
539
- onValueChange({ start: date, end: null });
540
- setSelectingEnd(true);
541
- } else {
542
- // Complete selection
543
- const start = value.start;
544
- // Ensure start is before end
545
- if (date < start) {
546
- onValueChange({ start: date, end: start });
547
- } else {
548
- onValueChange({ start, end: date });
549
- }
550
- setSelectingEnd(false);
551
- }
538
+ onValueChange(nextRangeSelection(value, date));
552
539
  },
553
- [value, onValueChange, selectingEnd],
540
+ [value, onValueChange],
554
541
  );
555
542
 
556
543
  const handleMonthChange = useCallback((year: number, month: number) => {