@startsimpli/ui 0.4.145 → 0.4.146

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/ui",
3
- "version": "0.4.145",
3
+ "version": "0.4.146",
4
4
  "description": "Shared UI components package for StartSimpli applications",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -152,8 +152,8 @@
152
152
  "tailwindcss": "^3.4.19",
153
153
  "tsup": "^8.5.1",
154
154
  "typescript": "^6.0.3",
155
- "@startsimpli/i18n": "0.1.3",
156
- "@startsimpli/realtime": "0.0.2"
155
+ "@startsimpli/realtime": "0.0.2",
156
+ "@startsimpli/i18n": "0.1.3"
157
157
  },
158
158
  "keywords": [
159
159
  "ui",
@@ -0,0 +1,89 @@
1
+ /**
2
+ * SegmentedControl (bd startsim-whwxd.17): a one-of-N switch that reads as a
3
+ * control, not a row of tiny links. Built on the package's Tabs primitive, so
4
+ * it inherits the roving focus and arrow keys rather than re-implementing them.
5
+ */
6
+ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
7
+ import { useState } from 'react'
8
+
9
+ import { SegmentedControl } from '../ui/segmented-control'
10
+
11
+ function Harness({ onChange = () => {}, disabledB = false }: { onChange?: (v: string) => void; disabledB?: boolean }) {
12
+ const [v, setV] = useState('a')
13
+ return (
14
+ <div>
15
+ <SegmentedControl
16
+ aria-label="Surface"
17
+ value={v}
18
+ onValueChange={(next) => {
19
+ setV(next)
20
+ onChange(next)
21
+ }}
22
+ options={[
23
+ { value: 'a', label: 'Read' },
24
+ { value: 'b', label: 'Edit', disabled: disabledB, title: disabledB ? 'Not allowed' : undefined },
25
+ { value: 'c', label: 'Suggest' },
26
+ ]}
27
+ controls="region-1"
28
+ />
29
+ <div id="region-1">panel</div>
30
+ </div>
31
+ )
32
+ }
33
+
34
+ describe('SegmentedControl', () => {
35
+ it('is a named tablist whose selected segment is announced', () => {
36
+ render(<Harness />)
37
+ const list = screen.getByRole('tablist', { name: 'Surface' })
38
+ expect(list).toBeInTheDocument()
39
+ expect(screen.getByRole('tab', { name: 'Read' })).toHaveAttribute('aria-selected', 'true')
40
+ expect(screen.getByRole('tab', { name: 'Edit' })).toHaveAttribute('aria-selected', 'false')
41
+ })
42
+
43
+ it('points every segment at the region it drives, not at a panel that does not exist', () => {
44
+ render(<Harness />)
45
+ for (const name of ['Read', 'Edit', 'Suggest']) {
46
+ expect(screen.getByRole('tab', { name })).toHaveAttribute('aria-controls', 'region-1')
47
+ }
48
+ })
49
+
50
+ it('selects on click (screen readers and automation click, they do not mousedown)', () => {
51
+ const onChange = jest.fn()
52
+ render(<Harness onChange={onChange} />)
53
+ fireEvent.click(screen.getByRole('tab', { name: 'Edit' }))
54
+ expect(onChange).toHaveBeenLastCalledWith('b')
55
+ expect(screen.getByRole('tab', { name: 'Edit' })).toHaveAttribute('aria-selected', 'true')
56
+ })
57
+
58
+ it('moves and selects with the arrow keys, one tab stop for the whole control', async () => {
59
+ const onChange = jest.fn()
60
+ render(<Harness onChange={onChange} />)
61
+ const read = screen.getByRole('tab', { name: 'Read' })
62
+ act(() => read.focus())
63
+ // Once inside, only the focused segment is in the tab order.
64
+ expect(read).toHaveAttribute('tabindex', '0')
65
+ expect(screen.getByRole('tab', { name: 'Edit' })).toHaveAttribute('tabindex', '-1')
66
+ fireEvent.keyDown(read, { key: 'ArrowRight' })
67
+ // Radix moves focus on the next tick; focusing a segment selects it.
68
+ await waitFor(() => expect(screen.getByRole('tab', { name: 'Edit' })).toHaveFocus())
69
+ expect(onChange).toHaveBeenLastCalledWith('b')
70
+ })
71
+
72
+ it('a disabled segment cannot be chosen and says why', () => {
73
+ const onChange = jest.fn()
74
+ render(<Harness onChange={onChange} disabledB />)
75
+ const edit = screen.getByRole('tab', { name: 'Edit' })
76
+ expect(edit).toBeDisabled()
77
+ expect(edit).toHaveAttribute('title', 'Not allowed')
78
+ fireEvent.click(edit)
79
+ expect(onChange).not.toHaveBeenCalled()
80
+ })
81
+
82
+ it('is readable: at least 36px tall segments in body-size type, with a distinct selected style', () => {
83
+ render(<Harness />)
84
+ const read = screen.getByRole('tab', { name: 'Read' })
85
+ expect(read.className).toMatch(/\bmin-h-9\b/)
86
+ expect(read.className).toMatch(/\btext-sm\b/)
87
+ expect(read.className).toMatch(/data-\[state=active\]:bg-background/)
88
+ })
89
+ })
@@ -21,10 +21,18 @@
21
21
  * offset would describe other text) and not a version loaded once and never
22
22
  * advanced (every read after the first save would be held back as stale).
23
23
  *
24
+ * OPENS READY TO TYPE (bd startsim-whwxd.17). The source editor is the default
25
+ * surface for every language: an editor lands in editing mode, a view-only
26
+ * reviewer in suggesting mode, and with `autoFocus` the caret is already in
27
+ * the text. Read is one choice away. Nothing is remembered between visits, so
28
+ * no stored "last surface" can put a click back in front of typing.
29
+ *
24
30
  * LAYOUT on its own container, never the viewport (startsim-jkkn7.12): the
25
31
  * rail sits beside the text when the field is wide, under it when narrow.
26
32
  */
27
- import { forwardRef, useCallback, useImperativeHandle, useMemo, useRef, useState, type ReactNode, type Ref } from 'react';
33
+ import { forwardRef, useCallback, useEffect, useId, useImperativeHandle, useMemo, useRef, useState, type ReactNode, type Ref } from 'react';
34
+
35
+ import { SegmentedControl } from '../ui/segmented-control';
28
36
 
29
37
  import { ChangesRail } from './ChangesRail';
30
38
  import { applyOpenSuggestions } from './final-text';
@@ -52,7 +60,12 @@ export interface TrackedFieldProps {
52
60
  onChange?: (value: string) => void;
53
61
  /** See the file header. Default: accept at `version`. */
54
62
  runAccept?: (accept: (expectedVersion: number) => Promise<AcceptResponseWire>) => Promise<unknown>;
63
+ /** Default `source`: the field opens editable (whwxd.17). */
55
64
  defaultSurface?: TrackedFieldSurface;
65
+ /** Put the caret in the text on first mount, so typing needs no click.
66
+ * Give it to ONE field per page (the one on screen). Switching surfaces
67
+ * later never re-takes focus. */
68
+ autoFocus?: boolean;
56
69
  /** Extra controls for the header (word count, save state). */
57
70
  headerExtra?: ReactNode;
58
71
  /** The Read view's element, for a host that paints its own highlights. */
@@ -67,11 +80,12 @@ export interface TrackedFieldHandle {
67
80
  flush: () => Promise<void>;
68
81
  }
69
82
 
70
- const seg = (on: boolean) =>
83
+ /** An on/off toggle (Show changes, Show authors), sized like the segments. */
84
+ const toggle = (on: boolean) =>
71
85
  [
72
- 'inline-flex items-center px-2 py-1 text-xs transition-colors',
86
+ 'inline-flex min-h-9 items-center rounded-md border border-border px-3 py-1.5 text-sm font-medium transition-colors',
73
87
  'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
74
- on ? 'bg-primary text-primary-foreground' : 'bg-transparent text-muted-foreground hover:bg-muted',
88
+ on ? 'bg-secondary text-secondary-foreground' : 'bg-background text-muted-foreground hover:text-foreground',
75
89
  ].join(' ');
76
90
 
77
91
  export const TrackedField = forwardRef<TrackedFieldHandle, TrackedFieldProps>(function TrackedField(
@@ -86,7 +100,8 @@ export const TrackedField = forwardRef<TrackedFieldHandle, TrackedFieldProps>(fu
86
100
  canEdit = true,
87
101
  onChange,
88
102
  runAccept,
89
- defaultSurface,
103
+ defaultSurface = 'source',
104
+ autoFocus = false,
90
105
  headerExtra,
91
106
  readViewRef,
92
107
  className,
@@ -94,7 +109,15 @@ export const TrackedField = forwardRef<TrackedFieldHandle, TrackedFieldProps>(fu
94
109
  ref,
95
110
  ) {
96
111
  const tc = useTrackChanges({ client, field, value, version });
97
- const [surface, setSurface] = useState<TrackedFieldSurface>(defaultSurface ?? (language === 'markdown' ? 'read' : 'source'));
112
+ const [surface, setSurface] = useState<TrackedFieldSurface>(defaultSurface);
113
+ // Focus is taken once, by the editor that mounts with the page. After the
114
+ // first commit this is false, so an editor re-mounted by a surface switch
115
+ // leaves focus on the control the reviewer just used.
116
+ const [focusOnMount, setFocusOnMount] = useState(autoFocus);
117
+ useEffect(() => {
118
+ setFocusOnMount(false);
119
+ }, []);
120
+ const bodyId = useId();
98
121
  const [view, setView] = useState<TrackedFieldView>('current');
99
122
  const [showChanges, setShowChanges] = useState(true);
100
123
  const [showAuthors, setShowAuthors] = useState(false);
@@ -160,46 +183,36 @@ export const TrackedField = forwardRef<TrackedFieldHandle, TrackedFieldProps>(fu
160
183
  <h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{label}</h3>
161
184
  <div className="flex flex-wrap items-center gap-2">
162
185
  {headerExtra}
163
- <div role="group" aria-label={`${label}: which text`} className="inline-flex overflow-hidden rounded-md border border-border">
164
- <button type="button" className={seg(view === 'current')} aria-pressed={view === 'current'} onClick={() => setView('current')}>
165
- Current
166
- </button>
167
- <button
168
- type="button"
169
- className={seg(view === 'final')}
170
- aria-pressed={view === 'final'}
171
- onClick={() => setView('final')}
172
- title="As it would read with every open suggestion accepted"
173
- >
174
- Final
175
- </button>
176
- <button
177
- type="button"
178
- className={seg(view === 'original')}
179
- aria-pressed={view === 'original'}
180
- onClick={() => setView('original')}
181
- title="As stored, without any suggestion"
182
- >
183
- Original
184
- </button>
185
- </div>
186
+ <SegmentedControl<TrackedFieldView>
187
+ aria-label={`${label}: which text`}
188
+ value={view}
189
+ onValueChange={setView}
190
+ controls={bodyId}
191
+ options={[
192
+ { value: 'current', label: 'Current' },
193
+ { value: 'final', label: 'Final', title: 'As it would read with every open suggestion accepted' },
194
+ { value: 'original', label: 'Original', title: 'As stored, without any suggestion' },
195
+ ]}
196
+ />
186
197
  {marked && (
187
- <div role="group" aria-label={`${label}: surface`} className="inline-flex overflow-hidden rounded-md border border-border">
188
- <button type="button" className={seg(surface === 'read')} aria-pressed={surface === 'read'} onClick={() => setSurface('read')}>
189
- Read
190
- </button>
191
- <button type="button" className={seg(surface === 'source')} aria-pressed={surface === 'source'} onClick={() => setSurface('source')}>
192
- {canEdit ? 'Edit' : 'Suggest'}
193
- </button>
194
- </div>
198
+ <SegmentedControl<TrackedFieldSurface>
199
+ aria-label={`${label}: surface`}
200
+ value={surface}
201
+ onValueChange={setSurface}
202
+ controls={bodyId}
203
+ options={[
204
+ { value: 'read', label: 'Read' },
205
+ { value: 'source', label: canEdit ? 'Edit' : 'Suggest' },
206
+ ]}
207
+ />
195
208
  )}
196
209
  {marked && (
197
- <button type="button" className={`${seg(showChanges)} rounded-md border border-border`} aria-pressed={showChanges} onClick={() => setShowChanges((v) => !v)}>
210
+ <button type="button" className={toggle(showChanges)} aria-pressed={showChanges} onClick={() => setShowChanges((v) => !v)}>
198
211
  Show changes
199
212
  </button>
200
213
  )}
201
214
  {marked && surface === 'read' && (
202
- <button type="button" className={`${seg(showAuthors)} rounded-md border border-border`} aria-pressed={showAuthors} onClick={() => setShowAuthors((v) => !v)}>
215
+ <button type="button" className={toggle(showAuthors)} aria-pressed={showAuthors} onClick={() => setShowAuthors((v) => !v)}>
203
216
  Show authors
204
217
  </button>
205
218
  )}
@@ -207,7 +220,7 @@ export const TrackedField = forwardRef<TrackedFieldHandle, TrackedFieldProps>(fu
207
220
  </div>
208
221
 
209
222
  <div className="grid gap-4 p-4 @3xl:grid-cols-[minmax(0,1fr)_18rem]">
210
- <div className="min-w-0">
223
+ <div className="min-w-0" id={bodyId}>
211
224
  {!marked ? (
212
225
  <>
213
226
  <p className="mb-2 text-xs text-muted-foreground" role="status">
@@ -254,6 +267,7 @@ export const TrackedField = forwardRef<TrackedFieldHandle, TrackedFieldProps>(fu
254
267
  show={{ suggestions: showChanges, comments: showChanges }}
255
268
  showAuthors={showAuthors}
256
269
  onShowAuthorsChange={setShowAuthors}
270
+ autoFocus={focusOnMount}
257
271
  onChange={onChange}
258
272
  onPropose={tc.propose}
259
273
  onActiveChange={(m) => setActive(m && m.kind !== 'blame' && m.kind !== 'local' ? m.id : null)}
@@ -70,6 +70,7 @@ import {
70
70
  type TrackKeys,
71
71
  type TrackMark,
72
72
  } from './extension';
73
+ import { SegmentedControl } from '../ui/segmented-control';
73
74
  import type { BlameSpan, CommentThread, SelectionAnchor, Suggestion } from './types';
74
75
 
75
76
  export type TrackMode = 'editing' | 'suggesting';
@@ -131,6 +132,11 @@ export interface TrackedTextEditorProps {
131
132
  keys?: TrackKeys;
132
133
  /** Render the mode and authors toolbar. Default true. */
133
134
  toolbar?: boolean;
135
+ /** Put the caret in the text when the editor mounts, so the reviewer can
136
+ * type without a click (bd startsim-whwxd.17). Read once, at mount; it
137
+ * never takes focus from another control that already has it. Escape
138
+ * gives focus back to the page. */
139
+ autoFocus?: boolean;
134
140
  className?: string;
135
141
  editorClassName?: string;
136
142
  }
@@ -217,6 +223,7 @@ export const TrackedTextEditor = forwardRef<TrackedTextEditorHandle, TrackedText
217
223
  const activeRef = useRef<TrackMark | null>(null);
218
224
 
219
225
  const helpId = useId();
226
+ const hostId = useId();
220
227
  const [innerMode, setInnerMode] = useState<TrackMode>(defaultMode ?? (canEdit ? 'editing' : 'suggesting'));
221
228
  const mode: TrackMode = !canEdit ? 'suggesting' : (modeProp ?? innerMode);
222
229
  const [innerShowAuthors, setInnerShowAuthors] = useState(defaultShowAuthors);
@@ -324,6 +331,18 @@ export const TrackedTextEditor = forwardRef<TrackedTextEditorHandle, TrackedText
324
331
  });
325
332
 
326
333
  const k = { ...DEFAULT_KEYS, ...p.keys };
334
+ // Escape leaves the text, so a page's single-key shortcuts (which stand
335
+ // down while focus is in an editable element) are one key away even
336
+ // when the editor took focus on load.
337
+ const escapeLeaves = keymap.of([
338
+ {
339
+ key: 'Escape',
340
+ run: (v) => {
341
+ v.contentDOM.blur();
342
+ return true;
343
+ },
344
+ },
345
+ ]);
327
346
  const view = new EditorView({
328
347
  parent: host,
329
348
  state: EditorState.create({
@@ -331,6 +350,7 @@ export const TrackedTextEditor = forwardRef<TrackedTextEditorHandle, TrackedText
331
350
  extensions: [
332
351
  trackChanges({ keys: k }),
333
352
  compartments.history.of(history()),
353
+ escapeLeaves,
334
354
  keymap.of([...defaultKeymap, ...historyKeymap]),
335
355
  EditorView.lineWrapping,
336
356
  compartments.language.of([]),
@@ -343,6 +363,10 @@ export const TrackedTextEditor = forwardRef<TrackedTextEditorHandle, TrackedText
343
363
  }),
344
364
  });
345
365
  viewRef.current = view;
366
+ if (p.autoFocus && typeof document !== 'undefined') {
367
+ const current = document.activeElement;
368
+ if (!current || current === document.body) view.contentDOM.focus({ preventScroll: true });
369
+ }
346
370
  return () => {
347
371
  void flush();
348
372
  view.destroy();
@@ -491,10 +515,10 @@ export const TrackedTextEditor = forwardRef<TrackedTextEditorHandle, TrackedText
491
515
  const k = { ...DEFAULT_KEYS, ...keys };
492
516
  const segment = (on: boolean) =>
493
517
  [
494
- 'rounded px-2.5 py-1 text-sm font-medium transition-colors',
518
+ 'min-h-9 rounded-md border border-border px-3 py-1.5 text-sm font-medium transition-colors',
495
519
  'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
496
520
  'disabled:cursor-not-allowed disabled:opacity-50',
497
- on ? 'bg-secondary text-secondary-foreground' : 'text-muted-foreground hover:text-foreground',
521
+ on ? 'bg-secondary text-secondary-foreground' : 'bg-background text-muted-foreground hover:text-foreground',
498
522
  ].join(' ');
499
523
 
500
524
  const suggestDisabledWhy =
@@ -527,28 +551,26 @@ export const TrackedTextEditor = forwardRef<TrackedTextEditorHandle, TrackedText
527
551
  <div data-testid="tracked-text-editor" className={['@container flex flex-col gap-2', className].filter(Boolean).join(' ')}>
528
552
  {toolbar && (
529
553
  <div className="flex flex-col gap-2 @md:flex-row @md:items-center @md:justify-between">
530
- <div role="group" aria-label="Mode" className="inline-flex w-fit gap-0.5 rounded-md border border-border p-0.5">
531
- <button
532
- type="button"
533
- className={segment(mode === 'editing')}
534
- aria-pressed={mode === 'editing'}
535
- disabled={!canEdit || readOnly}
536
- title={!canEdit ? 'You can suggest changes to this field but not edit it.' : undefined}
537
- onClick={() => setMode('editing')}
538
- >
539
- Editing
540
- </button>
541
- <button
542
- type="button"
543
- className={segment(mode === 'suggesting')}
544
- aria-pressed={mode === 'suggesting'}
545
- disabled={mode !== 'suggesting' && !!suggestDisabledWhy}
546
- title={suggestDisabledWhy}
547
- onClick={() => setMode('suggesting')}
548
- >
549
- Suggesting
550
- </button>
551
- </div>
554
+ <SegmentedControl<TrackMode>
555
+ aria-label={`${label}: mode`}
556
+ value={mode}
557
+ onValueChange={setMode}
558
+ controls={hostId}
559
+ options={[
560
+ {
561
+ value: 'editing',
562
+ label: 'Editing',
563
+ disabled: !canEdit || readOnly,
564
+ title: !canEdit ? 'You can suggest changes to this field but not edit it.' : undefined,
565
+ },
566
+ {
567
+ value: 'suggesting',
568
+ label: 'Suggesting',
569
+ disabled: mode !== 'suggesting' && !!suggestDisabledWhy,
570
+ title: suggestDisabledWhy,
571
+ },
572
+ ]}
573
+ />
552
574
  <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
553
575
  {proposalLine && <span data-testid="proposal-state">{proposalLine}</span>}
554
576
  <button
@@ -564,6 +586,7 @@ export const TrackedTextEditor = forwardRef<TrackedTextEditorHandle, TrackedText
564
586
  )}
565
587
  <div
566
588
  ref={hostRef}
589
+ id={hostId}
567
590
  className={
568
591
  editorClassName ??
569
592
  'rounded-md border border-input bg-background text-sm leading-relaxed focus-within:ring-2 focus-within:ring-ring'
@@ -0,0 +1,166 @@
1
+ /**
2
+ * A tracked field opens ready to type (bd startsim-whwxd.17).
3
+ *
4
+ * Quinn, 2026-10-08: "you shouldnt need to click 2 buttons to start editting
5
+ * something." Before this, the blog opened in the rendered Read view: Edit,
6
+ * then a click into the text, before a key reached the field. These pin the
7
+ * zero-click landing for both roles, and that landing writes nothing: no
8
+ * PATCH-bound onChange and no suggestion POST from merely opening the page.
9
+ */
10
+ import { act, fireEvent, render, screen } from '@testing-library/react'
11
+
12
+ // react-markdown is ESM-only; the Read view's renderer is not under test here.
13
+ jest.mock('../../blog/MarkdownRenderer', () => ({
14
+ MarkdownRenderer: ({ content }: { content: string }) => <div data-testid="md">{content}</div>,
15
+ }))
16
+
17
+ import { TrackedField } from '../TrackedField'
18
+ import type { TrackChangesClient } from '../types'
19
+
20
+ const { EditorView } = jest.requireActual('@codemirror/view')
21
+
22
+ beforeAll(() => {
23
+ // jsdom has no layout; CodeMirror measures text with these.
24
+ const rect = { x: 0, y: 0, top: 0, left: 0, right: 0, bottom: 0, width: 0, height: 0, toJSON() {} }
25
+ Range.prototype.getBoundingClientRect = () => rect as DOMRect
26
+ Range.prototype.getClientRects = () => ({ length: 0, item: () => null, [Symbol.iterator]: [][Symbol.iterator] }) as unknown as DOMRectList
27
+ })
28
+
29
+ const TEXT = 'Hello world. A second sentence.'
30
+ const page = <T,>(results: T[]) => ({ offset_unit: 'utf16', version: 3, next_cursor: null, results })
31
+
32
+ function client(): TrackChangesClient {
33
+ return {
34
+ listSuggestions: jest.fn().mockResolvedValue(page([])),
35
+ listComments: jest.fn().mockResolvedValue(page([])),
36
+ blame: jest.fn().mockResolvedValue({ offset_unit: 'utf16', version: 3, spans: [] }),
37
+ propose: jest.fn().mockResolvedValue({}),
38
+ accept: jest.fn().mockResolvedValue({ version: 4 }),
39
+ comment: jest.fn().mockResolvedValue({}),
40
+ } as unknown as TrackChangesClient
41
+ }
42
+
43
+ function editorView() {
44
+ const el = document.querySelector('.cm-editor') as HTMLElement | null
45
+ return el ? EditorView.findFromDOM(el) : null
46
+ }
47
+
48
+ /** Type the way a keystroke does, at the caret, without a click first. */
49
+ function typeAtCaret(text: string) {
50
+ const view = editorView()!
51
+ act(() => {
52
+ view.dispatch(view.state.replaceSelection(text), { userEvent: 'input.type' })
53
+ })
54
+ }
55
+
56
+ describe('a tracked field opens ready to type (whwxd.17)', () => {
57
+ it('an editor lands in the source editor, editing mode, focused: typing works with zero clicks', () => {
58
+ const onChange = jest.fn()
59
+ render(
60
+ <TrackedField client={client()} field="blog" label="Blog post" language="markdown" value={TEXT} version={3} currentActorSub="ada" onChange={onChange} autoFocus />,
61
+ )
62
+ // The editor is mounted, not the Read view.
63
+ expect(screen.getByTestId('tracked-text-editor')).toBeInTheDocument()
64
+ expect(screen.queryByTestId('tracked-read-view')).toBeNull()
65
+ const view = editorView()!
66
+ expect(view.state.readOnly).toBe(false)
67
+ // Focus is already in the text: no click placed it.
68
+ expect(view.contentDOM.contains(document.activeElement)).toBe(true)
69
+ // The surface control says Edit, the mode control says Editing.
70
+ expect(screen.getByRole('tab', { name: 'Edit' })).toHaveAttribute('aria-selected', 'true')
71
+ expect(screen.getByRole('tab', { name: 'Editing' })).toHaveAttribute('aria-selected', 'true')
72
+
73
+ typeAtCaret('Hi. ')
74
+ expect(onChange).toHaveBeenLastCalledWith('Hi. ' + TEXT)
75
+ })
76
+
77
+ it('a viewer lands in suggesting mode, focused, and typing makes a proposal, never an edit', () => {
78
+ jest.useFakeTimers()
79
+ try {
80
+ const onChange = jest.fn()
81
+ const c = client()
82
+ render(
83
+ <TrackedField client={c} field="blog" label="Blog post" value={TEXT} version={3} currentActorSub="vee" canEdit={false} onChange={onChange} autoFocus />,
84
+ )
85
+ const view = editorView()!
86
+ expect(view.state.readOnly).toBe(false)
87
+ expect(view.contentDOM.contains(document.activeElement)).toBe(true)
88
+ expect(screen.getByRole('tab', { name: 'Suggest' })).toHaveAttribute('aria-selected', 'true')
89
+ expect(screen.getByRole('tab', { name: 'Suggesting' })).toHaveAttribute('aria-selected', 'true')
90
+ expect(screen.getByRole('tab', { name: 'Editing' })).toBeDisabled()
91
+
92
+ typeAtCaret('Hi. ')
93
+ expect(onChange).not.toHaveBeenCalled()
94
+ } finally {
95
+ jest.useRealTimers()
96
+ }
97
+ })
98
+
99
+ it('opening the field writes nothing: no edit reported, no suggestion posted, even after blur and every debounce', async () => {
100
+ jest.useFakeTimers()
101
+ try {
102
+ for (const canEdit of [true, false]) {
103
+ const onChange = jest.fn()
104
+ const c = client()
105
+ const { rerender, unmount } = render(
106
+ <TrackedField client={c} field="blog" label="Blog post" value={TEXT} version={3} currentActorSub="ada" canEdit={canEdit} onChange={onChange} autoFocus />,
107
+ )
108
+ // The stored text and version move under it (a save echo, an accept
109
+ // elsewhere): still nobody typed.
110
+ rerender(
111
+ <TrackedField client={c} field="blog" label="Blog post" value={TEXT + ' More.'} version={4} currentActorSub="ada" canEdit={canEdit} onChange={onChange} autoFocus />,
112
+ )
113
+ await act(async () => {
114
+ jest.advanceTimersByTime(10_000)
115
+ })
116
+ // Focus leaves the text (a suggesting-mode blur posts a pending proposal).
117
+ fireEvent.blur(editorView()!.contentDOM)
118
+ await act(async () => {
119
+ jest.advanceTimersByTime(10_000)
120
+ })
121
+ expect(onChange).not.toHaveBeenCalled()
122
+ expect(c.propose).not.toHaveBeenCalled()
123
+ unmount()
124
+ expect(c.propose).not.toHaveBeenCalled()
125
+ }
126
+ } finally {
127
+ jest.useRealTimers()
128
+ }
129
+ })
130
+
131
+ it('does not take focus unless the host asks (two fields on one page cannot both have it)', () => {
132
+ render(<TrackedField client={client()} field="blog" label="Blog post" value={TEXT} version={3} currentActorSub="ada" />)
133
+ expect(screen.getByTestId('tracked-text-editor')).toBeInTheDocument()
134
+ expect(editorView()!.contentDOM.contains(document.activeElement)).toBe(false)
135
+ })
136
+
137
+ it('does not steal focus from a control the reviewer is already in', () => {
138
+ const outside = document.createElement('input')
139
+ document.body.appendChild(outside)
140
+ outside.focus()
141
+ try {
142
+ render(<TrackedField client={client()} field="blog" label="Blog post" value={TEXT} version={3} currentActorSub="ada" autoFocus />)
143
+ expect(document.activeElement).toBe(outside)
144
+ } finally {
145
+ outside.remove()
146
+ }
147
+ })
148
+
149
+ it('Escape hands focus back to the page, so its single-key shortcuts work again', () => {
150
+ render(<TrackedField client={client()} field="blog" label="Blog post" value={TEXT} version={3} currentActorSub="ada" autoFocus />)
151
+ const view = editorView()!
152
+ expect(view.contentDOM.contains(document.activeElement)).toBe(true)
153
+ fireEvent.keyDown(view.contentDOM, { key: 'Escape' })
154
+ expect(view.contentDOM.contains(document.activeElement)).toBe(false)
155
+ })
156
+
157
+ it('Read is still one choice away, and switching to it does not pull focus back into an editor', () => {
158
+ render(<TrackedField client={client()} field="blog" label="Blog post" value={TEXT} version={3} currentActorSub="ada" autoFocus />)
159
+ fireEvent.mouseDown(screen.getByRole('tab', { name: 'Read' }))
160
+ expect(screen.getByTestId('tracked-read-view')).toBeInTheDocument()
161
+ expect(screen.queryByTestId('tracked-text-editor')).toBeNull()
162
+ fireEvent.mouseDown(screen.getByRole('tab', { name: 'Edit' }))
163
+ expect(screen.getByTestId('tracked-text-editor')).toBeInTheDocument()
164
+ expect(editorView()!.contentDOM.contains(document.activeElement)).toBe(false)
165
+ })
166
+ })
@@ -554,10 +554,10 @@ describe('TrackedField', () => {
554
554
  it('shows Final with the open suggestions applied, and Original as stored', async () => {
555
555
  render(<TrackedField client={client()} field="blog" label="Blog" language="plain" value={TEXT} version={3} currentActorSub="ada" defaultSurface="read" />)
556
556
  await screen.findByRole('button', { name: 'Accept' })
557
- fireEvent.click(screen.getByRole('button', { name: 'Final' }))
557
+ fireEvent.click(screen.getByRole('tab', { name: 'Final' }))
558
558
  expect(screen.getByTestId('tracked-read-view')).toHaveTextContent('Hello world. One second sentence. The end.')
559
559
  expect(screen.getByText(/as it would read with 1 open suggestion accepted/i)).toBeInTheDocument()
560
- fireEvent.click(screen.getByRole('button', { name: 'Original' }))
560
+ fireEvent.click(screen.getByRole('tab', { name: 'Original' }))
561
561
  expect(screen.getByTestId('tracked-read-view')).toHaveTextContent(TEXT)
562
562
  expect(screen.getByTestId('tracked-read-view').querySelector('del')).toBeNull()
563
563
  })
@@ -574,7 +574,7 @@ describe('TrackedField', () => {
574
574
  render(<TrackedField client={client()} field="blog" label="Blog" language="plain" value={TEXT} version={3} currentActorSub="vee" canEdit={false} defaultSurface="read" />)
575
575
  await screen.findByTestId('rail-suggestion')
576
576
  expect(screen.queryByRole('button', { name: 'Accept' })).toBeNull()
577
- expect(screen.getByRole('button', { name: 'Suggest' })).toBeInTheDocument()
577
+ expect(screen.getByRole('tab', { name: 'Suggest' })).toBeInTheDocument()
578
578
  expect(screen.getByLabelText('New comment on Blog')).toBeInTheDocument()
579
579
  })
580
580
 
@@ -132,7 +132,7 @@ describe('suggesting mode', () => {
132
132
  const { ref, onPropose, onChange } = mountSuggesting({ onModeChange })
133
133
  typeAt(ref.current!, 0, 'X')
134
134
  await act(async () => {
135
- fireEvent.click(screen.getByRole('button', { name: 'Editing' }))
135
+ fireEvent.click(screen.getByRole('tab', { name: 'Editing' }))
136
136
  })
137
137
  expect(onPropose).toHaveBeenCalledWith('X' + VALUE, 7)
138
138
  expect(onModeChange).toHaveBeenCalledWith('editing')
@@ -172,8 +172,8 @@ describe('who may do what', () => {
172
172
  const onChange = jest.fn()
173
173
  const ref = createRef<TrackedTextEditorHandle>()
174
174
  render(<TrackedTextEditor ref={ref} label="Blog" value={VALUE} version={1} currentActorSub="v" canEdit={false} onChange={onChange} onPropose={jest.fn()} />)
175
- expect(screen.getByRole('button', { name: 'Editing' })).toBeDisabled()
176
- expect(screen.getByRole('button', { name: 'Suggesting' })).toHaveAttribute('aria-pressed', 'true')
175
+ expect(screen.getByRole('tab', { name: 'Editing' })).toBeDisabled()
176
+ expect(screen.getByRole('tab', { name: 'Suggesting' })).toHaveAttribute('aria-selected', 'true')
177
177
  typeAt(ref.current!, 0, 'X')
178
178
  expect(onChange).not.toHaveBeenCalled()
179
179
  })
@@ -182,12 +182,12 @@ describe('who may do what', () => {
182
182
  const ref = createRef<TrackedTextEditorHandle>()
183
183
  render(<TrackedTextEditor ref={ref} label="Blog" value={VALUE} version={1} currentActorSub="a" onChange={jest.fn()} onPropose={jest.fn()} />)
184
184
  typeAt(ref.current!, 0, 'X')
185
- expect(screen.getByRole('button', { name: 'Suggesting' })).toBeDisabled()
185
+ expect(screen.getByRole('tab', { name: 'Suggesting' })).toBeDisabled()
186
186
  })
187
187
 
188
188
  it('cannot suggest on a record with no version', () => {
189
189
  render(<TrackedTextEditor label="Blog" value={VALUE} version={null} currentActorSub="a" onChange={jest.fn()} onPropose={jest.fn()} />)
190
- expect(screen.getByRole('button', { name: 'Suggesting' })).toBeDisabled()
190
+ expect(screen.getByRole('tab', { name: 'Suggesting' })).toBeDisabled()
191
191
  })
192
192
  })
193
193
 
@@ -407,7 +407,7 @@ describe('switching into suggesting mode', () => {
407
407
  const ref = createRef<TrackedTextEditorHandle>()
408
408
  render(<TrackedTextEditor ref={ref} label="Blog" value={VALUE} version={2} currentActorSub="ada" onChange={jest.fn()} onPropose={onPropose} />)
409
409
  act(() => ref.current!.view!.focus())
410
- const button = screen.getByRole('button', { name: 'Suggesting' })
410
+ const button = screen.getByRole('tab', { name: 'Suggesting' })
411
411
  await act(async () => {
412
412
  button.focus() // the editor blurs, as a real click does
413
413
  fireEvent.click(button)
@@ -37,6 +37,7 @@ export * from './separator'
37
37
  export * from './skeleton'
38
38
  export * from './table'
39
39
  export * from './tabs'
40
+ export * from './segmented-control'
40
41
  export * from './textarea'
41
42
  export * from './tooltip'
42
43
  export * from './feature-gate'
@@ -0,0 +1,93 @@
1
+ "use client"
2
+
3
+ /**
4
+ * SegmentedControl — choose one of a few options that switch what a region
5
+ * shows or how it behaves (Read / Edit, Editing / Suggesting) (bd
6
+ * startsim-whwxd.17).
7
+ *
8
+ * BUILT ON THE PACKAGE'S TABS (./tabs, Radix), not beside it: the roving tab
9
+ * stop, arrow keys, Home/End and disabled-skipping come from there. What this
10
+ * adds is the look (readable, an obvious selected segment) and two fixes for
11
+ * using a tablist without TabsContent panels:
12
+ * - `controls` points every segment's aria-controls at the region it drives.
13
+ * Radix would otherwise point it at a generated panel id that never exists.
14
+ * - selection on CLICK as well as mousedown. Radix selects on mousedown only;
15
+ * screen readers in browse mode and automation send click.
16
+ *
17
+ * LAYOUT on the nearest `@container` ancestor, never the viewport: the
18
+ * segments share the full width when the container is narrow and sit inline
19
+ * from `@md` up. It does not declare a container itself: an inline-size
20
+ * container inside a flex row has no intrinsic width and collapses to zero.
21
+ */
22
+ import * as React from "react"
23
+
24
+ import { cn } from "../../lib/utils"
25
+ import { Tabs, TabsList, TabsTrigger } from "./tabs"
26
+
27
+ export interface SegmentedOption<T extends string = string> {
28
+ value: T
29
+ label: React.ReactNode
30
+ disabled?: boolean
31
+ /** Shown on hover; say why a disabled segment is disabled. */
32
+ title?: string
33
+ /** Accessible name when `label` is not plain text. */
34
+ "aria-label"?: string
35
+ }
36
+
37
+ export interface SegmentedControlProps<T extends string = string> {
38
+ value: T
39
+ onValueChange: (value: T) => void
40
+ options: ReadonlyArray<SegmentedOption<T>>
41
+ /** The control's accessible name ("Blog post: view"). Required. */
42
+ "aria-label": string
43
+ /** id of the element this control changes. */
44
+ controls?: string
45
+ className?: string
46
+ "data-testid"?: string
47
+ }
48
+
49
+ export function SegmentedControl<T extends string = string>({
50
+ value,
51
+ onValueChange,
52
+ options,
53
+ controls,
54
+ className,
55
+ "aria-label": ariaLabel,
56
+ "data-testid": testId,
57
+ }: SegmentedControlProps<T>) {
58
+ const select = (next: string) => {
59
+ if (next === value) return
60
+ const opt = options.find((o) => o.value === next)
61
+ if (!opt || opt.disabled) return
62
+ onValueChange(next as T)
63
+ }
64
+ return (
65
+ <Tabs value={value} onValueChange={select} className={cn("w-full @md:w-auto", className)}>
66
+ <TabsList
67
+ aria-label={ariaLabel}
68
+ data-testid={testId}
69
+ className="flex h-auto w-full gap-1 rounded-lg border border-border bg-muted p-1 text-muted-foreground @md:inline-flex @md:w-auto"
70
+ >
71
+ {options.map((o) => (
72
+ <TabsTrigger
73
+ key={o.value}
74
+ value={o.value}
75
+ disabled={o.disabled}
76
+ title={o.title}
77
+ aria-label={o["aria-label"]}
78
+ {...(controls ? { "aria-controls": controls } : {})}
79
+ onClick={() => select(o.value)}
80
+ className={cn(
81
+ "min-h-9 flex-1 rounded-md px-3.5 py-1.5 text-sm font-medium @md:flex-none",
82
+ "text-muted-foreground hover:bg-background/60 hover:text-foreground",
83
+ "data-[state=active]:bg-background data-[state=active]:font-semibold data-[state=active]:text-foreground data-[state=active]:shadow-sm data-[state=active]:ring-1 data-[state=active]:ring-border",
84
+ "disabled:cursor-not-allowed",
85
+ )}
86
+ >
87
+ {o.label}
88
+ </TabsTrigger>
89
+ ))}
90
+ </TabsList>
91
+ </Tabs>
92
+ )
93
+ }