@jupyter-ai/persona-manager 0.1.1 → 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.
@@ -56,7 +56,7 @@ export declare function applyControlChange(settings: PersonaSettings, control: C
56
56
  * row is "Default" (selection = null); the rest are the persona's advertised
57
57
  * options (selection = that option's id). Exported for tests.
58
58
  */
59
- export declare function ControlItem(props: {
59
+ export declare function ControlMenu(props: {
60
60
  control: Control;
61
61
  onSelect: (value: string | null) => void;
62
62
  }): JSX.Element;
@@ -65,7 +65,7 @@ export declare function ControlItem(props: {
65
65
  * menu (no nested dropdowns). Each control renders as a group label followed by
66
66
  * its Default row and choices. Exported for tests.
67
67
  */
68
- export declare function OverflowMenu(props: {
68
+ export declare function OverflowControlsMenu(props: {
69
69
  controls: Control[];
70
70
  anchor: HTMLElement | null;
71
71
  onClose: () => void;
@@ -219,20 +219,23 @@ function defaultChoiceLabel(control) {
219
219
  * The uppercase group label used in control menus: it titles a control's own
220
220
  * dropdown and labels each control's section in the overflow menu. Rendered
221
221
  * with MUI's `ListSubheader`, which has no tabindex, so arrow-key focus skips
222
- * it and the menu stays keyboard-navigable.
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.
223
226
  */
224
- function ControlSubheader(props) {
225
- return (React.createElement(ListSubheader, { id: props.id, disableSticky: true, className: `${MENU_CLASS}-subheader` }, props.label));
227
+ function ControlMenuSubheader(props) {
228
+ return (React.createElement(ListSubheader, { id: props.id, className: `${MENU_CLASS}-subheader` }, props.label));
226
229
  }
227
230
  // MUI's MenuList skips initial focus for children whose type carries this
228
231
  // static; ListSubheader's own copy is hidden behind the wrapper.
229
- ControlSubheader.muiSkipListHighlight = true;
232
+ ControlMenuSubheader.muiSkipListHighlight = true;
230
233
  /**
231
234
  * A dropdown for a control, titled with the control's label. The first choice
232
235
  * row is "Default" (selection = null); the rest are the persona's advertised
233
236
  * options (selection = that option's id). Exported for tests.
234
237
  */
235
- export function ControlItem(props) {
238
+ export function ControlMenu(props) {
236
239
  const { control, onSelect } = props;
237
240
  const [anchor, setAnchor] = useState(null);
238
241
  // The heading names the menu for assistive tech: the subheader itself is a
@@ -243,7 +246,7 @@ export function ControlItem(props) {
243
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 },
244
247
  React.createElement("span", { className: `${SELECTOR_CLASS}-control-value` }, currentControlLabel(control))),
245
248
  React.createElement(Menu, { anchorEl: anchor, open: !!anchor, onClose: () => setAnchor(null), MenuListProps: { 'aria-labelledby': headingId }, ...menuAnchorProps },
246
- React.createElement(ControlSubheader, { id: headingId, label: control.label }),
249
+ React.createElement(ControlMenuSubheader, { id: headingId, label: control.label }),
247
250
  React.createElement(ChoiceMenuItem, { primary: defaultChoiceLabel(control), description: null, selected: control.selection === null, onSelect: () => {
248
251
  setAnchor(null);
249
252
  onSelect(null);
@@ -258,10 +261,10 @@ export function ControlItem(props) {
258
261
  * menu (no nested dropdowns). Each control renders as a group label followed by
259
262
  * its Default row and choices. Exported for tests.
260
263
  */
261
- export function OverflowMenu(props) {
264
+ export function OverflowControlsMenu(props) {
262
265
  const { controls, anchor, onClose, onChange } = props;
263
266
  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 }),
267
+ React.createElement(ControlMenuSubheader, { key: `${control.id}-label`, label: control.label }),
265
268
  React.createElement(ChoiceMenuItem, { key: `${control.id}-default`, primary: defaultChoiceLabel(control), description: null, selected: control.selection === null, onSelect: () => {
266
269
  onClose();
267
270
  onChange(control, null);
@@ -340,12 +343,12 @@ function ControlsRow(props) {
340
343
  const visible = controls.slice(0, visibleCount);
341
344
  const overflow = controls.slice(visibleCount);
342
345
  return (React.createElement("div", { className: `${SELECTOR_CLASS}-controls`, ref: rowRef },
343
- React.createElement("div", { className: `${SELECTOR_CLASS}-controls-measure`, ref: measureRef, "aria-hidden": "true" }, controls.map(control => (React.createElement(ControlItem, { key: control.id, control: control, onSelect: v => onChange(control, v) })))),
344
- visible.map(control => (React.createElement(ControlItem, { key: control.id, control: control, onSelect: v => onChange(control, v) }))),
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) }))),
345
348
  overflow.length ? (React.createElement(React.Fragment, null,
346
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" },
347
350
  React.createElement(MoreHorizIcon, { fontSize: "small" })),
348
- React.createElement(OverflowMenu, { controls: overflow, anchor: overflowAnchor, onClose: () => setOverflowAnchor(null), onChange: onChange }))) : null));
351
+ React.createElement(OverflowControlsMenu, { controls: overflow, anchor: overflowAnchor, onClose: () => setOverflowAnchor(null), onChange: onChange }))) : null));
349
352
  }
350
353
  // All formatters pin the `en` locale so numbers agree with each other and
351
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.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,6 +67,9 @@
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",
@@ -5,16 +5,13 @@
5
5
  * type-ahead, and Enter drive the choice rows.
6
6
  */
7
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;
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';
18
15
 
19
16
  const SUBHEADER_SELECTOR = 'li.jp-jai-controlMenu-subheader';
20
17
 
@@ -32,105 +29,84 @@ function modelControl(selection: string | null): Control {
32
29
  };
33
30
  }
34
31
 
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
- }
32
+ function heading(): HTMLElement {
33
+ return document.querySelector(SUBHEADER_SELECTOR) as HTMLElement;
34
+ }
57
35
 
58
- function press(key: string): void {
59
- act(() => {
60
- focused().dispatchEvent(
61
- new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })
62
- );
63
- });
64
- }
36
+ function focused(): HTMLElement {
37
+ return document.activeElement as HTMLElement;
38
+ }
65
39
 
40
+ describe('control menus', () => {
66
41
  describe('control dropdown', () => {
67
- function openMenu(
42
+ async function openMenu(
68
43
  control: Control,
69
44
  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
- });
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;
79
50
  }
80
51
 
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);
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
+ );
88
64
  });
89
65
 
90
- it('focuses the selected row and moves focus with the keyboard', () => {
91
- openMenu(modelControl('beta'));
66
+ it('focuses the selected row and moves focus with the keyboard', async () => {
67
+ const user = await openMenu(modelControl('beta'));
92
68
  // Rows: heading, "Default (Alpha)", "Alpha", "Beta"; "Beta" is selected.
93
- expect(focused().textContent).toBe('Beta');
94
- expect(focused().getAttribute('role')).toBe('menuitem');
69
+ expect(focused()).toBe(screen.getByRole('menuitem', { name: 'Beta' }));
95
70
  // 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');
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' }));
100
77
  // Type-ahead: "b" jumps to Beta.
101
- press('b');
102
- expect(focused().textContent).toBe('Beta');
78
+ await user.keyboard('b');
79
+ expect(focused()).toBe(screen.getByRole('menuitem', { name: 'Beta' }));
103
80
  });
104
81
 
105
- it('leaves focus in place when type-ahead matches only the heading', () => {
106
- openMenu(modelControl('beta'));
82
+ it('leaves focus in place when type-ahead matches only the heading', async () => {
83
+ const user = await openMenu(modelControl('beta'));
107
84
  // First key after open, so it cannot buffer onto a previous press: "m"
108
85
  // matches the heading ("Model") and no choice row, and the heading
109
86
  // never takes focus.
110
- press('m');
111
- expect(focused().textContent).toBe('Beta');
87
+ await user.keyboard('m');
88
+ expect(focused()).toBe(screen.getByRole('menuitem', { name: 'Beta' }));
112
89
  });
113
90
 
114
- it('falls back to the first choice row when the selection is stale', () => {
91
+ it('falls back to the first choice row when the selection is stale', async () => {
115
92
  // A selection id the persona no longer advertises leaves no row
116
93
  // 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');
94
+ await openMenu(modelControl('stale'));
95
+ expect(focused()).toBe(
96
+ screen.getByRole('menuitem', { name: 'Default (Alpha)' })
97
+ );
120
98
  });
121
99
 
122
- it('activates the focused row with Enter', () => {
100
+ it('activates the focused row with Enter', async () => {
123
101
  const onSelect = jest.fn();
124
- openMenu(modelControl(null), onSelect);
125
- press('ArrowDown');
126
- press('ArrowDown');
127
- press('Enter');
102
+ const user = await openMenu(modelControl(null), onSelect);
103
+ await user.keyboard('{ArrowDown}{ArrowDown}{Enter}');
128
104
  expect(onSelect).toHaveBeenCalledWith('beta');
129
105
  });
130
106
  });
131
107
 
132
108
  describe('overflow menu', () => {
133
- it('is labeled and arrows skip the section headings', () => {
109
+ it('is labeled and arrows skip the section headings', async () => {
134
110
  const first: Control = {
135
111
  id: 'a',
136
112
  kind: 'setting',
@@ -143,32 +119,39 @@ describe('control menus', () => {
143
119
  id: 'b',
144
120
  kind: 'setting',
145
121
  label: 'Bbb',
146
- current: null,
122
+ current: 'b1',
147
123
  selection: 'stale',
148
124
  options: [{ id: 'b1', name: 'B one', description: null }]
149
125
  };
126
+ const user = userEvent.setup();
150
127
  const anchor = document.createElement('button');
151
128
  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');
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
+ );
163
140
  const headings = Array.from(
164
141
  document.querySelectorAll(SUBHEADER_SELECTOR)
165
- ).map(h => h.textContent);
166
- expect(headings).toEqual(['Aaa', 'Bbb']);
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
+ }
167
148
  // "A one" is the only selected row; ArrowDown crosses the "Bbb" heading
168
149
  // to the next section's first choice row.
169
- expect(focused().textContent).toBe('A one');
170
- press('ArrowDown');
171
- expect(focused().textContent).toBe('Default');
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
+ );
172
155
  anchor.remove();
173
156
  });
174
157
  });
@@ -333,15 +333,17 @@ function defaultChoiceLabel(control: Control): string {
333
333
  * The uppercase group label used in control menus: it titles a control's own
334
334
  * dropdown and labels each control's section in the overflow menu. Rendered
335
335
  * with MUI's `ListSubheader`, which has no tabindex, so arrow-key focus skips
336
- * it and the menu stays keyboard-navigable.
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.
337
340
  */
338
- function ControlSubheader(props: { label: string; id?: string }): JSX.Element {
341
+ function ControlMenuSubheader(props: {
342
+ label: string;
343
+ id?: string;
344
+ }): JSX.Element {
339
345
  return (
340
- <ListSubheader
341
- id={props.id}
342
- disableSticky
343
- className={`${MENU_CLASS}-subheader`}
344
- >
346
+ <ListSubheader id={props.id} className={`${MENU_CLASS}-subheader`}>
345
347
  {props.label}
346
348
  </ListSubheader>
347
349
  );
@@ -349,14 +351,14 @@ function ControlSubheader(props: { label: string; id?: string }): JSX.Element {
349
351
 
350
352
  // MUI's MenuList skips initial focus for children whose type carries this
351
353
  // static; ListSubheader's own copy is hidden behind the wrapper.
352
- ControlSubheader.muiSkipListHighlight = true;
354
+ ControlMenuSubheader.muiSkipListHighlight = true;
353
355
 
354
356
  /**
355
357
  * A dropdown for a control, titled with the control's label. The first choice
356
358
  * row is "Default" (selection = null); the rest are the persona's advertised
357
359
  * options (selection = that option's id). Exported for tests.
358
360
  */
359
- export function ControlItem(props: {
361
+ export function ControlMenu(props: {
360
362
  control: Control;
361
363
  onSelect: (value: string | null) => void;
362
364
  }): JSX.Element {
@@ -388,7 +390,7 @@ export function ControlItem(props: {
388
390
  MenuListProps={{ 'aria-labelledby': headingId }}
389
391
  {...menuAnchorProps}
390
392
  >
391
- <ControlSubheader id={headingId} label={control.label} />
393
+ <ControlMenuSubheader id={headingId} label={control.label} />
392
394
  <ChoiceMenuItem
393
395
  primary={defaultChoiceLabel(control)}
394
396
  description={null}
@@ -420,7 +422,7 @@ export function ControlItem(props: {
420
422
  * menu (no nested dropdowns). Each control renders as a group label followed by
421
423
  * its Default row and choices. Exported for tests.
422
424
  */
423
- export function OverflowMenu(props: {
425
+ export function OverflowControlsMenu(props: {
424
426
  controls: Control[];
425
427
  anchor: HTMLElement | null;
426
428
  onClose: () => void;
@@ -436,7 +438,10 @@ export function OverflowMenu(props: {
436
438
  {...menuAnchorProps}
437
439
  >
438
440
  {controls.flatMap(control => [
439
- <ControlSubheader key={`${control.id}-label`} label={control.label} />,
441
+ <ControlMenuSubheader
442
+ key={`${control.id}-label`}
443
+ label={control.label}
444
+ />,
440
445
  <ChoiceMenuItem
441
446
  key={`${control.id}-default`}
442
447
  primary={defaultChoiceLabel(control)}
@@ -550,7 +555,7 @@ function ControlsRow(props: {
550
555
  aria-hidden="true"
551
556
  >
552
557
  {controls.map(control => (
553
- <ControlItem
558
+ <ControlMenu
554
559
  key={control.id}
555
560
  control={control}
556
561
  onSelect={v => onChange(control, v)}
@@ -559,7 +564,7 @@ function ControlsRow(props: {
559
564
  </div>
560
565
 
561
566
  {visible.map(control => (
562
- <ControlItem
567
+ <ControlMenu
563
568
  key={control.id}
564
569
  control={control}
565
570
  onSelect={v => onChange(control, v)}
@@ -578,7 +583,7 @@ function ControlsRow(props: {
578
583
  >
579
584
  <MoreHorizIcon fontSize="small" />
580
585
  </button>
581
- <OverflowMenu
586
+ <OverflowControlsMenu
582
587
  controls={overflow}
583
588
  anchor={overflowAnchor}
584
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;
@@ -281,7 +290,11 @@
281
290
  /* Control menu group label: an uppercase heading above a control's choices.
282
291
  It titles each control's own dropdown; in the overflow popover a hairline
283
292
  divider and top spacing separate the controls' sections. Scoped under the
284
- paper to win over MUI's ListSubheader defaults. */
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. */
285
298
  .jp-jai-controlMenu-paper .jp-jai-controlMenu-subheader {
286
299
  margin-top: 8px;
287
300
  padding: 8px 12px 2px;
@@ -294,6 +307,9 @@
294
307
  text-transform: uppercase;
295
308
  color: var(--jp-ui-font-color1);
296
309
  background-color: var(--jp-layout-color1);
310
+ white-space: nowrap;
311
+ overflow: hidden;
312
+ text-overflow: ellipsis;
297
313
  }
298
314
 
299
315
  /* The first (or only) label sits flush at the top, no divider or extra gap