@lynn123411/dsh-model-submenu 0.2.5 → 0.3.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/DETAILS.md +8 -2
- package/README.md +10 -8
- package/lib/client.js +1158 -326
- package/lib/client.js.map +1 -1
- package/locale/en.json +2 -2
- package/locale/zh.json +2 -2
- package/package.json +26 -20
- package/src/client/ModelPicker.tsx +305 -0
- package/src/client/ModelRow.tsx +127 -0
- package/src/client/ModelSubmenu.module.css +338 -97
- package/src/client/ModelSubmenu.tsx +237 -233
- package/src/client/effort-memory.ts +90 -0
- package/src/client/facts-source.ts +93 -0
- package/src/client/favorites.ts +103 -0
- package/src/client/icons.tsx +36 -0
- package/src/client/index.ts +29 -8
- package/src/client/locales.ts +66 -33
- package/src/client/model-facts.ts +198 -0
- package/src/client/rows.ts +125 -0
- package/src/client/selection.ts +112 -0
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The model picker: the centered dialog opened from the menu's last row.
|
|
3
|
+
*
|
|
4
|
+
* It is the one place every provider is visible at once — a search field, a tab
|
|
5
|
+
* per provider with a model, and one flat list with each model's metadata line.
|
|
6
|
+
* Favorites lead the list and can be toggled from a row's star without
|
|
7
|
+
* selecting that row. A click commits at once; a rejected commit keeps the
|
|
8
|
+
* dialog open so the user can retry or pick another model.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { useEffect, useId, useMemo, useRef, useState } from 'react'
|
|
12
|
+
import {
|
|
13
|
+
IconSearchOutlineRegular,
|
|
14
|
+
Input,
|
|
15
|
+
Modal,
|
|
16
|
+
ShortcutKeys,
|
|
17
|
+
rankByName,
|
|
18
|
+
} from '@deepseek-ai/dsh-client-ui-primitives'
|
|
19
|
+
import type { ModelDirectoryState } from '@deepseek-ai/dsh-client-ui-model-selection/client'
|
|
20
|
+
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
|
|
21
|
+
import { ModelRow } from './ModelRow.tsx'
|
|
22
|
+
import { isFavorite, orderWithFavorites } from './favorites.ts'
|
|
23
|
+
import type { ModelFacts } from './model-facts.ts'
|
|
24
|
+
import { metaSegments } from './model-facts.ts'
|
|
25
|
+
import {
|
|
26
|
+
ALL_TAB,
|
|
27
|
+
flattenGroups,
|
|
28
|
+
pickerTabs,
|
|
29
|
+
reasoningOf,
|
|
30
|
+
rowsForTab,
|
|
31
|
+
searchRows,
|
|
32
|
+
type ModelRow as CatalogRow,
|
|
33
|
+
type PickerTab,
|
|
34
|
+
} from './rows.ts'
|
|
35
|
+
import type { ModelFactsLookup } from './facts-source.ts'
|
|
36
|
+
import type { Selection } from './selection.ts'
|
|
37
|
+
import { planModelCommit } from './selection.ts'
|
|
38
|
+
import css from './ModelSubmenu.module.css'
|
|
39
|
+
|
|
40
|
+
/** Props the seat hands the picker. */
|
|
41
|
+
export interface ModelPickerProps {
|
|
42
|
+
/** Whether the dialog is showing. */
|
|
43
|
+
readonly open: boolean
|
|
44
|
+
/** The shared directory snapshot. */
|
|
45
|
+
readonly state: ModelDirectoryState
|
|
46
|
+
/** The selection in force, null before a first selection resolves. */
|
|
47
|
+
readonly live: Selection | null
|
|
48
|
+
/** The metadata lookup for the current settings answer. */
|
|
49
|
+
readonly facts: ModelFactsLookup
|
|
50
|
+
/** The favorite table right now. */
|
|
51
|
+
readonly favorites: readonly { readonly provider: string; readonly model: string }[]
|
|
52
|
+
/** Whether a selection is in flight. */
|
|
53
|
+
readonly busy: boolean
|
|
54
|
+
/** Submit one selection. */
|
|
55
|
+
readonly onPick: (selection: Selection) => void
|
|
56
|
+
/** Toggle one model's favorite. */
|
|
57
|
+
readonly onToggleFavorite: (provider: string, model: string) => void
|
|
58
|
+
/** Reload the catalog after a failure. */
|
|
59
|
+
readonly onReload: () => void
|
|
60
|
+
/** Close the dialog, optionally returning focus. */
|
|
61
|
+
readonly onClose: () => void
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Render the picker.
|
|
66
|
+
* @param props - the catalog, the live selection, and the picker's verbs.
|
|
67
|
+
* @returns the dialog; nothing while closed.
|
|
68
|
+
*/
|
|
69
|
+
export function ModelPicker({
|
|
70
|
+
open, state, live, facts, favorites, busy, onPick, onToggleFavorite, onReload, onClose, t,
|
|
71
|
+
}: ModelPickerProps & PropsLocale<'modelSubmenu'>) {
|
|
72
|
+
const id = useId()
|
|
73
|
+
const searchRef = useRef<HTMLInputElement | null>(null)
|
|
74
|
+
const listRef = useRef<HTMLDivElement | null>(null)
|
|
75
|
+
const [query, setQuery] = useState('')
|
|
76
|
+
const [tab, setTab] = useState(ALL_TAB)
|
|
77
|
+
const [highlight, setHighlight] = useState(0)
|
|
78
|
+
const tabRefs = useRef(new Map<string, HTMLButtonElement>())
|
|
79
|
+
|
|
80
|
+
const tabs = useMemo(() => pickerTabs(state.groups, t('picker.all')), [state.groups, t])
|
|
81
|
+
const reason = useMemo(() => reasoningOf(state.groups), [state.groups])
|
|
82
|
+
const rows = useMemo(() => orderWithFavorites(
|
|
83
|
+
rowsForTab(flattenGroups(state.groups), tab),
|
|
84
|
+
(row) => isFavorite(favorites, row.provider, row.model),
|
|
85
|
+
), [state.groups, tab, favorites])
|
|
86
|
+
// Search runs inside the favorite partition, never across it: a query must not
|
|
87
|
+
// pull an unstarred model above a starred one, because favorites lead the list
|
|
88
|
+
// whether or not a query is typed.
|
|
89
|
+
const visible = useMemo(() => orderWithFavorites(
|
|
90
|
+
searchRows(rows, query, rankByName),
|
|
91
|
+
(row) => isFavorite(favorites, row.provider, row.model),
|
|
92
|
+
), [rows, query, favorites])
|
|
93
|
+
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
if (!open) return
|
|
96
|
+
setQuery('')
|
|
97
|
+
setTab(ALL_TAB)
|
|
98
|
+
setHighlight(0)
|
|
99
|
+
if (listRef.current !== null) listRef.current.scrollTop = 0
|
|
100
|
+
const frame = requestAnimationFrame(() => { searchRef.current?.focus() })
|
|
101
|
+
return () => { cancelAnimationFrame(frame) }
|
|
102
|
+
}, [open])
|
|
103
|
+
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
setHighlight((current) => visible.length === 0 ? 0 : Math.min(current, visible.length - 1))
|
|
106
|
+
}, [visible.length])
|
|
107
|
+
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
const row = listRef.current?.querySelector<HTMLElement>(`[data-row-index="${highlight}"]`)
|
|
110
|
+
row?.scrollIntoView({ block: 'nearest' })
|
|
111
|
+
}, [highlight])
|
|
112
|
+
|
|
113
|
+
if (!open) return null
|
|
114
|
+
|
|
115
|
+
const activeRow = visible[highlight]
|
|
116
|
+
const activeId = activeRow === undefined ? undefined : `${id}-row-${highlight}`
|
|
117
|
+
const onSearchKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
|
118
|
+
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
|
119
|
+
event.preventDefault()
|
|
120
|
+
if (visible.length === 0) return
|
|
121
|
+
setHighlight((current) => {
|
|
122
|
+
const next = current + (event.key === 'ArrowDown' ? 1 : -1)
|
|
123
|
+
return (next + visible.length) % visible.length
|
|
124
|
+
})
|
|
125
|
+
return
|
|
126
|
+
}
|
|
127
|
+
if (event.key === 'Enter' && activeRow !== undefined && !busy) {
|
|
128
|
+
event.preventDefault()
|
|
129
|
+
pick(activeRow)
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
const pick = (row: CatalogRow) => {
|
|
133
|
+
const selection = planModelCommit(live, row.provider, row.model, reason)
|
|
134
|
+
if (selection === null) {
|
|
135
|
+
onClose()
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
onPick(selection)
|
|
139
|
+
}
|
|
140
|
+
/** Move to another tab and hand it the keyboard, in one step. */
|
|
141
|
+
const goTab = (next: PickerTab | undefined) => {
|
|
142
|
+
if (next === undefined || next.id === tab) return
|
|
143
|
+
setTab(next.id)
|
|
144
|
+
setHighlight(0)
|
|
145
|
+
tabRefs.current.get(next.id)?.focus()
|
|
146
|
+
}
|
|
147
|
+
const onTabKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>) => {
|
|
148
|
+
const index = tabs.findIndex((candidate) => candidate.id === tab)
|
|
149
|
+
if (event.key === 'ArrowRight' || event.key === 'ArrowLeft') {
|
|
150
|
+
event.preventDefault()
|
|
151
|
+
const step = event.key === 'ArrowRight' ? 1 : -1
|
|
152
|
+
goTab(tabs[((index === -1 ? 0 : index) + step + tabs.length) % tabs.length])
|
|
153
|
+
return
|
|
154
|
+
}
|
|
155
|
+
if (event.key === 'Home' || event.key === 'End') {
|
|
156
|
+
event.preventDefault()
|
|
157
|
+
goTab(tabs[event.key === 'Home' ? 0 : tabs.length - 1])
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const failures = state.failures.filter((failure) => tab === ALL_TAB || failure.id === tab)
|
|
162
|
+
const failed = state.error !== null && state.status === 'error'
|
|
163
|
+
const loading = state.status === 'loading'
|
|
164
|
+
return (
|
|
165
|
+
<Modal
|
|
166
|
+
open={open}
|
|
167
|
+
onClose={onClose}
|
|
168
|
+
title={t('picker.title')}
|
|
169
|
+
closeLabel={t('picker.close')}
|
|
170
|
+
className={css.pickerDialog ?? ''}
|
|
171
|
+
contentClassName={css.pickerContent ?? ''}
|
|
172
|
+
>
|
|
173
|
+
<div className={css.pickerSearch}>
|
|
174
|
+
<Input
|
|
175
|
+
ref={searchRef}
|
|
176
|
+
className={css.pickerSearchInput ?? ''}
|
|
177
|
+
type="text"
|
|
178
|
+
role="searchbox"
|
|
179
|
+
aria-label={t('picker.search')}
|
|
180
|
+
aria-controls={`${id}-list`}
|
|
181
|
+
aria-activedescendant={activeId}
|
|
182
|
+
placeholder={t('picker.search')}
|
|
183
|
+
icon={<IconSearchOutlineRegular size={14} />}
|
|
184
|
+
value={query}
|
|
185
|
+
readOnly={busy}
|
|
186
|
+
onChange={(event) => { setQuery(event.target.value) }}
|
|
187
|
+
onKeyDown={onSearchKeyDown}
|
|
188
|
+
data-modal-autofocus=""
|
|
189
|
+
/>
|
|
190
|
+
</div>
|
|
191
|
+
<div className={css.pickerTabs} role="tablist" aria-label={t('picker.title')}>
|
|
192
|
+
{tabs.map((entry) => (
|
|
193
|
+
<button
|
|
194
|
+
key={entry.id}
|
|
195
|
+
ref={(node) => {
|
|
196
|
+
if (node === null) tabRefs.current.delete(entry.id)
|
|
197
|
+
else tabRefs.current.set(entry.id, node)
|
|
198
|
+
}}
|
|
199
|
+
type="button"
|
|
200
|
+
role="tab"
|
|
201
|
+
id={`${id}-tab-${entry.id}`}
|
|
202
|
+
aria-selected={entry.id === tab}
|
|
203
|
+
aria-controls={`${id}-list`}
|
|
204
|
+
tabIndex={0}
|
|
205
|
+
className={css.pickerTab}
|
|
206
|
+
onClick={() => {
|
|
207
|
+
setTab(entry.id)
|
|
208
|
+
setHighlight(0)
|
|
209
|
+
}}
|
|
210
|
+
onKeyDown={onTabKeyDown}
|
|
211
|
+
onFocus={() => {
|
|
212
|
+
if (entry.id !== tab) {
|
|
213
|
+
setTab(entry.id)
|
|
214
|
+
setHighlight(0)
|
|
215
|
+
}
|
|
216
|
+
}}
|
|
217
|
+
>
|
|
218
|
+
{entry.label}
|
|
219
|
+
</button>
|
|
220
|
+
))}
|
|
221
|
+
</div>
|
|
222
|
+
<div
|
|
223
|
+
ref={listRef}
|
|
224
|
+
id={`${id}-list`}
|
|
225
|
+
className={css.pickerList}
|
|
226
|
+
role="menu"
|
|
227
|
+
aria-label={t('picker.title')}
|
|
228
|
+
>
|
|
229
|
+
{loading && <div className={css.notice}>{t('status.loading')}</div>}
|
|
230
|
+
{failed && (
|
|
231
|
+
<div className={css.notice}>
|
|
232
|
+
<span>{t('status.failed', { message: state.error ?? '' })}</span>
|
|
233
|
+
<button type="button" className={css.retry} onClick={onReload}>{t('action.reload')}</button>
|
|
234
|
+
</div>
|
|
235
|
+
)}
|
|
236
|
+
{failures.map((failure) => (
|
|
237
|
+
<div className={css.notice} key={failure.id}>
|
|
238
|
+
<span>{t('status.providerFailed', { name: failure.name, message: failure.message })}</span>
|
|
239
|
+
<button type="button" className={css.retry} onClick={onReload}>{t('action.reload')}</button>
|
|
240
|
+
</div>
|
|
241
|
+
))}
|
|
242
|
+
{visible.length === 0 && !loading && !failed && (
|
|
243
|
+
<div className={css.notice}>{state.groups.length === 0 ? t('status.empty') : t('picker.searchEmpty')}</div>
|
|
244
|
+
)}
|
|
245
|
+
{visible.map((row, index) => (
|
|
246
|
+
<div key={`${row.provider}/${row.model}`} data-row-index={index} id={`${id}-row-${index}`}>
|
|
247
|
+
<ModelRow
|
|
248
|
+
name={row.entry.name}
|
|
249
|
+
selected={live?.provider === row.provider && live.model === row.model}
|
|
250
|
+
busy={busy}
|
|
251
|
+
star="interactive"
|
|
252
|
+
favorited={isFavorite(favorites, row.provider, row.model)}
|
|
253
|
+
meta={metaLine({
|
|
254
|
+
row,
|
|
255
|
+
facts: facts(row.provider, row.model),
|
|
256
|
+
providerName: tab === ALL_TAB ? row.providerName : undefined,
|
|
257
|
+
thinks: row.entry.reasoning !== undefined,
|
|
258
|
+
effortLabel: t('meta.effort'),
|
|
259
|
+
imageLabel: t('meta.images'),
|
|
260
|
+
})}
|
|
261
|
+
starLabel={isFavorite(favorites, row.provider, row.model)
|
|
262
|
+
? t('picker.unfavorite', { model: row.entry.name })
|
|
263
|
+
: t('picker.favorite', { model: row.entry.name })}
|
|
264
|
+
starTabStop={index === highlight}
|
|
265
|
+
rowTabIndex={-1}
|
|
266
|
+
onPick={() => { pick(row) }}
|
|
267
|
+
onFocusRow={() => { setHighlight(index) }}
|
|
268
|
+
onToggleFavorite={() => { onToggleFavorite(row.provider, row.model) }}
|
|
269
|
+
/>
|
|
270
|
+
</div>
|
|
271
|
+
))}
|
|
272
|
+
</div>
|
|
273
|
+
<div className={css.pickerFooter}>
|
|
274
|
+
<span className={css.footerHint}>
|
|
275
|
+
<ShortcutKeys keys={['↑', '↓']} />
|
|
276
|
+
<span>{t('picker.hintNavigate')}</span>
|
|
277
|
+
<span className={css.footerDot}>·</span>
|
|
278
|
+
<ShortcutKeys keys={['Enter']} />
|
|
279
|
+
<span>{t('picker.hintSelect')}</span>
|
|
280
|
+
<span className={css.footerDot}>·</span>
|
|
281
|
+
<ShortcutKeys keys={['Esc']} />
|
|
282
|
+
<span>{t('picker.hintClose')}</span>
|
|
283
|
+
</span>
|
|
284
|
+
</div>
|
|
285
|
+
</Modal>
|
|
286
|
+
)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function metaLine(input: {
|
|
290
|
+
readonly row: CatalogRow
|
|
291
|
+
readonly facts: ModelFacts
|
|
292
|
+
readonly providerName: string | undefined
|
|
293
|
+
readonly thinks: boolean
|
|
294
|
+
readonly effortLabel: string
|
|
295
|
+
readonly imageLabel: string
|
|
296
|
+
}): string {
|
|
297
|
+
return metaSegments({
|
|
298
|
+
...input.providerName === undefined ? {} : { providerName: input.providerName },
|
|
299
|
+
facts: input.facts,
|
|
300
|
+
thinks: input.thinks,
|
|
301
|
+
effortLabel: input.effortLabel,
|
|
302
|
+
imageLabel: input.imageLabel,
|
|
303
|
+
}).join(' · ')
|
|
304
|
+
}
|
|
305
|
+
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One pickable model, as both surfaces render it.
|
|
3
|
+
*
|
|
4
|
+
* The menu row is one button that spans the whole width — name, favorite mark,
|
|
5
|
+
* and the check for the model in use all live inside it, so hovering any part of
|
|
6
|
+
* the row (including the star) highlights the row and clicking any part picks
|
|
7
|
+
* the model. The picker row keeps the same shape but lifts the star out into its
|
|
8
|
+
* own control, because favouriting there must not switch the model.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { ReactNode } from 'react'
|
|
12
|
+
import { IconCheckOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
13
|
+
import { StarIcon } from './icons.tsx'
|
|
14
|
+
import css from './ModelSubmenu.module.css'
|
|
15
|
+
|
|
16
|
+
/** How one row shows its favorite mark: part of the row, or a control of its own. */
|
|
17
|
+
export type StarMode = 'static' | 'interactive'
|
|
18
|
+
|
|
19
|
+
/** Props shared by both row shapes. */
|
|
20
|
+
export interface ModelRowProps {
|
|
21
|
+
/** The model's catalog name. */
|
|
22
|
+
readonly name: string
|
|
23
|
+
/** Whether this row is the model in use. */
|
|
24
|
+
readonly selected: boolean
|
|
25
|
+
/** Whether a selection is in flight, which locks the row. */
|
|
26
|
+
readonly busy: boolean
|
|
27
|
+
/** How the favorite mark behaves on this row. */
|
|
28
|
+
readonly star: StarMode
|
|
29
|
+
/** Whether the model is favorited right now. */
|
|
30
|
+
readonly favorited: boolean
|
|
31
|
+
/** The metadata line's text; undefined in the menu. */
|
|
32
|
+
readonly meta?: string | undefined
|
|
33
|
+
/** Localized accessible name for the star control. */
|
|
34
|
+
readonly starLabel?: string | undefined
|
|
35
|
+
/** Whether the star is a Tab stop right now (the picker's roving highlight). */
|
|
36
|
+
readonly starTabStop?: boolean
|
|
37
|
+
/** Tab order of the row itself; the picker keeps rows out of it so ↑/↓ own the walk. */
|
|
38
|
+
readonly rowTabIndex?: number
|
|
39
|
+
/** Pick this row. */
|
|
40
|
+
readonly onPick: () => void
|
|
41
|
+
/** The row took the keyboard, so its star becomes reachable. */
|
|
42
|
+
readonly onFocusRow?: (() => void) | undefined
|
|
43
|
+
/** Toggle this row's favorite; absent when the star is part of the row. */
|
|
44
|
+
readonly onToggleFavorite?: (() => void) | undefined
|
|
45
|
+
/** Extra row content, such as the picker's disabled reason. */
|
|
46
|
+
readonly children?: ReactNode
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Render one model row.
|
|
51
|
+
* @param props - the row's content and behaviour.
|
|
52
|
+
* @returns the row element.
|
|
53
|
+
*/
|
|
54
|
+
export function ModelRow({
|
|
55
|
+
name, selected, busy, star, favorited, meta, starLabel,
|
|
56
|
+
starTabStop = false, rowTabIndex, onPick, onFocusRow, onToggleFavorite, children,
|
|
57
|
+
}: ModelRowProps) {
|
|
58
|
+
const copy = (
|
|
59
|
+
<>
|
|
60
|
+
<span className={css.rowCopy}>
|
|
61
|
+
<span className={css.rowName}>{name}</span>
|
|
62
|
+
{meta !== undefined && meta !== '' && <span className={css.rowMeta}>{meta}</span>}
|
|
63
|
+
</span>
|
|
64
|
+
</>
|
|
65
|
+
)
|
|
66
|
+
const mark = favorited ? <StarIcon filled className={css.star} /> : null
|
|
67
|
+
|
|
68
|
+
// The menu's row is one target: the star and the check sit inside the button,
|
|
69
|
+
// so they inherit its hover fill and its click.
|
|
70
|
+
if (star === 'static') {
|
|
71
|
+
return (
|
|
72
|
+
<button
|
|
73
|
+
type="button"
|
|
74
|
+
role="menuitemradio"
|
|
75
|
+
aria-checked={selected}
|
|
76
|
+
tabIndex={rowTabIndex}
|
|
77
|
+
className={css.rowPick}
|
|
78
|
+
title={name}
|
|
79
|
+
disabled={busy}
|
|
80
|
+
onClick={onPick}
|
|
81
|
+
onFocus={onFocusRow}
|
|
82
|
+
data-selected={selected ? '' : undefined}
|
|
83
|
+
data-favorited={favorited ? '' : undefined}
|
|
84
|
+
>
|
|
85
|
+
{copy}
|
|
86
|
+
{mark !== null && <span className={css.starStatic} aria-hidden="true">{mark}</span>}
|
|
87
|
+
<span className={css.check} aria-hidden={!selected}>{selected ? <IconCheckOutlineRegular /> : null}</span>
|
|
88
|
+
{children}
|
|
89
|
+
</button>
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<div
|
|
95
|
+
className={css.row}
|
|
96
|
+
data-selected={selected ? '' : undefined}
|
|
97
|
+
data-favorited={favorited ? '' : undefined}
|
|
98
|
+
>
|
|
99
|
+
<button
|
|
100
|
+
type="button"
|
|
101
|
+
role="menuitemradio"
|
|
102
|
+
aria-checked={selected}
|
|
103
|
+
tabIndex={rowTabIndex}
|
|
104
|
+
className={css.rowPick}
|
|
105
|
+
title={name}
|
|
106
|
+
disabled={busy}
|
|
107
|
+
onClick={onPick}
|
|
108
|
+
onFocus={onFocusRow}
|
|
109
|
+
>
|
|
110
|
+
{copy}
|
|
111
|
+
</button>
|
|
112
|
+
<button
|
|
113
|
+
type="button"
|
|
114
|
+
className={css.starButton}
|
|
115
|
+
aria-pressed={favorited}
|
|
116
|
+
aria-label={starLabel}
|
|
117
|
+
tabIndex={starTabStop ? 0 : -1}
|
|
118
|
+
disabled={busy}
|
|
119
|
+
onClick={onToggleFavorite}
|
|
120
|
+
>
|
|
121
|
+
<StarIcon filled={favorited} className={css.star} />
|
|
122
|
+
</button>
|
|
123
|
+
<span className={css.check} aria-hidden={!selected}>{selected ? <IconCheckOutlineRegular /> : null}</span>
|
|
124
|
+
{children}
|
|
125
|
+
</div>
|
|
126
|
+
)
|
|
127
|
+
}
|