@jupyter-ai/persona-manager 0.1.0 → 0.1.2
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/lib/persona-controls.d.ts +20 -0
- package/lib/persona-controls.js +42 -20
- package/package.json +5 -1
- package/src/__tests__/persona-controls-menus.spec.tsx +158 -0
- package/src/persona-controls.tsx +59 -21
- package/style/base.css +26 -9
|
@@ -51,6 +51,26 @@ export declare function showLoadingPlaceholder(hasAwareness: boolean, managerRes
|
|
|
51
51
|
* control's kind. A null value resets that control to the persona's default.
|
|
52
52
|
*/
|
|
53
53
|
export declare function applyControlChange(settings: PersonaSettings, control: Control, value: string | null): PersonaSettings;
|
|
54
|
+
/**
|
|
55
|
+
* A dropdown for a control, titled with the control's label. The first choice
|
|
56
|
+
* row is "Default" (selection = null); the rest are the persona's advertised
|
|
57
|
+
* options (selection = that option's id). Exported for tests.
|
|
58
|
+
*/
|
|
59
|
+
export declare function ControlMenu(props: {
|
|
60
|
+
control: Control;
|
|
61
|
+
onSelect: (value: string | null) => void;
|
|
62
|
+
}): JSX.Element;
|
|
63
|
+
/**
|
|
64
|
+
* The overflow popover: controls that did not fit inline, shown as a single flat
|
|
65
|
+
* menu (no nested dropdowns). Each control renders as a group label followed by
|
|
66
|
+
* its Default row and choices. Exported for tests.
|
|
67
|
+
*/
|
|
68
|
+
export declare function OverflowControlsMenu(props: {
|
|
69
|
+
controls: Control[];
|
|
70
|
+
anchor: HTMLElement | null;
|
|
71
|
+
onClose: () => void;
|
|
72
|
+
onChange: (control: Control, value: string | null) => void;
|
|
73
|
+
}): JSX.Element;
|
|
54
74
|
/**
|
|
55
75
|
* Format a token count compactly: 950 stays as-is, 41500 becomes "41.5k",
|
|
56
76
|
* 1240000 becomes "1.24M". `Intl.NumberFormat` picks the tier after rounding,
|
package/lib/persona-controls.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
1
|
+
import React, { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from 'react';
|
|
2
2
|
import { Button, ListItemText, ListSubheader, Menu, MenuItem, Popover, Skeleton } from '@mui/material';
|
|
3
3
|
import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown';
|
|
4
4
|
import CheckIcon from '@mui/icons-material/Check';
|
|
@@ -189,12 +189,15 @@ function currentControlLabel(control) {
|
|
|
189
189
|
* description, which is just noise). The full description is available on hover.
|
|
190
190
|
*/
|
|
191
191
|
function ChoiceMenuItem(props) {
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
192
|
+
// MenuList clones the row it picks for initial focus with extra props
|
|
193
|
+
// (tabIndex, autoFocus); forward them to the MenuItem, or no row is ever
|
|
194
|
+
// focused and the menu's arrow-key and type-ahead handling never engages.
|
|
195
|
+
const { primary, description: rawDescription, selected, onSelect, ...menuItemProps } = props;
|
|
196
|
+
const description = rawDescription &&
|
|
197
|
+
rawDescription.trim().toLowerCase() !== primary.trim().toLowerCase()
|
|
198
|
+
? rawDescription
|
|
196
199
|
: null;
|
|
197
|
-
return (React.createElement(MenuItem, { selected: selected, onClick: onSelect, title: description !== null && description !== void 0 ? description : undefined },
|
|
200
|
+
return (React.createElement(MenuItem, { ...menuItemProps, selected: selected, onClick: onSelect, title: description !== null && description !== void 0 ? description : undefined },
|
|
198
201
|
React.createElement(ListItemText, { primary: primary, secondary: description, classes: {
|
|
199
202
|
primary: `${MENU_CLASS}-name`,
|
|
200
203
|
secondary: `${MENU_CLASS}-desc`
|
|
@@ -213,16 +216,37 @@ function defaultChoiceLabel(control) {
|
|
|
213
216
|
return name ? `Default (${name})` : 'Default';
|
|
214
217
|
}
|
|
215
218
|
/**
|
|
216
|
-
*
|
|
217
|
-
*
|
|
219
|
+
* The uppercase group label used in control menus: it titles a control's own
|
|
220
|
+
* dropdown and labels each control's section in the overflow menu. Rendered
|
|
221
|
+
* with MUI's `ListSubheader`, which has no tabindex, so arrow-key focus skips
|
|
222
|
+
* it and the menu stays keyboard-navigable. Sticky: when the menu scrolls, the
|
|
223
|
+
* label pins to the top, so the group the visible rows belong to stays
|
|
224
|
+
* readable; in the overflow menu the next section's label paints over it on
|
|
225
|
+
* arrival.
|
|
218
226
|
*/
|
|
219
|
-
function
|
|
227
|
+
function ControlMenuSubheader(props) {
|
|
228
|
+
return (React.createElement(ListSubheader, { id: props.id, className: `${MENU_CLASS}-subheader` }, props.label));
|
|
229
|
+
}
|
|
230
|
+
// MUI's MenuList skips initial focus for children whose type carries this
|
|
231
|
+
// static; ListSubheader's own copy is hidden behind the wrapper.
|
|
232
|
+
ControlMenuSubheader.muiSkipListHighlight = true;
|
|
233
|
+
/**
|
|
234
|
+
* A dropdown for a control, titled with the control's label. The first choice
|
|
235
|
+
* row is "Default" (selection = null); the rest are the persona's advertised
|
|
236
|
+
* options (selection = that option's id). Exported for tests.
|
|
237
|
+
*/
|
|
238
|
+
export function ControlMenu(props) {
|
|
220
239
|
const { control, onSelect } = props;
|
|
221
240
|
const [anchor, setAnchor] = useState(null);
|
|
241
|
+
// The heading names the menu for assistive tech: the subheader itself is a
|
|
242
|
+
// roleless, never-focused list row, so without this wiring the popup has no
|
|
243
|
+
// accessible name at all.
|
|
244
|
+
const headingId = useId();
|
|
222
245
|
return (React.createElement(React.Fragment, null,
|
|
223
246
|
React.createElement(Button, { className: `${SELECTOR_CLASS} ${SELECTOR_CLASS}-control-btn`, size: "small", variant: "text", disableRipple: true, endIcon: React.createElement(ArrowDropDownIcon, { className: `${SELECTOR_CLASS}-arrow` }), onClick: event => setAnchor(event.currentTarget), title: control.label },
|
|
224
247
|
React.createElement("span", { className: `${SELECTOR_CLASS}-control-value` }, currentControlLabel(control))),
|
|
225
|
-
React.createElement(Menu, { anchorEl: anchor, open: !!anchor, onClose: () => setAnchor(null), ...menuAnchorProps },
|
|
248
|
+
React.createElement(Menu, { anchorEl: anchor, open: !!anchor, onClose: () => setAnchor(null), MenuListProps: { 'aria-labelledby': headingId }, ...menuAnchorProps },
|
|
249
|
+
React.createElement(ControlMenuSubheader, { id: headingId, label: control.label }),
|
|
226
250
|
React.createElement(ChoiceMenuItem, { primary: defaultChoiceLabel(control), description: null, selected: control.selection === null, onSelect: () => {
|
|
227
251
|
setAnchor(null);
|
|
228
252
|
onSelect(null);
|
|
@@ -234,15 +258,13 @@ function ControlItem(props) {
|
|
|
234
258
|
}
|
|
235
259
|
/**
|
|
236
260
|
* The overflow popover: controls that did not fit inline, shown as a single flat
|
|
237
|
-
* menu (no nested dropdowns). Each control renders as a
|
|
238
|
-
*
|
|
239
|
-
* menu keyboard-navigable: `ListSubheader` has no tabindex so arrow-key focus
|
|
240
|
-
* skips it.
|
|
261
|
+
* menu (no nested dropdowns). Each control renders as a group label followed by
|
|
262
|
+
* its Default row and choices. Exported for tests.
|
|
241
263
|
*/
|
|
242
|
-
function
|
|
264
|
+
export function OverflowControlsMenu(props) {
|
|
243
265
|
const { controls, anchor, onClose, onChange } = props;
|
|
244
|
-
return (React.createElement(Menu, { anchorEl: anchor, open: !!anchor, onClose: onClose, ...menuAnchorProps }, controls.flatMap(control => [
|
|
245
|
-
React.createElement(
|
|
266
|
+
return (React.createElement(Menu, { anchorEl: anchor, open: !!anchor, onClose: onClose, MenuListProps: { 'aria-label': 'More controls' }, ...menuAnchorProps }, controls.flatMap(control => [
|
|
267
|
+
React.createElement(ControlMenuSubheader, { key: `${control.id}-label`, label: control.label }),
|
|
246
268
|
React.createElement(ChoiceMenuItem, { key: `${control.id}-default`, primary: defaultChoiceLabel(control), description: null, selected: control.selection === null, onSelect: () => {
|
|
247
269
|
onClose();
|
|
248
270
|
onChange(control, null);
|
|
@@ -321,12 +343,12 @@ function ControlsRow(props) {
|
|
|
321
343
|
const visible = controls.slice(0, visibleCount);
|
|
322
344
|
const overflow = controls.slice(visibleCount);
|
|
323
345
|
return (React.createElement("div", { className: `${SELECTOR_CLASS}-controls`, ref: rowRef },
|
|
324
|
-
React.createElement("div", { className: `${SELECTOR_CLASS}-controls-measure`, ref: measureRef, "aria-hidden": "true" }, controls.map(control => (React.createElement(
|
|
325
|
-
visible.map(control => (React.createElement(
|
|
346
|
+
React.createElement("div", { className: `${SELECTOR_CLASS}-controls-measure`, ref: measureRef, "aria-hidden": "true" }, controls.map(control => (React.createElement(ControlMenu, { key: control.id, control: control, onSelect: v => onChange(control, v) })))),
|
|
347
|
+
visible.map(control => (React.createElement(ControlMenu, { key: control.id, control: control, onSelect: v => onChange(control, v) }))),
|
|
326
348
|
overflow.length ? (React.createElement(React.Fragment, null,
|
|
327
349
|
React.createElement("button", { type: "button", ref: overflowBtnRef, className: `${SELECTOR_CLASS} ${SELECTOR_CLASS}-overflow-btn`, onClick: event => setOverflowAnchor(event.currentTarget), title: "More controls", "aria-label": "More controls" },
|
|
328
350
|
React.createElement(MoreHorizIcon, { fontSize: "small" })),
|
|
329
|
-
React.createElement(
|
|
351
|
+
React.createElement(OverflowControlsMenu, { controls: overflow, anchor: overflowAnchor, onClose: () => setOverflowAnchor(null), onChange: onChange }))) : null));
|
|
330
352
|
}
|
|
331
353
|
// All formatters pin the `en` locale so numbers agree with each other and
|
|
332
354
|
// with the surrounding English labels.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jupyter-ai/persona-manager",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "The core manager & registry for AI personas in Jupyter AI",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"jupyter",
|
|
@@ -67,10 +67,14 @@
|
|
|
67
67
|
"devDependencies": {
|
|
68
68
|
"@jupyterlab/builder": "^4.0.0",
|
|
69
69
|
"@jupyterlab/testutils": "^4.0.0",
|
|
70
|
+
"@testing-library/dom": "^10.4.0",
|
|
71
|
+
"@testing-library/react": "^16.3.0",
|
|
72
|
+
"@testing-library/user-event": "^14.6.0",
|
|
70
73
|
"@types/jest": "^29.2.0",
|
|
71
74
|
"@types/json-schema": "^7.0.11",
|
|
72
75
|
"@types/react": "^18.0.26",
|
|
73
76
|
"@types/react-addons-linked-state-mixin": "^0.14.22",
|
|
77
|
+
"@types/react-dom": "^18.0.0",
|
|
74
78
|
"@typescript-eslint/eslint-plugin": "^6.1.0",
|
|
75
79
|
"@typescript-eslint/parser": "^6.1.0",
|
|
76
80
|
"css-loader": "^6.7.1",
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Renders the control menus and pins their user-observable contract: each
|
|
3
|
+
* dropdown opens titled by its control's heading, the heading names the menu
|
|
4
|
+
* for assistive tech without joining keyboard traversal, and arrow keys,
|
|
5
|
+
* type-ahead, and Enter drive the choice rows.
|
|
6
|
+
*/
|
|
7
|
+
import React from 'react';
|
|
8
|
+
import { render, screen } from '@testing-library/react';
|
|
9
|
+
import userEvent from '@testing-library/user-event';
|
|
10
|
+
import {
|
|
11
|
+
Control,
|
|
12
|
+
ControlMenu,
|
|
13
|
+
OverflowControlsMenu
|
|
14
|
+
} from '../persona-controls';
|
|
15
|
+
|
|
16
|
+
const SUBHEADER_SELECTOR = 'li.jp-jai-controlMenu-subheader';
|
|
17
|
+
|
|
18
|
+
function modelControl(selection: string | null): Control {
|
|
19
|
+
return {
|
|
20
|
+
id: '__model__',
|
|
21
|
+
kind: 'model',
|
|
22
|
+
label: 'Model',
|
|
23
|
+
current: 'alpha',
|
|
24
|
+
selection,
|
|
25
|
+
options: [
|
|
26
|
+
{ id: 'alpha', name: 'Alpha', description: null },
|
|
27
|
+
{ id: 'beta', name: 'Beta', description: null }
|
|
28
|
+
]
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function heading(): HTMLElement {
|
|
33
|
+
return document.querySelector(SUBHEADER_SELECTOR) as HTMLElement;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function focused(): HTMLElement {
|
|
37
|
+
return document.activeElement as HTMLElement;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('control menus', () => {
|
|
41
|
+
describe('control dropdown', () => {
|
|
42
|
+
async function openMenu(
|
|
43
|
+
control: Control,
|
|
44
|
+
onSelect: (value: string | null) => void = () => undefined
|
|
45
|
+
): Promise<ReturnType<typeof userEvent.setup>> {
|
|
46
|
+
const user = userEvent.setup();
|
|
47
|
+
render(<ControlMenu control={control} onSelect={onSelect} />);
|
|
48
|
+
await user.click(screen.getByRole('button'));
|
|
49
|
+
return user;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
it('opens titled by a heading that names the menu', async () => {
|
|
53
|
+
await openMenu(modelControl('beta'));
|
|
54
|
+
expect(heading().textContent).toBe('Model');
|
|
55
|
+
expect(heading().hasAttribute('tabindex')).toBe(false);
|
|
56
|
+
expect(heading().id).toBeTruthy();
|
|
57
|
+
expect(screen.getByRole('menu').getAttribute('aria-labelledby')).toBe(
|
|
58
|
+
heading().id
|
|
59
|
+
);
|
|
60
|
+
// Sticky, so the title stays visible while a long option list scrolls.
|
|
61
|
+
expect(heading().classList.contains('MuiListSubheader-sticky')).toBe(
|
|
62
|
+
true
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('focuses the selected row and moves focus with the keyboard', async () => {
|
|
67
|
+
const user = await openMenu(modelControl('beta'));
|
|
68
|
+
// Rows: heading, "Default (Alpha)", "Alpha", "Beta"; "Beta" is selected.
|
|
69
|
+
expect(focused()).toBe(screen.getByRole('menuitem', { name: 'Beta' }));
|
|
70
|
+
// Wrapping from the last row skips the heading to the first choice row.
|
|
71
|
+
await user.keyboard('{ArrowDown}');
|
|
72
|
+
expect(focused()).toBe(
|
|
73
|
+
screen.getByRole('menuitem', { name: 'Default (Alpha)' })
|
|
74
|
+
);
|
|
75
|
+
await user.keyboard('{ArrowDown}');
|
|
76
|
+
expect(focused()).toBe(screen.getByRole('menuitem', { name: 'Alpha' }));
|
|
77
|
+
// Type-ahead: "b" jumps to Beta.
|
|
78
|
+
await user.keyboard('b');
|
|
79
|
+
expect(focused()).toBe(screen.getByRole('menuitem', { name: 'Beta' }));
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('leaves focus in place when type-ahead matches only the heading', async () => {
|
|
83
|
+
const user = await openMenu(modelControl('beta'));
|
|
84
|
+
// First key after open, so it cannot buffer onto a previous press: "m"
|
|
85
|
+
// matches the heading ("Model") and no choice row, and the heading
|
|
86
|
+
// never takes focus.
|
|
87
|
+
await user.keyboard('m');
|
|
88
|
+
expect(focused()).toBe(screen.getByRole('menuitem', { name: 'Beta' }));
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('falls back to the first choice row when the selection is stale', async () => {
|
|
92
|
+
// A selection id the persona no longer advertises leaves no row
|
|
93
|
+
// selected; initial focus then skips the heading to the first row.
|
|
94
|
+
await openMenu(modelControl('stale'));
|
|
95
|
+
expect(focused()).toBe(
|
|
96
|
+
screen.getByRole('menuitem', { name: 'Default (Alpha)' })
|
|
97
|
+
);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('activates the focused row with Enter', async () => {
|
|
101
|
+
const onSelect = jest.fn();
|
|
102
|
+
const user = await openMenu(modelControl(null), onSelect);
|
|
103
|
+
await user.keyboard('{ArrowDown}{ArrowDown}{Enter}');
|
|
104
|
+
expect(onSelect).toHaveBeenCalledWith('beta');
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe('overflow menu', () => {
|
|
109
|
+
it('is labeled and arrows skip the section headings', async () => {
|
|
110
|
+
const first: Control = {
|
|
111
|
+
id: 'a',
|
|
112
|
+
kind: 'setting',
|
|
113
|
+
label: 'Aaa',
|
|
114
|
+
current: null,
|
|
115
|
+
selection: 'a1',
|
|
116
|
+
options: [{ id: 'a1', name: 'A one', description: null }]
|
|
117
|
+
};
|
|
118
|
+
const second: Control = {
|
|
119
|
+
id: 'b',
|
|
120
|
+
kind: 'setting',
|
|
121
|
+
label: 'Bbb',
|
|
122
|
+
current: 'b1',
|
|
123
|
+
selection: 'stale',
|
|
124
|
+
options: [{ id: 'b1', name: 'B one', description: null }]
|
|
125
|
+
};
|
|
126
|
+
const user = userEvent.setup();
|
|
127
|
+
const anchor = document.createElement('button');
|
|
128
|
+
document.body.appendChild(anchor);
|
|
129
|
+
render(
|
|
130
|
+
<OverflowControlsMenu
|
|
131
|
+
controls={[first, second]}
|
|
132
|
+
anchor={anchor}
|
|
133
|
+
onClose={() => undefined}
|
|
134
|
+
onChange={() => undefined}
|
|
135
|
+
/>
|
|
136
|
+
);
|
|
137
|
+
expect(screen.getByRole('menu').getAttribute('aria-label')).toBe(
|
|
138
|
+
'More controls'
|
|
139
|
+
);
|
|
140
|
+
const headings = Array.from(
|
|
141
|
+
document.querySelectorAll(SUBHEADER_SELECTOR)
|
|
142
|
+
);
|
|
143
|
+
expect(headings.map(h => h.textContent)).toEqual(['Aaa', 'Bbb']);
|
|
144
|
+
// Sticky, so the section a scrolled row belongs to stays visible.
|
|
145
|
+
for (const h of headings) {
|
|
146
|
+
expect(h.classList.contains('MuiListSubheader-sticky')).toBe(true);
|
|
147
|
+
}
|
|
148
|
+
// "A one" is the only selected row; ArrowDown crosses the "Bbb" heading
|
|
149
|
+
// to the next section's first choice row.
|
|
150
|
+
expect(focused()).toBe(screen.getByRole('menuitem', { name: 'A one' }));
|
|
151
|
+
await user.keyboard('{ArrowDown}');
|
|
152
|
+
expect(focused()).toBe(
|
|
153
|
+
screen.getByRole('menuitem', { name: 'Default (B one)' })
|
|
154
|
+
);
|
|
155
|
+
anchor.remove();
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
});
|
package/src/persona-controls.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import React, {
|
|
2
2
|
useCallback,
|
|
3
3
|
useEffect,
|
|
4
|
+
useId,
|
|
4
5
|
useLayoutEffect,
|
|
5
6
|
useRef,
|
|
6
7
|
useState
|
|
@@ -280,14 +281,24 @@ function ChoiceMenuItem(props: {
|
|
|
280
281
|
selected: boolean;
|
|
281
282
|
onSelect: () => void;
|
|
282
283
|
}): JSX.Element {
|
|
283
|
-
|
|
284
|
+
// MenuList clones the row it picks for initial focus with extra props
|
|
285
|
+
// (tabIndex, autoFocus); forward them to the MenuItem, or no row is ever
|
|
286
|
+
// focused and the menu's arrow-key and type-ahead handling never engages.
|
|
287
|
+
const {
|
|
288
|
+
primary,
|
|
289
|
+
description: rawDescription,
|
|
290
|
+
selected,
|
|
291
|
+
onSelect,
|
|
292
|
+
...menuItemProps
|
|
293
|
+
} = props;
|
|
284
294
|
const description =
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
?
|
|
295
|
+
rawDescription &&
|
|
296
|
+
rawDescription.trim().toLowerCase() !== primary.trim().toLowerCase()
|
|
297
|
+
? rawDescription
|
|
288
298
|
: null;
|
|
289
299
|
return (
|
|
290
300
|
<MenuItem
|
|
301
|
+
{...menuItemProps}
|
|
291
302
|
selected={selected}
|
|
292
303
|
onClick={onSelect}
|
|
293
304
|
title={description ?? undefined}
|
|
@@ -319,15 +330,44 @@ function defaultChoiceLabel(control: Control): string {
|
|
|
319
330
|
}
|
|
320
331
|
|
|
321
332
|
/**
|
|
322
|
-
*
|
|
323
|
-
*
|
|
333
|
+
* The uppercase group label used in control menus: it titles a control's own
|
|
334
|
+
* dropdown and labels each control's section in the overflow menu. Rendered
|
|
335
|
+
* with MUI's `ListSubheader`, which has no tabindex, so arrow-key focus skips
|
|
336
|
+
* it and the menu stays keyboard-navigable. Sticky: when the menu scrolls, the
|
|
337
|
+
* label pins to the top, so the group the visible rows belong to stays
|
|
338
|
+
* readable; in the overflow menu the next section's label paints over it on
|
|
339
|
+
* arrival.
|
|
324
340
|
*/
|
|
325
|
-
function
|
|
341
|
+
function ControlMenuSubheader(props: {
|
|
342
|
+
label: string;
|
|
343
|
+
id?: string;
|
|
344
|
+
}): JSX.Element {
|
|
345
|
+
return (
|
|
346
|
+
<ListSubheader id={props.id} className={`${MENU_CLASS}-subheader`}>
|
|
347
|
+
{props.label}
|
|
348
|
+
</ListSubheader>
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// MUI's MenuList skips initial focus for children whose type carries this
|
|
353
|
+
// static; ListSubheader's own copy is hidden behind the wrapper.
|
|
354
|
+
ControlMenuSubheader.muiSkipListHighlight = true;
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* A dropdown for a control, titled with the control's label. The first choice
|
|
358
|
+
* row is "Default" (selection = null); the rest are the persona's advertised
|
|
359
|
+
* options (selection = that option's id). Exported for tests.
|
|
360
|
+
*/
|
|
361
|
+
export function ControlMenu(props: {
|
|
326
362
|
control: Control;
|
|
327
363
|
onSelect: (value: string | null) => void;
|
|
328
364
|
}): JSX.Element {
|
|
329
365
|
const { control, onSelect } = props;
|
|
330
366
|
const [anchor, setAnchor] = useState<HTMLElement | null>(null);
|
|
367
|
+
// The heading names the menu for assistive tech: the subheader itself is a
|
|
368
|
+
// roleless, never-focused list row, so without this wiring the popup has no
|
|
369
|
+
// accessible name at all.
|
|
370
|
+
const headingId = useId();
|
|
331
371
|
return (
|
|
332
372
|
<>
|
|
333
373
|
<Button
|
|
@@ -347,8 +387,10 @@ function ControlItem(props: {
|
|
|
347
387
|
anchorEl={anchor}
|
|
348
388
|
open={!!anchor}
|
|
349
389
|
onClose={() => setAnchor(null)}
|
|
390
|
+
MenuListProps={{ 'aria-labelledby': headingId }}
|
|
350
391
|
{...menuAnchorProps}
|
|
351
392
|
>
|
|
393
|
+
<ControlMenuSubheader id={headingId} label={control.label} />
|
|
352
394
|
<ChoiceMenuItem
|
|
353
395
|
primary={defaultChoiceLabel(control)}
|
|
354
396
|
description={null}
|
|
@@ -377,12 +419,10 @@ function ControlItem(props: {
|
|
|
377
419
|
|
|
378
420
|
/**
|
|
379
421
|
* The overflow popover: controls that did not fit inline, shown as a single flat
|
|
380
|
-
* menu (no nested dropdowns). Each control renders as a
|
|
381
|
-
*
|
|
382
|
-
* menu keyboard-navigable: `ListSubheader` has no tabindex so arrow-key focus
|
|
383
|
-
* skips it.
|
|
422
|
+
* menu (no nested dropdowns). Each control renders as a group label followed by
|
|
423
|
+
* its Default row and choices. Exported for tests.
|
|
384
424
|
*/
|
|
385
|
-
function
|
|
425
|
+
export function OverflowControlsMenu(props: {
|
|
386
426
|
controls: Control[];
|
|
387
427
|
anchor: HTMLElement | null;
|
|
388
428
|
onClose: () => void;
|
|
@@ -394,16 +434,14 @@ function OverflowMenu(props: {
|
|
|
394
434
|
anchorEl={anchor}
|
|
395
435
|
open={!!anchor}
|
|
396
436
|
onClose={onClose}
|
|
437
|
+
MenuListProps={{ 'aria-label': 'More controls' }}
|
|
397
438
|
{...menuAnchorProps}
|
|
398
439
|
>
|
|
399
440
|
{controls.flatMap(control => [
|
|
400
|
-
<
|
|
441
|
+
<ControlMenuSubheader
|
|
401
442
|
key={`${control.id}-label`}
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
>
|
|
405
|
-
{control.label}
|
|
406
|
-
</ListSubheader>,
|
|
443
|
+
label={control.label}
|
|
444
|
+
/>,
|
|
407
445
|
<ChoiceMenuItem
|
|
408
446
|
key={`${control.id}-default`}
|
|
409
447
|
primary={defaultChoiceLabel(control)}
|
|
@@ -517,7 +555,7 @@ function ControlsRow(props: {
|
|
|
517
555
|
aria-hidden="true"
|
|
518
556
|
>
|
|
519
557
|
{controls.map(control => (
|
|
520
|
-
<
|
|
558
|
+
<ControlMenu
|
|
521
559
|
key={control.id}
|
|
522
560
|
control={control}
|
|
523
561
|
onSelect={v => onChange(control, v)}
|
|
@@ -526,7 +564,7 @@ function ControlsRow(props: {
|
|
|
526
564
|
</div>
|
|
527
565
|
|
|
528
566
|
{visible.map(control => (
|
|
529
|
-
<
|
|
567
|
+
<ControlMenu
|
|
530
568
|
key={control.id}
|
|
531
569
|
control={control}
|
|
532
570
|
onSelect={v => onChange(control, v)}
|
|
@@ -545,7 +583,7 @@ function ControlsRow(props: {
|
|
|
545
583
|
>
|
|
546
584
|
<MoreHorizIcon fontSize="small" />
|
|
547
585
|
</button>
|
|
548
|
-
<
|
|
586
|
+
<OverflowControlsMenu
|
|
549
587
|
controls={overflow}
|
|
550
588
|
anchor={overflowAnchor}
|
|
551
589
|
onClose={() => setOverflowAnchor(null)}
|
package/style/base.css
CHANGED
|
@@ -229,6 +229,15 @@
|
|
|
229
229
|
box-shadow: var(--jp-elevation-z6);
|
|
230
230
|
}
|
|
231
231
|
|
|
232
|
+
/* Keep focus-scrolled rows clear of the stuck group label: when arrow keys
|
|
233
|
+
scroll a row into view at the top, offset it by the label's height so the
|
|
234
|
+
label does not cover it. Only menus containing a group label need the
|
|
235
|
+
offset; the persona picker and usage popover share this paper class and
|
|
236
|
+
keep edge-aligned scrolling. */
|
|
237
|
+
.jp-jai-controlMenu-paper:has(.jp-jai-controlMenu-subheader) {
|
|
238
|
+
scroll-padding-top: 32px;
|
|
239
|
+
}
|
|
240
|
+
|
|
232
241
|
/* stylelint-disable selector-class-pattern -- theming MUI's menu item classes */
|
|
233
242
|
.jp-jai-controlMenu-paper .MuiMenuItem-root {
|
|
234
243
|
padding: 6px 12px;
|
|
@@ -278,10 +287,15 @@
|
|
|
278
287
|
flex-shrink: 0;
|
|
279
288
|
}
|
|
280
289
|
|
|
281
|
-
/*
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
290
|
+
/* Control menu group label: an uppercase heading above a control's choices.
|
|
291
|
+
It titles each control's own dropdown; in the overflow popover a hairline
|
|
292
|
+
divider and top spacing separate the controls' sections. Scoped under the
|
|
293
|
+
paper to win over MUI's ListSubheader defaults. The labels are sticky:
|
|
294
|
+
sticky is MUI's ListSubheader default, and its sticky class supplies the
|
|
295
|
+
positioning. The opaque background is load-bearing, scrolled rows pass
|
|
296
|
+
beneath it. One line only, so stacked sticky labels in the overflow menu
|
|
297
|
+
cover each other exactly. */
|
|
298
|
+
.jp-jai-controlMenu-paper .jp-jai-controlMenu-subheader {
|
|
285
299
|
margin-top: 8px;
|
|
286
300
|
padding: 8px 12px 2px;
|
|
287
301
|
border-top: 1px solid var(--jp-border-color2);
|
|
@@ -293,11 +307,14 @@
|
|
|
293
307
|
text-transform: uppercase;
|
|
294
308
|
color: var(--jp-ui-font-color1);
|
|
295
309
|
background-color: var(--jp-layout-color1);
|
|
310
|
+
white-space: nowrap;
|
|
311
|
+
overflow: hidden;
|
|
312
|
+
text-overflow: ellipsis;
|
|
296
313
|
}
|
|
297
314
|
|
|
298
|
-
/* first
|
|
299
|
-
.
|
|
300
|
-
|
|
315
|
+
/* The first (or only) label sits flush at the top, no divider or extra gap
|
|
316
|
+
above it. */
|
|
317
|
+
.jp-jai-controlMenu-paper .jp-jai-controlMenu-subheader:first-child {
|
|
301
318
|
margin-top: 0;
|
|
302
319
|
border-top: none;
|
|
303
320
|
}
|
|
@@ -361,8 +378,8 @@
|
|
|
361
378
|
white-space: nowrap;
|
|
362
379
|
}
|
|
363
380
|
|
|
364
|
-
/* Usage popover card: labeled sections in the
|
|
365
|
-
one row per quantity, label muted and value right-aligned. */
|
|
381
|
+
/* Usage popover card: labeled sections in the control menu's subheader style,
|
|
382
|
+
then one row per quantity, label muted and value right-aligned. */
|
|
366
383
|
.jp-jai-usage-card {
|
|
367
384
|
padding: 8px 12px;
|
|
368
385
|
min-width: 220px;
|