react-x11 2.29.0 → 2.31.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.
@@ -14,6 +14,11 @@
14
14
  // via emit. Geometry and input arrive as channel messages (the host owns
15
15
  // layout and hit-testing — this is CPU offloading, not isolation), and what
16
16
  // goes out is pane-present, and pane-cursor for the cursor the host shows.
17
+ //
18
+ // Every other window the pane's tree makes — a menu, a dropdown's sheet, a
19
+ // tooltip, a dialog — is a `CocoaPaneSubwindow`: the same ring and the same
20
+ // presents, naming the window, and an NSWindow the host makes for it
21
+ // (src/cocoa/panehost.js), because nothing in a pane process runs AppKit.
17
22
  import { BackendContext2D } from '../backend/context2d.js';
18
23
 
19
24
  let nextPaneId = 1;
@@ -31,13 +36,15 @@ export class CocoaPaneWindow {
31
36
  this.id = this.windowId;
32
37
  this.windowNumber = this.windowId;
33
38
  this.scale = app.scale ?? 2;
34
- this.width = Math.max(
35
- 1,
36
- Math.round((attributes.width ?? 400) * this.scale),
37
- );
39
+ // Device pixels, as every window's attributes are (`windowAttributes`
40
+ // multiplies the props through once): the size the pane is born at,
41
+ // until the host's first pane-rect says what it is. Multiplied again
42
+ // here, a pane was born twice its size, and a dialog it opened in its
43
+ // first commit was centred over a window that was never there.
44
+ this.width = Math.max(1, Math.round(attributes.width ?? 400 * this.scale));
38
45
  this.height = Math.max(
39
46
  1,
40
- Math.round((attributes.height ?? 300) * this.scale),
47
+ Math.round(attributes.height ?? 300 * this.scale),
41
48
  );
42
49
  this._listeners = new Map();
43
50
  this._surface = null;
@@ -52,27 +59,51 @@ export class CocoaPaneWindow {
52
59
  this._seq = 0;
53
60
  this._presentedAt = -Infinity;
54
61
  this._reactX11Node = null;
62
+ // Where the pane's corner is on the screen, in device pixels, as the
63
+ // host last said (`setPaneSize`): what a popup the pane anchors to one
64
+ // of its nodes is placed against (`windowOrigin`, src/anchor.js). The
65
+ // pane has no window of its own to ask, so until the host says, a menu
66
+ // dropped from the screen's corner.
67
+ this.x = 0;
68
+ this.y = 0;
69
+ this._screenOrigin = { x: 0, y: 0 };
70
+ // what the app's window map keys this window by, and the id the host
71
+ // names it by in a pane-event (`CocoaApp.attachPaneChannel`)
72
+ this._key = this.windowId;
55
73
  app._registerWindow(this);
56
74
  }
57
75
 
58
76
  // --- the channel-facing half --------------------------------------------
59
77
 
60
- /** The host's layout answer: logical size plus the display scale. */
61
- setPaneSize(width, height, scale) {
78
+ /**
79
+ * The host's layout answer: logical size plus the display scale, and
80
+ * where the pane is on the screen — `screen`, device pixels, absent from
81
+ * a host that does not say. A move alone is a `resize` that `moved`, the
82
+ * event a window's own move is, so what is anchored here follows it.
83
+ */
84
+ setPaneSize(width, height, scale, screen) {
62
85
  if (this.destroyed) return;
63
86
  if (scale) this.scale = scale;
64
87
  const w = Math.max(1, Math.round(width * this.scale));
65
88
  const h = Math.max(1, Math.round(height * this.scale));
66
- if (w === this.width && h === this.height) return;
89
+ const moved =
90
+ screen != null && (screen.x !== this.x || screen.y !== this.y);
91
+ const resized = w !== this.width || h !== this.height;
92
+ if (!moved && !resized) return;
93
+ if (moved) {
94
+ this.x = screen.x;
95
+ this.y = screen.y;
96
+ this._screenOrigin = { x: screen.x, y: screen.y };
97
+ }
67
98
  this.width = w;
68
99
  this.height = h;
69
100
  this.emit('resize', {
70
101
  width: w,
71
102
  height: h,
72
- x: 0,
73
- y: 0,
74
- moved: false,
75
- resized: true,
103
+ x: this.x,
104
+ y: this.y,
105
+ moved,
106
+ resized,
76
107
  });
77
108
  }
78
109
 
@@ -103,7 +134,13 @@ export class CocoaPaneWindow {
103
134
  */
104
135
  setCursor(name) {
105
136
  if (this.destroyed) return;
106
- this.app._paneSend?.({ type: 'pane-cursor', cursor: name ?? null });
137
+ this._post({ type: 'pane-cursor', cursor: name ?? null });
138
+ }
139
+
140
+ /** A message about this window, to the host. The pane's own names none:
141
+ * the host knows it as the pane (`CocoaPaneSubwindow` names itself). */
142
+ _post(msg) {
143
+ this.app._paneSend?.(msg);
107
144
  }
108
145
 
109
146
  requestAnimationFrame(cb) {
@@ -264,7 +301,7 @@ export class CocoaPaneWindow {
264
301
  const shown = this._ring[this._drawIndex];
265
302
  this._native.surfaceUnlock(shown.handle);
266
303
  this._flushDamage = null;
267
- this.app._paneSend?.({
304
+ this._post({
268
305
  type: 'pane-present',
269
306
  seq: ++this._seq,
270
307
  id: shown.iosurfaceId,
@@ -304,3 +341,156 @@ export class CocoaPaneWindow {
304
341
  this._releaseRing();
305
342
  }
306
343
  }
344
+
345
+ /**
346
+ * Any other window a pane's tree makes: a `<popup>` — a menu, a dropdown's
347
+ * sheet, a tooltip, the edit menu — or a managed one, a dialog (#824).
348
+ *
349
+ * A pane process runs no AppKit (`CocoaApp.start`): no NSApplication, no
350
+ * event pump. An NSWindow made here exists and is never shown, so a
351
+ * `<Select>` in a pane took the press, opened, and showed nothing. This
352
+ * window paints into a shared ring exactly as the pane does, and the host
353
+ * makes the NSWindow and shows the ring in it (`CocoaPaneHost`,
354
+ * src/cocoa/panehost.js) — the split the pane already has: the pane draws,
355
+ * the host owns glass and input.
356
+ *
357
+ * Its place is the pane's to decide and the host's to take as given: `x`
358
+ * and `y` are the screen position the pane's own anchoring worked out,
359
+ * against the origin the host sends with every pane-rect. What goes out is
360
+ * the window's life as `pane-window` messages and its frames as
361
+ * `pane-present`s naming it; what comes back is the input on the host's
362
+ * window, as `pane-event`s naming it, and for a managed window the moves
363
+ * and the resizes the user made and the close button.
364
+ */
365
+ export class CocoaPaneSubwindow extends CocoaPaneWindow {
366
+ constructor(app, attributes = {}) {
367
+ super(app, attributes);
368
+ this.attributes = attributes;
369
+ this._popup = attributes.overrideRedirect === true;
370
+ this.mapped = false;
371
+ // device pixels, as every window's attributes are — unlike the pane's
372
+ // own, whose size is the host's to say
373
+ const size = this.snapSize(attributes.width ?? 1, attributes.height ?? 1);
374
+ this.width = size.width;
375
+ this.height = size.height;
376
+ this.x = Math.round(attributes.x ?? 0);
377
+ this.y = Math.round(attributes.y ?? 0);
378
+ this._screenOrigin = { x: this.x, y: this.y };
379
+ this._send({
380
+ op: 'create',
381
+ x: this.x,
382
+ y: this.y,
383
+ width: this.width,
384
+ height: this.height,
385
+ popup: this._popup,
386
+ // an ARGB visual is a transparent window here, as in `CocoaWindow`
387
+ transparent:
388
+ attributes.visual !== undefined || Boolean(attributes.transparent),
389
+ grabKeyboard: attributes.grabKeyboard === true,
390
+ dragPreview: Boolean(attributes.dragPreview),
391
+ title: attributes.title ?? '',
392
+ decorations: attributes.decorations,
393
+ resizable: attributes.resizable,
394
+ sizeHints: attributes.sizeHints,
395
+ });
396
+ }
397
+
398
+ /** Named, and held until the host is listening (`CocoaApp._postWindow`). */
399
+ _post(msg) {
400
+ this.app._postWindow({ ...msg, window: this.windowId });
401
+ }
402
+
403
+ _send(msg) {
404
+ if (this.destroyed) return;
405
+ this._post({ type: 'pane-window', ...msg });
406
+ }
407
+
408
+ /** `CocoaWindow`'s rule, so the size this window records is the size the
409
+ * host's NSWindow will take: whole points, rounded up. */
410
+ snapSize(width, height) {
411
+ const s = this.scale;
412
+ const up = (v) =>
413
+ Math.max(1, Math.ceil(Math.max(1, Math.round(v)) / s) * s);
414
+ return { width: up(width), height: up(height) };
415
+ }
416
+
417
+ map() {
418
+ this.mapped = true;
419
+ this._send({ op: 'map' });
420
+ }
421
+
422
+ unmap() {
423
+ this.mapped = false;
424
+ this._send({ op: 'unmap' });
425
+ }
426
+
427
+ move(x, y) {
428
+ this.x = Math.round(x);
429
+ this.y = Math.round(y);
430
+ this._screenOrigin = { x: this.x, y: this.y };
431
+ this._send({ op: 'move', x: this.x, y: this.y });
432
+ }
433
+
434
+ resize(width, height) {
435
+ const size = this.snapSize(width, height);
436
+ this.width = size.width;
437
+ this.height = size.height;
438
+ this._send({ op: 'resize', width: this.width, height: this.height });
439
+ }
440
+
441
+ setTitle(title) {
442
+ this._send({ op: 'title', title: String(title ?? '') });
443
+ }
444
+
445
+ setSizeHints(hints = {}) {
446
+ this._send({ op: 'size-hints', hints: { ...hints } });
447
+ }
448
+
449
+ /** The grab is the host's: a press anywhere in its windows comes here,
450
+ * outside this window, which is the dismissal (`CocoaApp._grabTarget`). */
451
+ grabPointer(options, cb) {
452
+ this._send({ op: 'grab' });
453
+ cb?.(null, 0);
454
+ }
455
+
456
+ ungrabPointer() {
457
+ this._send({ op: 'ungrab' });
458
+ }
459
+
460
+ /**
461
+ * Where the host's window went for a reason of its own — the user moved a
462
+ * dialog, or resized it — which a `resize` here says as a window's own
463
+ * move or resize does. A size comes only from the user (`resized`): the
464
+ * pane's own resizes come back as the host's echo, and one taken as news
465
+ * would read as the user taking an `'auto'` window over.
466
+ */
467
+ _hostGeometry(ev) {
468
+ if (this.destroyed) return;
469
+ const moved = ev.x !== this.x || ev.y !== this.y;
470
+ const resized =
471
+ Boolean(ev.resized) &&
472
+ (ev.width !== this.width || ev.height !== this.height);
473
+ if (!moved && !resized) return;
474
+ this.x = ev.x;
475
+ this.y = ev.y;
476
+ this._screenOrigin = { x: ev.x, y: ev.y };
477
+ if (resized) {
478
+ this.width = ev.width;
479
+ this.height = ev.height;
480
+ }
481
+ this.emit('resize', {
482
+ width: this.width,
483
+ height: this.height,
484
+ x: this.x,
485
+ y: this.y,
486
+ moved,
487
+ resized,
488
+ });
489
+ }
490
+
491
+ destroy() {
492
+ if (this.destroyed) return;
493
+ this._send({ op: 'destroy' });
494
+ super.destroy();
495
+ }
496
+ }
@@ -591,8 +591,16 @@ export class CocoaPromotion {
591
591
  * node's reach, the parent's clip, if it has one, must hold the whole of
592
592
  * it, and the parent must not fade: its group is drawn on the bitmap or on
593
593
  * its own layer, and either way a layer of the node's would be outside it.
594
+ *
595
+ * With `cut`, the reach is an element's part (src/cocoa/sprites.js),
596
+ * whose layer can be cut to a rectangle: a clipping ancestor with square
597
+ * corners narrows `cut.clip` to its padding box — its border is drawn
598
+ * over what it holds anyway — instead of having to hold the part whole,
599
+ * and every test from there up is asked of what shows through the clip.
600
+ * `cut.bounds` comes back as that. A clip that leaves nothing showing is
601
+ * a refusal.
594
602
  */
595
- _clear(node, bounds = node._subtreeBounds()) {
603
+ _clear(node, bounds = node._subtreeBounds(), cut = null) {
596
604
  // the exact reach, not `paintBounds()`: that one carries the damage
597
605
  // model's pixel of slop, and a section laid out flush under a card
598
606
  // would read as reaching into it. An element's part is asked about at
@@ -600,6 +608,17 @@ export class CocoaPromotion {
600
608
  for (let n = node; !n.isWindow; n = n.parent) {
601
609
  const parent = n.parent;
602
610
  if (!parent) return false;
611
+ if (
612
+ cut &&
613
+ !parent.isWindow &&
614
+ parent.clipsChildren?.() &&
615
+ !(parent.style?.borderRadius > 0)
616
+ ) {
617
+ const inner = insetRect(parent.abs, borderReach(parent));
618
+ cut.clip = cut.clip ? intersectRects(cut.clip, inner) : inner;
619
+ bounds = cut.clip && intersectRects(bounds, cut.clip);
620
+ if (!bounds) return false;
621
+ }
603
622
  const order = parent.paintOrder();
604
623
  for (let j = order.indexOf(n) + 1; j < order.length; j++) {
605
624
  if (this._reaches(order[j], bounds)) return false;
@@ -636,6 +655,7 @@ export class CocoaPromotion {
636
655
  }
637
656
  }
638
657
  }
658
+ if (cut) cut.bounds = bounds;
639
659
  return true;
640
660
  }
641
661
 
@@ -25,6 +25,7 @@
25
25
  // element.
26
26
 
27
27
  import { RASTER_PAD, RasterState } from './presenter.js';
28
+ import { intersectRects } from '../nodes/rects.js';
28
29
  import { DEV } from '../nodes/util.js';
29
30
 
30
31
  // Past this many device pixels on a side a part is drawn by its element: a
@@ -109,6 +110,7 @@ function isSprite(sprite) {
109
110
  if (sprite == null || typeof sprite.key !== 'string') return false;
110
111
  if (!isRect(sprite.rect) || typeof sprite.paint !== 'function') return false;
111
112
  if (sprite.reach !== undefined && !isRect(sprite.reach)) return false;
113
+ if (sprite.clip !== undefined && !isRect(sprite.clip)) return false;
112
114
  if (sprite.opacity !== undefined && !finite(sprite.opacity)) return false;
113
115
  if (sprite.transform !== undefined && !isMatrix(sprite.transform)) {
114
116
  return false;
@@ -304,18 +306,30 @@ export class SpriteLayers {
304
306
  if (now.has(sprite.key)) continue;
305
307
  const raster = rasterRectOf(sprite);
306
308
  if (raster.width > MAX_SIDE || raster.height > MAX_SIDE) continue;
309
+ // what shows of everywhere it can be: inside its own clip, and the
310
+ // clips of its element's ancestors, which cut its layer (`_clear`)
311
+ const cut = { clip: sprite.clip ?? null, bounds: null };
307
312
  const extent = extentOf(sprite);
308
- if (!this.promotion._clear(host, extent)) continue;
313
+ const bounds = cut.clip ? intersectRects(extent, cut.clip) : extent;
314
+ if (!bounds || !this.promotion._clear(host, bounds, cut)) continue;
315
+ const shown = cut.bounds;
309
316
  let state = was?.get(sprite.key);
317
+ // a clip that comes or goes is a layer in a box or out of one: lifted
318
+ // again, rather than a running layer moved between parents
319
+ if (state && Boolean(state.clip) !== Boolean(cut.clip)) {
320
+ this._drop(state, root);
321
+ state = null;
322
+ }
310
323
  const lifting = !state;
311
- if (lifting) state = this._lift(host, sprite.key);
324
+ if (lifting) state = this._lift(host, sprite.key, cut.clip !== null);
312
325
  state.sprite = sprite;
326
+ state.clip = cut.clip;
313
327
  state.order = [...place, AFTER_CHILDREN, index];
314
- // the bitmap under where it can be repaints without it, from this
328
+ // the bitmap under where it shows repaints without it, from this
315
329
  // frame on; where it went since, it is the element's own claims
316
330
  // that move the hole
317
- if (lifting) this.promotion._claim(root, extent);
318
- state.extent = extent;
331
+ if (lifting) this.promotion._claim(root, shown);
332
+ state.extent = shown;
319
333
  now.set(sprite.key, state);
320
334
  }
321
335
  if (was) {
@@ -356,13 +370,25 @@ export class SpriteLayers {
356
370
  }
357
371
  }
358
372
 
359
- _lift(host, key) {
373
+ _lift(host, key, clipped) {
360
374
  const layer = this.native.createLayer();
361
- this.native.addSublayer(this.promotion.rootVisual.layer, layer);
375
+ // a part cut to a clip is in a box of the clip's size that masks to it,
376
+ // and the box is what stands among the layers above the bitmap
377
+ let box = null;
378
+ if (clipped) {
379
+ box = this.native.createLayer();
380
+ this.native.addSublayer(this.promotion.rootVisual.layer, box);
381
+ this.native.addSublayer(box, layer);
382
+ } else {
383
+ this.native.addSublayer(this.promotion.rootVisual.layer, layer);
384
+ }
362
385
  return {
363
386
  host,
364
387
  key,
365
388
  layer,
389
+ box,
390
+ boxProps: {},
391
+ clip: null, // device pixels, window coordinates
366
392
  raster: new RasterState(),
367
393
  painted: UNPAINTED,
368
394
  rect: null, // the raster's rect, device pixels, window coordinates
@@ -383,7 +409,7 @@ export class SpriteLayers {
383
409
  * forgotten, and the bitmap under where it could be repaints with the
384
410
  * element drawing it again. */
385
411
  _drop(state, root) {
386
- this.native.removeFromSuperlayer(state.layer);
412
+ this.native.removeFromSuperlayer(state.box ?? state.layer);
387
413
  state.raster.release(this.native);
388
414
  for (const run of state.runs.values()) this._ends().delete(run.caId);
389
415
  state.runs.clear();
@@ -415,14 +441,31 @@ export class SpriteLayers {
415
441
  state.rect = rect;
416
442
  const origin = originOf(sprite);
417
443
  const m = sprite.transform ?? IDENTITY;
444
+ // in its box, where it is cut to a clip: placed from the clip's corner,
445
+ // and the box at the part's place among the layers
446
+ const clip = state.clip;
447
+ if (state.box) {
448
+ const frame = [clip.x / s, clip.y / s, clip.width / s, clip.height / s];
449
+ const was = state.boxProps;
450
+ if (!sameMatrix(was.frame, frame) || was.zPosition !== z) {
451
+ this.native.setLayerProps(state.box, {
452
+ frame,
453
+ masksToBounds: true,
454
+ zPosition: z,
455
+ });
456
+ state.boxProps = { frame, zPosition: z };
457
+ }
458
+ }
459
+ const ox = state.box ? clip.x : 0;
460
+ const oy = state.box ? clip.y : 0;
418
461
  const next = {
419
462
  bounds: [0, 0, rect.width / s, rect.height / s],
420
463
  anchorPoint: [
421
464
  (origin.x - rect.x) / rect.width,
422
465
  (origin.y - rect.y) / rect.height,
423
466
  ],
424
- position: [origin.x / s, origin.y / s],
425
- zPosition: z,
467
+ position: [(origin.x - ox) / s, (origin.y - oy) / s],
468
+ zPosition: state.box ? 0 : z,
426
469
  opacity: Math.min(1, Math.max(0, sprite.opacity ?? 1)),
427
470
  matrix: m,
428
471
  };
@@ -552,7 +595,7 @@ export class SpriteLayers {
552
595
  destroy() {
553
596
  for (const lifted of this.hosts.values()) {
554
597
  for (const state of lifted.values()) {
555
- this.native.removeFromSuperlayer(state.layer);
598
+ this.native.removeFromSuperlayer(state.box ?? state.layer);
556
599
  state.raster.release(this.native);
557
600
  for (const run of state.runs.values()) this._ends().delete(run.caId);
558
601
  }
@@ -143,6 +143,33 @@ const TRIGGER_PAD_LEFT = 10;
143
143
  // (`nodes/scrollbars.js`, SCROLLBAR_WIDTH), so a menu that scrolls reserves the room
144
144
  const SCROLLBAR_WIDTH = 6;
145
145
 
146
+ // The family a caption's style names, for the platform's menu: the first of
147
+ // a CSS list, unquoted, and nothing for a generic one, which is the menu
148
+ // font's own to answer.
149
+ const GENERIC_FAMILIES = new Set([
150
+ 'sans-serif',
151
+ 'serif',
152
+ 'monospace',
153
+ 'cursive',
154
+ 'fantasy',
155
+ 'system-ui',
156
+ 'ui-sans-serif',
157
+ 'ui-serif',
158
+ 'ui-monospace',
159
+ '-apple-system',
160
+ 'blinkmacsystemfont',
161
+ ]);
162
+ function menuFamily(family) {
163
+ if (typeof family !== 'string') return undefined;
164
+ const first = family
165
+ .split(',')[0]
166
+ .trim()
167
+ .replace(/^["']|["']$/g, '');
168
+ return first && !GENERIC_FAMILIES.has(first.toLowerCase())
169
+ ? first
170
+ : undefined;
171
+ }
172
+
146
173
  function normalizeOption(option) {
147
174
  return typeof option === 'object' && option !== null
148
175
  ? option
@@ -291,6 +318,17 @@ function Option({
291
318
  * caption's `fontSize` is read back: the chevron is as tall as the capitals
292
319
  * beside it. Any slot also chooses the drawn trigger over the platform's
293
320
  * popup bezel, whose pixels no style reaches.
321
+ *
322
+ * `nativeMenu` is the menu's half of `native`: where the backend drops a
323
+ * control's menu as the platform's own (`app.nativePopUpMenus`, macOS), the
324
+ * list is that menu — the chosen row over the trigger, the platform's
325
+ * tracking, type-select and VoiceOver — rather than a `<popup>` drawn to
326
+ * look like it. It follows the trigger by default, so a select under the
327
+ * native bezel gets the native menu and a drawn one keeps the drawn menu;
328
+ * `true` asks for the platform's menu under a drawn trigger too, which is
329
+ * what a browser does with a `<select>` a page styled, and `false` keeps the
330
+ * drawn one. A drawn trigger's menu is set at the caption's size, in the
331
+ * family its style names when the system has it.
294
332
  */
295
333
  export function Select({
296
334
  value,
@@ -299,6 +337,7 @@ export function Select({
299
337
  name,
300
338
  placeholder = 'Select…',
301
339
  native,
340
+ nativeMenu,
302
341
  style,
303
342
  labelStyle,
304
343
  chevronStyle,
@@ -308,11 +347,20 @@ export function Select({
308
347
  const app = useAppOrNull();
309
348
  const styled = Boolean(labelStyle || chevronStyle);
310
349
  const nativeControls = useNativeControls(styled ? false : native);
350
+ // the platform's own menu, where there is one and this select wants it
351
+ const platformMenu =
352
+ (nativeMenu ?? nativeControls) && Boolean(app?.nativePopUpMenus);
311
353
  // The size the caption is set in, where its slot names one: the chevron is
312
354
  // measured against those capitals, and an `<Icon>`'s size does not inherit.
313
355
  const ownSize = flattenStyle(labelStyle).fontSize;
314
356
  const captionSize = typeof ownSize === 'number' ? ownSize : theme.fontSize;
315
357
  const [open, setOpen] = useState(false);
358
+ // The platform's menu, while it is down: its cancel function, and whether
359
+ // it is open, for the trigger's open look and `aria-expanded`.
360
+ const platformRef = useRef(null);
361
+ const [platformOpen, setPlatformOpen] = useState(false);
362
+ // a menu is down, whichever one: the trigger's open look follows it
363
+ const expanded = open || platformOpen;
316
364
  const [anchor, setAnchor] = useState(null);
317
365
  const [focused, setFocused] = useState(false);
318
366
  const [activeIndex, setActiveIndex] = useState(-1);
@@ -387,8 +435,48 @@ export function Select({
387
435
  };
388
436
  };
389
437
 
390
- const close = () => setOpen(false);
438
+ const close = () => {
439
+ setOpen(false);
440
+ platformRef.current?.();
441
+ };
442
+ // The platform's menu, dropped from the trigger: the same options, the
443
+ // current one placed over it. Its answer is a pick like a row's.
444
+ const openPlatformMenu = () => {
445
+ const node = triggerRef.current;
446
+ const wnd = node?.root?.window;
447
+ if (!node || !wnd || platformRef.current) return;
448
+ const chosen = normalized.findIndex((o) => o.value === value);
449
+ const family = menuFamily(flattenStyle(labelStyle).fontFamily);
450
+ setPlatformOpen(true);
451
+ platformRef.current = app.popUpMenu(
452
+ wnd,
453
+ {
454
+ items: normalized.map((o, i) => ({
455
+ id: i + 1,
456
+ title: String(o.label),
457
+ })),
458
+ frame: [node.abs.x, node.abs.y, node.abs.width, node.abs.height],
459
+ ...(chosen >= 0 && { selected: chosen + 1 }),
460
+ // under the bezel the menu is the bezel's, at the menu font's size
461
+ ...(!nativeControls && { fontSize: captionSize }),
462
+ ...(!nativeControls && family && { fontFamily: family }),
463
+ // the theme's, as the bezel's appearance is
464
+ appearance: theme.scheme === 'dark' ? 'dark' : 'light',
465
+ rtl: node.direction === 'rtl',
466
+ },
467
+ (id) => {
468
+ platformRef.current = null;
469
+ setPlatformOpen(false);
470
+ const option = id != null ? normalized[id - 1] : null;
471
+ if (option && option.value !== value) emit(option.value);
472
+ },
473
+ );
474
+ };
391
475
  const openMenu = () => {
476
+ if (platformMenu) {
477
+ openPlatformMenu();
478
+ return;
479
+ }
392
480
  const node = triggerRef.current;
393
481
  if (!node) return;
394
482
  const rect = measureAnchor(menuAnchorOptions());
@@ -398,7 +486,9 @@ export function Select({
398
486
  setActiveIndex(selected >= 0 ? selected : 0);
399
487
  setOpen(true);
400
488
  };
401
- const toggle = () => (open ? close() : openMenu());
489
+ const toggle = () => (expanded ? close() : openMenu());
490
+ // a menu still down when the select goes is taken with it
491
+ useEffect(() => () => platformRef.current?.(), []);
402
492
 
403
493
  // keeps the menu under the trigger for as long as it is open: a scrolled
404
494
  // ancestor, the trigger's own layout moving it (a neighbouring field
@@ -491,7 +581,7 @@ export function Select({
491
581
  {
492
582
  theme,
493
583
  role: 'combobox',
494
- 'aria-expanded': open,
584
+ 'aria-expanded': expanded,
495
585
  'aria-haspopup': 'listbox',
496
586
  ref: triggerRef,
497
587
  focusable: true,
@@ -546,7 +636,8 @@ export function Select({
546
636
  paddingRight: 10,
547
637
  borderWidth: theme.borderWidth,
548
638
  borderRadius: theme.radius,
549
- borderColor: focused || open ? theme.borderFocus : theme.border,
639
+ borderColor:
640
+ focused || expanded ? theme.borderFocus : theme.border,
550
641
  backgroundColor: theme.surface,
551
642
  },
552
643
  // Hover and press belong to the trigger while it is *shut*: they say
@@ -560,7 +651,7 @@ export function Select({
560
651
  // not overwrite. The only way for open to win is to not be competing.
561
652
  // (In native mode the wash overlay below answers the press instead.)
562
653
  !nativeControls &&
563
- !open && {
654
+ !expanded && {
564
655
  ':hover': { backgroundColor: theme.surfaceHover },
565
656
  ':active': { backgroundColor: theme.surfaceActive },
566
657
  },
@@ -576,7 +667,7 @@ export function Select({
576
667
  nativeControls &&
577
668
  h(Bezel, {
578
669
  kind: 'popup',
579
- pressed: open,
670
+ pressed: expanded,
580
671
  enabled: true,
581
672
  style: ABS_FILL,
582
673
  }),
@@ -605,7 +696,7 @@ export function Select({
605
696
  style: [
606
697
  ABS_FILL,
607
698
  { borderRadius: 6 },
608
- !open && { ':active': { backgroundColor: pressWash(theme) } },
699
+ !expanded && { ':active': { backgroundColor: pressWash(theme) } },
609
700
  ],
610
701
  })
611
702
  : h(Icon, {