@youdie006/prodex 0.38.1 → 0.39.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/dist/chatgpt-browser.js +144 -17
- package/dist/cli-pro.js +11 -4
- package/dist/config.js +1 -1
- package/dist/picker-interaction.js +34 -2
- package/package.json +1 -1
package/dist/chatgpt-browser.js
CHANGED
|
@@ -6,7 +6,7 @@ import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
|
6
6
|
import path from "node:path";
|
|
7
7
|
import { captureBrowserDiagnostics, diagnosticsEnabled, diagnosticsNote } from "./browser-diagnostics.js";
|
|
8
8
|
import os from "node:os";
|
|
9
|
-
import { readPowerSliderSelection } from "./picker-interaction.js";
|
|
9
|
+
import { menuKeyboardStep, readPowerSliderSelection } from "./picker-interaction.js";
|
|
10
10
|
export class ChatGptBrowserBlockerError extends Error {
|
|
11
11
|
blocker;
|
|
12
12
|
constructor(blocker) {
|
|
@@ -20,7 +20,13 @@ export class ChatGptBrowserBlockerError extends Error {
|
|
|
20
20
|
// of this control rather than a model. Leaving it out meant --effort could not
|
|
21
21
|
// reach the top of the slider at all, and the only route there - --model Pro -
|
|
22
22
|
// answered with advice to "say --effort Pro", which then failed.
|
|
23
|
-
|
|
23
|
+
/** A full trip round the menu is far more than any real picker needs. */
|
|
24
|
+
const MENU_KEYBOARD_WALK_LIMIT = 16;
|
|
25
|
+
// Two more rungs than before. Measured live: with the model row chosen rather
|
|
26
|
+
// than the recommended set, the slider reads Light / Medium / High / Extra High
|
|
27
|
+
// / Max / Ultra, and Ultra is the top of the machine. Pro is kept because older
|
|
28
|
+
// pickers still name the top step that way.
|
|
29
|
+
const REASONING_EFFORTS = ["즉시", "중간", "높음", "매우 높음", "Max", "Ultra", "Pro"];
|
|
24
30
|
const PRO_MODES = ["기본", "확장"];
|
|
25
31
|
// Aliases map friendly CLI input onto the exact Korean menu labels the picker
|
|
26
32
|
// clicks by text. Keys are lowercased and space-stripped before lookup.
|
|
@@ -29,19 +35,26 @@ const REASONING_EFFORT_ALIASES = {
|
|
|
29
35
|
instant: "즉시",
|
|
30
36
|
medium: "중간",
|
|
31
37
|
high: "높음",
|
|
32
|
-
max: "매우 높음",
|
|
33
38
|
extrahigh: "매우 높음",
|
|
39
|
+
max: "매우 높음",
|
|
40
|
+
light: "즉시",
|
|
41
|
+
"가벼움": "즉시",
|
|
42
|
+
"최대": "Max",
|
|
43
|
+
ultra: "Ultra",
|
|
44
|
+
"울트라": "Ultra",
|
|
34
45
|
pro: "Pro",
|
|
35
46
|
"프로": "Pro"
|
|
36
47
|
};
|
|
37
48
|
// Menu labels per canonical value, verified live in both the Korean and the
|
|
38
49
|
// English (US) ChatGPT UI. Matching tries every candidate so either UI works.
|
|
39
50
|
const EFFORT_MENU_LABELS = {
|
|
40
|
-
"즉시": ["즉시", "Instant"],
|
|
51
|
+
"즉시": ["즉시", "Instant", "Light"],
|
|
41
52
|
"중간": ["중간", "Medium"],
|
|
42
53
|
"높음": ["높음", "High"],
|
|
43
54
|
"매우 높음": ["매우 높음", "Extra High"],
|
|
44
|
-
|
|
55
|
+
Max: ["Max", "최대"],
|
|
56
|
+
Ultra: ["Ultra", "울트라"],
|
|
57
|
+
Pro: ["Pro", "프로", "6-pro", "GPT-6 Pro", "6 Pro"]
|
|
45
58
|
};
|
|
46
59
|
const PRO_MODE_SUBMENU_LABELS = {
|
|
47
60
|
"기본": ["Pro 기본", "Pro Standard", "Standard", "기본"],
|
|
@@ -60,7 +73,7 @@ export function parseReasoningEffort(raw) {
|
|
|
60
73
|
const alias = REASONING_EFFORT_ALIASES[trimmed.toLowerCase().replace(/\s+/g, "")];
|
|
61
74
|
if (alias)
|
|
62
75
|
return alias;
|
|
63
|
-
throw new Error(`--effort must be one of ${REASONING_EFFORTS.join(", ")} (English aliases: instant, medium, high, max)`);
|
|
76
|
+
throw new Error(`--effort must be one of ${REASONING_EFFORTS.join(", ")} (English aliases: instant/light, medium, high, extrahigh, max, ultra)`);
|
|
64
77
|
}
|
|
65
78
|
/** Normalize a CLI Pro sub-mode value onto the exact ChatGPT menu label. */
|
|
66
79
|
export function parseProMode(raw) {
|
|
@@ -1422,6 +1435,38 @@ export function powerSliderStateExpression() {
|
|
|
1422
1435
|
};
|
|
1423
1436
|
})()`;
|
|
1424
1437
|
}
|
|
1438
|
+
export function activeMenuItemExpression() {
|
|
1439
|
+
return `(() => {
|
|
1440
|
+
const a = document.activeElement;
|
|
1441
|
+
if (!a) return null;
|
|
1442
|
+
const menu = document.querySelector('[data-testid="composer-intelligence-picker-content"]');
|
|
1443
|
+
if (menu && !menu.contains(a)) return null;
|
|
1444
|
+
const label = ((a.innerText || a.textContent || "").trim().split(String.fromCharCode(10))[0] || "").trim();
|
|
1445
|
+
return { role: a.getAttribute("role"), label };
|
|
1446
|
+
})()`;
|
|
1447
|
+
}
|
|
1448
|
+
export function powerSliderPresentExpression() {
|
|
1449
|
+
return `Boolean(document.querySelector('[data-testid="composer-intelligence-picker-content"] [role="slider"]'))`;
|
|
1450
|
+
}
|
|
1451
|
+
export function pickerClosedExpression() {
|
|
1452
|
+
return `!document.querySelector('[data-testid="composer-intelligence-picker-content"]')`;
|
|
1453
|
+
}
|
|
1454
|
+
export function focusPickerMenuExpression() {
|
|
1455
|
+
return `(() => {
|
|
1456
|
+
const menu = document.querySelector('[data-testid="composer-intelligence-picker-content"]');
|
|
1457
|
+
if (!menu) return { ok: false, reason: "picker menu not open" };
|
|
1458
|
+
// Leave the focus the menu gave itself. It opens on the checked model row,
|
|
1459
|
+
// and ArrowDown from there walks the model list. Focusing the first item
|
|
1460
|
+
// instead lands on the label row above the slider, where ArrowDown only
|
|
1461
|
+
// cycles that row's own widgets - measured as four steps that repeat
|
|
1462
|
+
// forever and never reach a radio.
|
|
1463
|
+
if (menu.contains(document.activeElement)) return { ok: true };
|
|
1464
|
+
const radios = [...menu.querySelectorAll('[role="menuitemradio"]')];
|
|
1465
|
+
const target = radios.find((r) => r.getAttribute("aria-checked") === "true") || radios[0];
|
|
1466
|
+
if (target && typeof target.focus === "function") target.focus();
|
|
1467
|
+
return { ok: menu.contains(document.activeElement) };
|
|
1468
|
+
})()`;
|
|
1469
|
+
}
|
|
1425
1470
|
export function focusPowerSliderExpression() {
|
|
1426
1471
|
return `(() => {
|
|
1427
1472
|
const slider = document.querySelector('[role="slider"]');
|
|
@@ -1680,7 +1725,45 @@ export function stepSelectionUnavailableWarning(requested, offered) {
|
|
|
1680
1725
|
return (`step_not_applied: this ChatGPT picker has no "${requested}" step, so the send used the slider's current setting.${list} ` +
|
|
1681
1726
|
'Pick one of those with --effort, or clear a saved default with `prodex setup --clear-model`.');
|
|
1682
1727
|
}
|
|
1728
|
+
/**
|
|
1729
|
+
* Choose a model row through the menu's roving focus.
|
|
1730
|
+
*
|
|
1731
|
+
* The rows carry pointer-events: none and sit outside the menu's box, so
|
|
1732
|
+
* verifiedClickAt refuses them and .focus() is declined. ArrowDown from the row
|
|
1733
|
+
* the menu opens on does reach them, and Enter commits. Doing this BEFORE the
|
|
1734
|
+
* slider matters: with the recommended set active the slider walks a short
|
|
1735
|
+
* mixed ladder ending at Extra High, and picking the model swaps in that
|
|
1736
|
+
* model's own ladder, which continues past it.
|
|
1737
|
+
*/
|
|
1738
|
+
async function selectPickerModelByKeyboard(cdp, candidates) {
|
|
1739
|
+
const focused = await cdp.evaluate(focusPickerMenuExpression());
|
|
1740
|
+
if (!focused?.ok)
|
|
1741
|
+
return false;
|
|
1742
|
+
const limit = MENU_KEYBOARD_WALK_LIMIT;
|
|
1743
|
+
for (let pressed = 0;; pressed += 1) {
|
|
1744
|
+
const active = await cdp.evaluate(activeMenuItemExpression());
|
|
1745
|
+
const move = menuKeyboardStep({ active, requested: candidates, pressed, limit });
|
|
1746
|
+
if (move === "exhausted")
|
|
1747
|
+
return false;
|
|
1748
|
+
if (move === "select") {
|
|
1749
|
+
await dispatchMenuKey(cdp, "Enter", 13);
|
|
1750
|
+
// Committing closes the menu. Waiting for it to finish closing keeps the
|
|
1751
|
+
// reopen below from landing mid-animation and toggling it shut again.
|
|
1752
|
+
await waitForExpressionTrue(cdp, pickerClosedExpression(), 3_000);
|
|
1753
|
+
return true;
|
|
1754
|
+
}
|
|
1755
|
+
await dispatchMenuKey(cdp, "ArrowDown", 40);
|
|
1756
|
+
await sleep(140);
|
|
1757
|
+
}
|
|
1758
|
+
}
|
|
1759
|
+
async function dispatchMenuKey(cdp, key, code) {
|
|
1760
|
+
for (const type of ["keyDown", "keyUp"]) {
|
|
1761
|
+
await cdp.send("Input.dispatchKeyEvent", { type, key, code: key, windowsVirtualKeyCode: code });
|
|
1762
|
+
}
|
|
1763
|
+
}
|
|
1683
1764
|
async function selectPickerModel(cdp, requested, warnings = []) {
|
|
1765
|
+
if (await selectPickerModelByKeyboard(cdp, [requested]))
|
|
1766
|
+
return;
|
|
1684
1767
|
const hit = await cdp.evaluate(menuItemRectExpression(requested));
|
|
1685
1768
|
if (!hit.ok || hit.x === undefined || hit.y === undefined) {
|
|
1686
1769
|
const unavailable = modelSelectionUnavailableWarning(requested, hit.reason ?? "", hit.available);
|
|
@@ -1692,6 +1775,29 @@ async function selectPickerModel(cdp, requested, warnings = []) {
|
|
|
1692
1775
|
}
|
|
1693
1776
|
await verifiedClickWithRetry(cdp, () => cdp.evaluate(menuItemRectExpression(requested)), requested);
|
|
1694
1777
|
}
|
|
1778
|
+
/**
|
|
1779
|
+
* Make sure the composer picker is open, reopening it if it is not.
|
|
1780
|
+
*
|
|
1781
|
+
* Committing a model row closes the menu, and the slider lives inside it - so
|
|
1782
|
+
* without this the step that follows reported "the picker did not expose its
|
|
1783
|
+
* power slider" on a picker that was simply shut.
|
|
1784
|
+
*/
|
|
1785
|
+
async function ensurePickerOpen(cdp) {
|
|
1786
|
+
// The slider, not the menu, is what the caller needs - and an open menu that
|
|
1787
|
+
// has not painted its slider yet looks identical to one that has.
|
|
1788
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
1789
|
+
if (await cdp.evaluate(powerSliderPresentExpression()))
|
|
1790
|
+
return true;
|
|
1791
|
+
await waitForExpressionTrue(cdp, pickerClosedExpression(), 1_500);
|
|
1792
|
+
const button = await cdp.evaluate(modelButtonRectExpression());
|
|
1793
|
+
if (!button.ok || button.x === undefined || button.y === undefined)
|
|
1794
|
+
return false;
|
|
1795
|
+
await verifiedClickAt(cdp, button.x, button.y, "model selector");
|
|
1796
|
+
if (await waitForExpressionTrue(cdp, powerSliderPresentExpression(), MENU_OPEN_TIMEOUT_MS))
|
|
1797
|
+
return true;
|
|
1798
|
+
}
|
|
1799
|
+
return false;
|
|
1800
|
+
}
|
|
1695
1801
|
async function selectPowerStep(cdp, requested) {
|
|
1696
1802
|
// The menu paints a moment after it opens, and on a page that has just been
|
|
1697
1803
|
// built - a project created seconds ago - that moment is longer. Failing the
|
|
@@ -1800,7 +1906,11 @@ async function selectModelReasoning(cdp, options, selectionWarnings = []) {
|
|
|
1800
1906
|
// the whole send). Retry briefly with FRESH coordinates; a persistent
|
|
1801
1907
|
// cover still fails with the refusal message.
|
|
1802
1908
|
const clickDeadline = Date.now() + 5_000;
|
|
1803
|
-
|
|
1909
|
+
// Clicking the trigger TOGGLES the picker, so clicking one that is already
|
|
1910
|
+
// open shuts it and the next step reports "power slider not found" about a
|
|
1911
|
+
// control that was on screen a moment earlier.
|
|
1912
|
+
const alreadyOpen = await cdp.evaluate(powerSliderPresentExpression());
|
|
1913
|
+
for (; !alreadyOpen;) {
|
|
1804
1914
|
try {
|
|
1805
1915
|
await verifiedClickAt(cdp, button.x, button.y, "model selector");
|
|
1806
1916
|
break;
|
|
@@ -1832,7 +1942,16 @@ async function selectModelReasoning(cdp, options, selectionWarnings = []) {
|
|
|
1832
1942
|
});
|
|
1833
1943
|
if (plan.warning)
|
|
1834
1944
|
selectionWarnings.push(plan.warning);
|
|
1945
|
+
// The model goes first: choosing it replaces the ladder the slider walks,
|
|
1946
|
+
// so a step picked before it would be a step of the old ladder.
|
|
1947
|
+
if (plan.modelLabel) {
|
|
1948
|
+
await selectPickerModel(cdp, plan.modelLabel, selectionWarnings);
|
|
1949
|
+
}
|
|
1835
1950
|
if (plan.sliderLabel) {
|
|
1951
|
+
// Committing the model closed the menu; the slider is inside it.
|
|
1952
|
+
if (!(await ensurePickerOpen(cdp))) {
|
|
1953
|
+
throw new Error("ChatGPT's model picker would not reopen after the model was chosen.");
|
|
1954
|
+
}
|
|
1836
1955
|
try {
|
|
1837
1956
|
await selectPowerStep(cdp, plan.sliderLabel);
|
|
1838
1957
|
}
|
|
@@ -1847,9 +1966,6 @@ async function selectModelReasoning(cdp, options, selectionWarnings = []) {
|
|
|
1847
1966
|
selectionWarnings.push(stepSelectionUnavailableWarning(plan.sliderLabel, offered));
|
|
1848
1967
|
}
|
|
1849
1968
|
}
|
|
1850
|
-
if (plan.modelLabel) {
|
|
1851
|
-
await selectPickerModel(cdp, plan.modelLabel, selectionWarnings);
|
|
1852
|
-
}
|
|
1853
1969
|
// This branch cannot honour a sub-mode, and used to return without
|
|
1854
1970
|
// saying so - the caller got a clean receipt for a 확장 it never got.
|
|
1855
1971
|
const proModeWarning = proModeNotAppliedWarning(options.proMode, sliderState.effort ?? undefined);
|
|
@@ -2839,7 +2955,11 @@ export function modelMenuOptionsExpression() {
|
|
|
2839
2955
|
return `(() => {
|
|
2840
2956
|
const m = document.querySelector('[data-testid="composer-intelligence-picker-content"]');
|
|
2841
2957
|
if (!m) return [];
|
|
2842
|
-
|
|
2958
|
+
// Only the radios are models. The plain menuitems above them are the power
|
|
2959
|
+
// slider's own rows - its track, and the label pair naming the current model
|
|
2960
|
+
// and effort - and listing those put GPT-6 Astra in twice and Extra High in
|
|
2961
|
+
// as if it were a model.
|
|
2962
|
+
return [...m.querySelectorAll('[role="menuitemradio"]')]
|
|
2843
2963
|
.map((it) => {
|
|
2844
2964
|
// A submenu row renders as label over value ("Model" / "GPT-5.6 Sol"),
|
|
2845
2965
|
// and the value is the part a person actually wants to read.
|
|
@@ -3275,17 +3395,24 @@ async function readEffortSteps(cdp) {
|
|
|
3275
3395
|
await dispatchArrowKey(cdp, "ArrowLeft");
|
|
3276
3396
|
await sleep(120);
|
|
3277
3397
|
}
|
|
3278
|
-
|
|
3398
|
+
// Every position is recorded, including repeats. One slider now walks a
|
|
3399
|
+
// ladder of model-and-effort pairs, so "Light" is three different rungs -
|
|
3400
|
+
// collapsing by label turned six positions into three and lost which model
|
|
3401
|
+
// each one sends with.
|
|
3402
|
+
const rungs = [];
|
|
3279
3403
|
const record = async () => {
|
|
3280
3404
|
const state = await cdp.evaluate(powerSliderStateExpression());
|
|
3281
|
-
|
|
3282
|
-
|
|
3283
|
-
|
|
3405
|
+
if (!state?.ok)
|
|
3406
|
+
return;
|
|
3407
|
+
const effort = state.effort?.trim();
|
|
3408
|
+
if (!effort)
|
|
3409
|
+
return;
|
|
3410
|
+
rungs.push({ position: state.position ?? rungs.length, model: state.model?.trim() || null, effort });
|
|
3284
3411
|
};
|
|
3285
3412
|
await record();
|
|
3286
3413
|
for (let i = 0; i < plan.climb; i += 1) {
|
|
3287
3414
|
await dispatchArrowKey(cdp, "ArrowRight");
|
|
3288
|
-
await sleep(
|
|
3415
|
+
await sleep(220);
|
|
3289
3416
|
await record();
|
|
3290
3417
|
}
|
|
3291
3418
|
// Put it back. Reading someone's setting must not change it.
|
|
@@ -3293,7 +3420,7 @@ async function readEffortSteps(cdp) {
|
|
|
3293
3420
|
await dispatchArrowKey(cdp, "ArrowLeft");
|
|
3294
3421
|
await sleep(120);
|
|
3295
3422
|
}
|
|
3296
|
-
return
|
|
3423
|
+
return rungs.length > 0 ? { rungs, current } : undefined;
|
|
3297
3424
|
}
|
|
3298
3425
|
catch {
|
|
3299
3426
|
return undefined;
|
package/dist/cli-pro.js
CHANGED
|
@@ -449,11 +449,18 @@ export async function runProCommand(rest, io, runCliFn) {
|
|
|
449
449
|
}
|
|
450
450
|
if (listed.effortSteps) {
|
|
451
451
|
io.stdout("");
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
452
|
+
// One slider walks a ladder of model-and-effort pairs, so the same
|
|
453
|
+
// effort name appears at more than one rung with a different model
|
|
454
|
+
// behind it. Printing bare names hid that.
|
|
455
|
+
io.stdout("Power slider on this account (the slider was walked and put back):");
|
|
456
|
+
const rungs = listed.effortSteps.rungs;
|
|
457
|
+
const width = String(rungs.length).length;
|
|
458
|
+
for (const rung of rungs) {
|
|
459
|
+
const here = rung.effort === listed.effortSteps.current ? "*" : " ";
|
|
460
|
+
const step = String(rung.position + 1).padStart(width, " ");
|
|
461
|
+
io.stdout(`${here} ${step}/${rungs.length} ${rung.model ?? "?"} - ${rung.effort}`);
|
|
455
462
|
}
|
|
456
|
-
io.stdout("
|
|
463
|
+
io.stdout("Each row is one position of that single slider: it sets the model and the effort together, so they cannot be chosen apart. The list is complete - the slider shows one position at a time, so reading it any other way sees only the current one.");
|
|
457
464
|
}
|
|
458
465
|
else {
|
|
459
466
|
io.stdout("An arrow shows what that row is set to now; --model / --effort reach into those submenus (e.g. --model Pro).");
|
package/dist/config.js
CHANGED
|
@@ -11,7 +11,7 @@ const BrowserDefaultsSchema = z.object({
|
|
|
11
11
|
model: z.string().min(1).optional(),
|
|
12
12
|
pro_mode: z.enum(["기본", "확장"]).optional(),
|
|
13
13
|
// Pro is the slider's fifth step, so a saved default may name it.
|
|
14
|
-
effort: z.enum(["즉시", "중간", "높음", "매우 높음", "Pro"]).optional(),
|
|
14
|
+
effort: z.enum(["즉시", "중간", "높음", "매우 높음", "Max", "Ultra", "Pro"]).optional(),
|
|
15
15
|
project: z.string().min(1).optional()
|
|
16
16
|
});
|
|
17
17
|
const LocalConfigSchema = z.object({
|
|
@@ -10,6 +10,7 @@ export function readPowerSliderSelection(snapshot) {
|
|
|
10
10
|
}));
|
|
11
11
|
const powerLabels = new Set([
|
|
12
12
|
"instant",
|
|
13
|
+
"light",
|
|
13
14
|
"즉시",
|
|
14
15
|
"빠름",
|
|
15
16
|
"fast",
|
|
@@ -29,12 +30,43 @@ export function readPowerSliderSelection(snapshot) {
|
|
|
29
30
|
const legacyModel = items.find((item) => item.lines[0] === "Model")?.lines[1] ?? null;
|
|
30
31
|
const legacyEffort = items.find((item) => item.lines[0] === "Effort")?.lines[1] ?? null;
|
|
31
32
|
const checkedModel = items.find((item) => item.role === "menuitemradio" && item.checked)?.lines[0] ?? null;
|
|
32
|
-
|
|
33
|
+
// The slider stopped being an effort control: one slider now walks a ladder of
|
|
34
|
+
// model-and-effort pairs, and a row above the track reads "<model>" then
|
|
35
|
+
// "<effort>". Measured live, that pair sits in a SIBLING row - the row that
|
|
36
|
+
// literally contains the slider carries no text - so the pair is found by its
|
|
37
|
+
// shape rather than by ownership of the track. Reading the pair's first line
|
|
38
|
+
// as the effort is what made the listing announce "Extra High" as a model.
|
|
39
|
+
const pairRow = items.find((item) => item.role === "menuitem" && item.lines.length >= 2 && item.lines[0] !== "Model" && item.lines[0] !== "Effort");
|
|
40
|
+
const owner = items.find((item) => item.role === "menuitem" && item.containsSlider);
|
|
41
|
+
const sliderOwnerModel = pairRow?.lines[0] ?? null;
|
|
42
|
+
// A single-line row is the older form, where it carried only the step name.
|
|
43
|
+
const sliderOwner = pairRow?.lines[1] ?? (owner && owner.lines.length === 1 ? owner.lines[0] : null) ?? null;
|
|
33
44
|
const visiblePowerLabel = items.find((item) => item.role === "menuitem" && powerLabels.has((item.lines[0] ?? "").toLowerCase()))?.lines[0] ?? null;
|
|
34
45
|
const accessibleCandidate = snapshot.sliderValueText?.trim() || null;
|
|
35
46
|
const accessibleValue = accessibleCandidate && powerLabels.has(accessibleCandidate.toLowerCase()) ? accessibleCandidate : null;
|
|
36
47
|
return {
|
|
37
|
-
|
|
48
|
+
// The checked radio is "Default" - the recommended set - while the slider
|
|
49
|
+
// decides the model a send actually uses, so the slider's row wins.
|
|
50
|
+
model: legacyModel ?? sliderOwnerModel ?? checkedModel,
|
|
38
51
|
effort: accessibleValue ?? legacyEffort ?? sliderOwner ?? visiblePowerLabel
|
|
39
52
|
};
|
|
40
53
|
}
|
|
54
|
+
/**
|
|
55
|
+
* Decide the next move when walking the composer picker's menu by keyboard.
|
|
56
|
+
*
|
|
57
|
+
* The model rows carry pointer-events: none and refuse focus(), so the menu's
|
|
58
|
+
* own roving focus is the only way to reach them. Only a radio counts as the
|
|
59
|
+
* target: the row above the slider repeats the model name as its first line,
|
|
60
|
+
* and committing there selects nothing.
|
|
61
|
+
*/
|
|
62
|
+
export function menuKeyboardStep(input) {
|
|
63
|
+
const { active, requested, pressed, limit } = input;
|
|
64
|
+
if (!active)
|
|
65
|
+
return "exhausted";
|
|
66
|
+
if (active.role === "menuitemradio") {
|
|
67
|
+
const label = active.label.trim().toLowerCase();
|
|
68
|
+
if (requested.some((candidate) => candidate.trim().toLowerCase() === label))
|
|
69
|
+
return "select";
|
|
70
|
+
}
|
|
71
|
+
return pressed < limit ? "down" : "exhausted";
|
|
72
|
+
}
|