@jupyter-ai/persona-manager 0.1.0 → 0.1.1
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 +36 -17
- package/package.json +2 -1
- package/src/__tests__/persona-controls-menus.spec.tsx +175 -0
- package/src/persona-controls.tsx +52 -19
- package/style/base.css +10 -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 ControlItem(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 OverflowMenu(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,34 @@ 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.
|
|
218
223
|
*/
|
|
219
|
-
function
|
|
224
|
+
function ControlSubheader(props) {
|
|
225
|
+
return (React.createElement(ListSubheader, { id: props.id, disableSticky: true, className: `${MENU_CLASS}-subheader` }, props.label));
|
|
226
|
+
}
|
|
227
|
+
// MUI's MenuList skips initial focus for children whose type carries this
|
|
228
|
+
// static; ListSubheader's own copy is hidden behind the wrapper.
|
|
229
|
+
ControlSubheader.muiSkipListHighlight = true;
|
|
230
|
+
/**
|
|
231
|
+
* A dropdown for a control, titled with the control's label. The first choice
|
|
232
|
+
* row is "Default" (selection = null); the rest are the persona's advertised
|
|
233
|
+
* options (selection = that option's id). Exported for tests.
|
|
234
|
+
*/
|
|
235
|
+
export function ControlItem(props) {
|
|
220
236
|
const { control, onSelect } = props;
|
|
221
237
|
const [anchor, setAnchor] = useState(null);
|
|
238
|
+
// The heading names the menu for assistive tech: the subheader itself is a
|
|
239
|
+
// roleless, never-focused list row, so without this wiring the popup has no
|
|
240
|
+
// accessible name at all.
|
|
241
|
+
const headingId = useId();
|
|
222
242
|
return (React.createElement(React.Fragment, null,
|
|
223
243
|
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
244
|
React.createElement("span", { className: `${SELECTOR_CLASS}-control-value` }, currentControlLabel(control))),
|
|
225
|
-
React.createElement(Menu, { anchorEl: anchor, open: !!anchor, onClose: () => setAnchor(null), ...menuAnchorProps },
|
|
245
|
+
React.createElement(Menu, { anchorEl: anchor, open: !!anchor, onClose: () => setAnchor(null), MenuListProps: { 'aria-labelledby': headingId }, ...menuAnchorProps },
|
|
246
|
+
React.createElement(ControlSubheader, { id: headingId, label: control.label }),
|
|
226
247
|
React.createElement(ChoiceMenuItem, { primary: defaultChoiceLabel(control), description: null, selected: control.selection === null, onSelect: () => {
|
|
227
248
|
setAnchor(null);
|
|
228
249
|
onSelect(null);
|
|
@@ -234,15 +255,13 @@ function ControlItem(props) {
|
|
|
234
255
|
}
|
|
235
256
|
/**
|
|
236
257
|
* 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.
|
|
258
|
+
* menu (no nested dropdowns). Each control renders as a group label followed by
|
|
259
|
+
* its Default row and choices. Exported for tests.
|
|
241
260
|
*/
|
|
242
|
-
function OverflowMenu(props) {
|
|
261
|
+
export function OverflowMenu(props) {
|
|
243
262
|
const { controls, anchor, onClose, onChange } = props;
|
|
244
|
-
return (React.createElement(Menu, { anchorEl: anchor, open: !!anchor, onClose: onClose, ...menuAnchorProps }, controls.flatMap(control => [
|
|
245
|
-
React.createElement(
|
|
263
|
+
return (React.createElement(Menu, { anchorEl: anchor, open: !!anchor, onClose: onClose, MenuListProps: { 'aria-label': 'More controls' }, ...menuAnchorProps }, controls.flatMap(control => [
|
|
264
|
+
React.createElement(ControlSubheader, { key: `${control.id}-label`, label: control.label }),
|
|
246
265
|
React.createElement(ChoiceMenuItem, { key: `${control.id}-default`, primary: defaultChoiceLabel(control), description: null, selected: control.selection === null, onSelect: () => {
|
|
247
266
|
onClose();
|
|
248
267
|
onChange(control, null);
|
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.1",
|
|
4
4
|
"description": "The core manager & registry for AI personas in Jupyter AI",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"jupyter",
|
|
@@ -71,6 +71,7 @@
|
|
|
71
71
|
"@types/json-schema": "^7.0.11",
|
|
72
72
|
"@types/react": "^18.0.26",
|
|
73
73
|
"@types/react-addons-linked-state-mixin": "^0.14.22",
|
|
74
|
+
"@types/react-dom": "^18.0.0",
|
|
74
75
|
"@typescript-eslint/eslint-plugin": "^6.1.0",
|
|
75
76
|
"@typescript-eslint/parser": "^6.1.0",
|
|
76
77
|
"css-loader": "^6.7.1",
|
|
@@ -0,0 +1,175 @@
|
|
|
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 { createRoot, Root } from 'react-dom/client';
|
|
9
|
+
import { Control, ControlItem, OverflowMenu } from '../persona-controls';
|
|
10
|
+
|
|
11
|
+
// React 18.3 ships `React.act`, which the installed @types/react (18.0) does
|
|
12
|
+
// not declare yet.
|
|
13
|
+
const { act } = React as unknown as { act: (callback: () => void) => void };
|
|
14
|
+
|
|
15
|
+
(
|
|
16
|
+
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
|
17
|
+
).IS_REACT_ACT_ENVIRONMENT = true;
|
|
18
|
+
|
|
19
|
+
const SUBHEADER_SELECTOR = 'li.jp-jai-controlMenu-subheader';
|
|
20
|
+
|
|
21
|
+
function modelControl(selection: string | null): Control {
|
|
22
|
+
return {
|
|
23
|
+
id: '__model__',
|
|
24
|
+
kind: 'model',
|
|
25
|
+
label: 'Model',
|
|
26
|
+
current: 'alpha',
|
|
27
|
+
selection,
|
|
28
|
+
options: [
|
|
29
|
+
{ id: 'alpha', name: 'Alpha', description: null },
|
|
30
|
+
{ id: 'beta', name: 'Beta', description: null }
|
|
31
|
+
]
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe('control menus', () => {
|
|
36
|
+
let container: HTMLDivElement;
|
|
37
|
+
let root: Root;
|
|
38
|
+
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
container = document.createElement('div');
|
|
41
|
+
document.body.appendChild(container);
|
|
42
|
+
root = createRoot(container);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
afterEach(() => {
|
|
46
|
+
act(() => root.unmount());
|
|
47
|
+
container.remove();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
function menu(): HTMLElement {
|
|
51
|
+
return document.querySelector('ul[role="menu"]') as HTMLElement;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function focused(): HTMLElement {
|
|
55
|
+
return document.activeElement as HTMLElement;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function press(key: string): void {
|
|
59
|
+
act(() => {
|
|
60
|
+
focused().dispatchEvent(
|
|
61
|
+
new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
describe('control dropdown', () => {
|
|
67
|
+
function openMenu(
|
|
68
|
+
control: Control,
|
|
69
|
+
onSelect: (value: string | null) => void = () => undefined
|
|
70
|
+
): void {
|
|
71
|
+
act(() => {
|
|
72
|
+
root.render(<ControlItem control={control} onSelect={onSelect} />);
|
|
73
|
+
});
|
|
74
|
+
act(() => {
|
|
75
|
+
(container.querySelector('button') as HTMLElement).dispatchEvent(
|
|
76
|
+
new MouseEvent('click', { bubbles: true })
|
|
77
|
+
);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
it('opens titled by a heading that names the menu', () => {
|
|
82
|
+
openMenu(modelControl('beta'));
|
|
83
|
+
const heading = document.querySelector(SUBHEADER_SELECTOR) as HTMLElement;
|
|
84
|
+
expect(heading.textContent).toBe('Model');
|
|
85
|
+
expect(heading.hasAttribute('tabindex')).toBe(false);
|
|
86
|
+
expect(heading.id).toBeTruthy();
|
|
87
|
+
expect(menu().getAttribute('aria-labelledby')).toBe(heading.id);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('focuses the selected row and moves focus with the keyboard', () => {
|
|
91
|
+
openMenu(modelControl('beta'));
|
|
92
|
+
// Rows: heading, "Default (Alpha)", "Alpha", "Beta"; "Beta" is selected.
|
|
93
|
+
expect(focused().textContent).toBe('Beta');
|
|
94
|
+
expect(focused().getAttribute('role')).toBe('menuitem');
|
|
95
|
+
// Wrapping from the last row skips the heading to the first choice row.
|
|
96
|
+
press('ArrowDown');
|
|
97
|
+
expect(focused().textContent).toBe('Default (Alpha)');
|
|
98
|
+
press('ArrowDown');
|
|
99
|
+
expect(focused().textContent).toBe('Alpha');
|
|
100
|
+
// Type-ahead: "b" jumps to Beta.
|
|
101
|
+
press('b');
|
|
102
|
+
expect(focused().textContent).toBe('Beta');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('leaves focus in place when type-ahead matches only the heading', () => {
|
|
106
|
+
openMenu(modelControl('beta'));
|
|
107
|
+
// First key after open, so it cannot buffer onto a previous press: "m"
|
|
108
|
+
// matches the heading ("Model") and no choice row, and the heading
|
|
109
|
+
// never takes focus.
|
|
110
|
+
press('m');
|
|
111
|
+
expect(focused().textContent).toBe('Beta');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('falls back to the first choice row when the selection is stale', () => {
|
|
115
|
+
// A selection id the persona no longer advertises leaves no row
|
|
116
|
+
// selected; initial focus then skips the heading to the first row.
|
|
117
|
+
openMenu(modelControl('stale'));
|
|
118
|
+
expect(focused().textContent).toBe('Default (Alpha)');
|
|
119
|
+
expect(focused().getAttribute('role')).toBe('menuitem');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('activates the focused row with Enter', () => {
|
|
123
|
+
const onSelect = jest.fn();
|
|
124
|
+
openMenu(modelControl(null), onSelect);
|
|
125
|
+
press('ArrowDown');
|
|
126
|
+
press('ArrowDown');
|
|
127
|
+
press('Enter');
|
|
128
|
+
expect(onSelect).toHaveBeenCalledWith('beta');
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe('overflow menu', () => {
|
|
133
|
+
it('is labeled and arrows skip the section headings', () => {
|
|
134
|
+
const first: Control = {
|
|
135
|
+
id: 'a',
|
|
136
|
+
kind: 'setting',
|
|
137
|
+
label: 'Aaa',
|
|
138
|
+
current: null,
|
|
139
|
+
selection: 'a1',
|
|
140
|
+
options: [{ id: 'a1', name: 'A one', description: null }]
|
|
141
|
+
};
|
|
142
|
+
const second: Control = {
|
|
143
|
+
id: 'b',
|
|
144
|
+
kind: 'setting',
|
|
145
|
+
label: 'Bbb',
|
|
146
|
+
current: null,
|
|
147
|
+
selection: 'stale',
|
|
148
|
+
options: [{ id: 'b1', name: 'B one', description: null }]
|
|
149
|
+
};
|
|
150
|
+
const anchor = document.createElement('button');
|
|
151
|
+
document.body.appendChild(anchor);
|
|
152
|
+
act(() => {
|
|
153
|
+
root.render(
|
|
154
|
+
<OverflowMenu
|
|
155
|
+
controls={[first, second]}
|
|
156
|
+
anchor={anchor}
|
|
157
|
+
onClose={() => undefined}
|
|
158
|
+
onChange={() => undefined}
|
|
159
|
+
/>
|
|
160
|
+
);
|
|
161
|
+
});
|
|
162
|
+
expect(menu().getAttribute('aria-label')).toBe('More controls');
|
|
163
|
+
const headings = Array.from(
|
|
164
|
+
document.querySelectorAll(SUBHEADER_SELECTOR)
|
|
165
|
+
).map(h => h.textContent);
|
|
166
|
+
expect(headings).toEqual(['Aaa', 'Bbb']);
|
|
167
|
+
// "A one" is the only selected row; ArrowDown crosses the "Bbb" heading
|
|
168
|
+
// to the next section's first choice row.
|
|
169
|
+
expect(focused().textContent).toBe('A one');
|
|
170
|
+
press('ArrowDown');
|
|
171
|
+
expect(focused().textContent).toBe('Default');
|
|
172
|
+
anchor.remove();
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
});
|
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,42 @@ 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.
|
|
324
337
|
*/
|
|
325
|
-
function
|
|
338
|
+
function ControlSubheader(props: { label: string; id?: string }): JSX.Element {
|
|
339
|
+
return (
|
|
340
|
+
<ListSubheader
|
|
341
|
+
id={props.id}
|
|
342
|
+
disableSticky
|
|
343
|
+
className={`${MENU_CLASS}-subheader`}
|
|
344
|
+
>
|
|
345
|
+
{props.label}
|
|
346
|
+
</ListSubheader>
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// MUI's MenuList skips initial focus for children whose type carries this
|
|
351
|
+
// static; ListSubheader's own copy is hidden behind the wrapper.
|
|
352
|
+
ControlSubheader.muiSkipListHighlight = true;
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* A dropdown for a control, titled with the control's label. The first choice
|
|
356
|
+
* row is "Default" (selection = null); the rest are the persona's advertised
|
|
357
|
+
* options (selection = that option's id). Exported for tests.
|
|
358
|
+
*/
|
|
359
|
+
export function ControlItem(props: {
|
|
326
360
|
control: Control;
|
|
327
361
|
onSelect: (value: string | null) => void;
|
|
328
362
|
}): JSX.Element {
|
|
329
363
|
const { control, onSelect } = props;
|
|
330
364
|
const [anchor, setAnchor] = useState<HTMLElement | null>(null);
|
|
365
|
+
// The heading names the menu for assistive tech: the subheader itself is a
|
|
366
|
+
// roleless, never-focused list row, so without this wiring the popup has no
|
|
367
|
+
// accessible name at all.
|
|
368
|
+
const headingId = useId();
|
|
331
369
|
return (
|
|
332
370
|
<>
|
|
333
371
|
<Button
|
|
@@ -347,8 +385,10 @@ function ControlItem(props: {
|
|
|
347
385
|
anchorEl={anchor}
|
|
348
386
|
open={!!anchor}
|
|
349
387
|
onClose={() => setAnchor(null)}
|
|
388
|
+
MenuListProps={{ 'aria-labelledby': headingId }}
|
|
350
389
|
{...menuAnchorProps}
|
|
351
390
|
>
|
|
391
|
+
<ControlSubheader id={headingId} label={control.label} />
|
|
352
392
|
<ChoiceMenuItem
|
|
353
393
|
primary={defaultChoiceLabel(control)}
|
|
354
394
|
description={null}
|
|
@@ -377,12 +417,10 @@ function ControlItem(props: {
|
|
|
377
417
|
|
|
378
418
|
/**
|
|
379
419
|
* 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.
|
|
420
|
+
* menu (no nested dropdowns). Each control renders as a group label followed by
|
|
421
|
+
* its Default row and choices. Exported for tests.
|
|
384
422
|
*/
|
|
385
|
-
function OverflowMenu(props: {
|
|
423
|
+
export function OverflowMenu(props: {
|
|
386
424
|
controls: Control[];
|
|
387
425
|
anchor: HTMLElement | null;
|
|
388
426
|
onClose: () => void;
|
|
@@ -394,16 +432,11 @@ function OverflowMenu(props: {
|
|
|
394
432
|
anchorEl={anchor}
|
|
395
433
|
open={!!anchor}
|
|
396
434
|
onClose={onClose}
|
|
435
|
+
MenuListProps={{ 'aria-label': 'More controls' }}
|
|
397
436
|
{...menuAnchorProps}
|
|
398
437
|
>
|
|
399
438
|
{controls.flatMap(control => [
|
|
400
|
-
<
|
|
401
|
-
key={`${control.id}-label`}
|
|
402
|
-
disableSticky
|
|
403
|
-
className={`${SELECTOR_CLASS}-overflow-subheader`}
|
|
404
|
-
>
|
|
405
|
-
{control.label}
|
|
406
|
-
</ListSubheader>,
|
|
439
|
+
<ControlSubheader key={`${control.id}-label`} label={control.label} />,
|
|
407
440
|
<ChoiceMenuItem
|
|
408
441
|
key={`${control.id}-default`}
|
|
409
442
|
primary={defaultChoiceLabel(control)}
|
package/style/base.css
CHANGED
|
@@ -278,10 +278,11 @@
|
|
|
278
278
|
flex-shrink: 0;
|
|
279
279
|
}
|
|
280
280
|
|
|
281
|
-
/*
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
281
|
+
/* Control menu group label: an uppercase heading above a control's choices.
|
|
282
|
+
It titles each control's own dropdown; in the overflow popover a hairline
|
|
283
|
+
divider and top spacing separate the controls' sections. Scoped under the
|
|
284
|
+
paper to win over MUI's ListSubheader defaults. */
|
|
285
|
+
.jp-jai-controlMenu-paper .jp-jai-controlMenu-subheader {
|
|
285
286
|
margin-top: 8px;
|
|
286
287
|
padding: 8px 12px 2px;
|
|
287
288
|
border-top: 1px solid var(--jp-border-color2);
|
|
@@ -295,9 +296,9 @@
|
|
|
295
296
|
background-color: var(--jp-layout-color1);
|
|
296
297
|
}
|
|
297
298
|
|
|
298
|
-
/* first
|
|
299
|
-
.
|
|
300
|
-
|
|
299
|
+
/* The first (or only) label sits flush at the top, no divider or extra gap
|
|
300
|
+
above it. */
|
|
301
|
+
.jp-jai-controlMenu-paper .jp-jai-controlMenu-subheader:first-child {
|
|
301
302
|
margin-top: 0;
|
|
302
303
|
border-top: none;
|
|
303
304
|
}
|
|
@@ -361,8 +362,8 @@
|
|
|
361
362
|
white-space: nowrap;
|
|
362
363
|
}
|
|
363
364
|
|
|
364
|
-
/* Usage popover card: labeled sections in the
|
|
365
|
-
one row per quantity, label muted and value right-aligned. */
|
|
365
|
+
/* Usage popover card: labeled sections in the control menu's subheader style,
|
|
366
|
+
then one row per quantity, label muted and value right-aligned. */
|
|
366
367
|
.jp-jai-usage-card {
|
|
367
368
|
padding: 8px 12px;
|
|
368
369
|
min-width: 220px;
|