@startsimpli/ui 0.4.144 → 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 +1 -1
- package/src/components/__tests__/segmented-control.test.tsx +89 -0
- package/src/components/track-changes/ChangesRail.tsx +16 -6
- package/src/components/track-changes/TrackedField.tsx +54 -40
- package/src/components/track-changes/TrackedTextEditor.tsx +47 -24
- package/src/components/track-changes/__tests__/edit-default.test.tsx +166 -0
- package/src/components/track-changes/__tests__/rails.test.tsx +15 -3
- package/src/components/track-changes/__tests__/tracked-text-editor.test.tsx +6 -6
- package/src/components/ui/index.ts +1 -0
- package/src/components/ui/segmented-control.tsx +93 -0
package/package.json
CHANGED
|
@@ -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
|
+
})
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
* Presentational: every write is a callback, so the host decides the
|
|
25
25
|
* precondition (the version an accept asserts) and folds the result.
|
|
26
26
|
*/
|
|
27
|
-
import { useId, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from 'react';
|
|
27
|
+
import { useId, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from 'react';
|
|
28
28
|
|
|
29
29
|
import { ActorIdentity, actorKindTitle } from '../ActorIdentity';
|
|
30
30
|
import { describeChange, describeSuggestion, describeThread, quoted, who } from './describe';
|
|
@@ -587,6 +587,20 @@ function Composer({
|
|
|
587
587
|
const [hi, setHi] = useState(0);
|
|
588
588
|
const ref = useRef<HTMLTextAreaElement>(null);
|
|
589
589
|
const listId = `mention-${useId()}`;
|
|
590
|
+
// Where the caret goes after a picked mention. Applied in a LAYOUT effect,
|
|
591
|
+
// i.e. synchronously after React writes the new value and before the
|
|
592
|
+
// browser can deliver another keystroke. A requestAnimationFrame here let a
|
|
593
|
+
// fast typist's next characters land at the end of the text and then jump
|
|
594
|
+
// (caught on the deployed gate: "...nly).(sandbox o").
|
|
595
|
+
const caretRef = useRef<number | null>(null);
|
|
596
|
+
useLayoutEffect(() => {
|
|
597
|
+
const at = caretRef.current;
|
|
598
|
+
const el = ref.current;
|
|
599
|
+
if (at == null || !el) return;
|
|
600
|
+
caretRef.current = null;
|
|
601
|
+
el.focus();
|
|
602
|
+
el.setSelectionRange(at, at);
|
|
603
|
+
}, [body]);
|
|
590
604
|
|
|
591
605
|
const matches = useMemo(() => {
|
|
592
606
|
if (!q) return [];
|
|
@@ -603,11 +617,7 @@ function Composer({
|
|
|
603
617
|
setBody(next);
|
|
604
618
|
setPicked((p) => (p.some((x) => x.sub === m.sub) ? p : [...p, m]));
|
|
605
619
|
setQ(null);
|
|
606
|
-
|
|
607
|
-
const pos = q.start + tag.length;
|
|
608
|
-
el?.focus();
|
|
609
|
-
el?.setSelectionRange(pos, pos);
|
|
610
|
-
});
|
|
620
|
+
caretRef.current = q.start + tag.length;
|
|
611
621
|
};
|
|
612
622
|
|
|
613
623
|
const submit = async () => {
|
|
@@ -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
|
-
|
|
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-
|
|
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-
|
|
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
|
|
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
|
-
<
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
<
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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={
|
|
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={
|
|
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-
|
|
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
|
-
<
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
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
|
+
})
|
|
@@ -373,6 +373,18 @@ describe('ChangesRail', () => {
|
|
|
373
373
|
await waitFor(() => expect(onComment).toHaveBeenCalledWith('Ask @rita@x.io', ['rita'], selection))
|
|
374
374
|
})
|
|
375
375
|
|
|
376
|
+
it('puts the caret right after a picked mention before the next keystroke can land', async () => {
|
|
377
|
+
render(<ChangesRail {...base} canAccept={false} onComment={jest.fn()} mentionable={[{ sub: 'rita', label: 'rita@x.io' }]} />)
|
|
378
|
+
const box = screen.getByLabelText('New comment on Blog') as HTMLTextAreaElement
|
|
379
|
+
fireEvent.change(box, { target: { value: 'Ask @ri and more', selectionStart: 7 } })
|
|
380
|
+
fireEvent.mouseDown(await screen.findByRole('option', { name: 'rita@x.io' }))
|
|
381
|
+
// Synchronously, with no animation frame in between: a fast typist's next
|
|
382
|
+
// character must go after the tag, not wherever the value update left it.
|
|
383
|
+
expect(box.value).toBe('Ask @rita@x.io and more')
|
|
384
|
+
expect(box.selectionStart).toBe('Ask @rita@x.io '.length)
|
|
385
|
+
expect(document.activeElement).toBe(box)
|
|
386
|
+
})
|
|
387
|
+
|
|
376
388
|
it('keeps the typed comment when the write fails', async () => {
|
|
377
389
|
const onComment = jest.fn().mockRejectedValue(new Error('Could not add the comment: no.'))
|
|
378
390
|
render(<ChangesRail {...base} canAccept={false} onComment={onComment} />)
|
|
@@ -542,10 +554,10 @@ describe('TrackedField', () => {
|
|
|
542
554
|
it('shows Final with the open suggestions applied, and Original as stored', async () => {
|
|
543
555
|
render(<TrackedField client={client()} field="blog" label="Blog" language="plain" value={TEXT} version={3} currentActorSub="ada" defaultSurface="read" />)
|
|
544
556
|
await screen.findByRole('button', { name: 'Accept' })
|
|
545
|
-
fireEvent.click(screen.getByRole('
|
|
557
|
+
fireEvent.click(screen.getByRole('tab', { name: 'Final' }))
|
|
546
558
|
expect(screen.getByTestId('tracked-read-view')).toHaveTextContent('Hello world. One second sentence. The end.')
|
|
547
559
|
expect(screen.getByText(/as it would read with 1 open suggestion accepted/i)).toBeInTheDocument()
|
|
548
|
-
fireEvent.click(screen.getByRole('
|
|
560
|
+
fireEvent.click(screen.getByRole('tab', { name: 'Original' }))
|
|
549
561
|
expect(screen.getByTestId('tracked-read-view')).toHaveTextContent(TEXT)
|
|
550
562
|
expect(screen.getByTestId('tracked-read-view').querySelector('del')).toBeNull()
|
|
551
563
|
})
|
|
@@ -562,7 +574,7 @@ describe('TrackedField', () => {
|
|
|
562
574
|
render(<TrackedField client={client()} field="blog" label="Blog" language="plain" value={TEXT} version={3} currentActorSub="vee" canEdit={false} defaultSurface="read" />)
|
|
563
575
|
await screen.findByTestId('rail-suggestion')
|
|
564
576
|
expect(screen.queryByRole('button', { name: 'Accept' })).toBeNull()
|
|
565
|
-
expect(screen.getByRole('
|
|
577
|
+
expect(screen.getByRole('tab', { name: 'Suggest' })).toBeInTheDocument()
|
|
566
578
|
expect(screen.getByLabelText('New comment on Blog')).toBeInTheDocument()
|
|
567
579
|
})
|
|
568
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('
|
|
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('
|
|
176
|
-
expect(screen.getByRole('
|
|
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('
|
|
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('
|
|
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('
|
|
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)
|
|
@@ -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
|
+
}
|