react-x11 2.9.0 → 2.9.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/package.json +4 -2
- package/src/cocoa/app.js +18 -0
- package/src/cocoa/calendar.js +186 -0
- package/src/cocoa/native.js +20 -3
- package/src/cocoa/permissions.js +14 -5
- package/src/components/Button.js +68 -57
- package/src/components/Select.js +20 -3
- package/src/components/native.js +41 -0
- package/src/desktopcalendar.js +1415 -0
- package/src/desktopcalendarhooks.js +259 -0
- package/src/index.d.ts +1 -0
- package/src/index.js +9 -0
- package/src/permissionhooks.js +9 -6
- package/src/permissions.js +12 -2
- package/src/types/desktopcalendar.d.ts +207 -0
- package/src/types/permissions.d.ts +12 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-x11",
|
|
3
|
-
"version": "2.9.
|
|
3
|
+
"version": "2.9.2",
|
|
4
4
|
"description": "react renderer with X11 as a target",
|
|
5
5
|
"main": "./src/index.js",
|
|
6
6
|
"files": [
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
"examples:tooltips": "tsx examples/tooltips.jsx",
|
|
33
33
|
"examples:chat": "tsx examples/chat.jsx",
|
|
34
34
|
"examples:clipboard": "tsx examples/clipboard.jsx",
|
|
35
|
+
"examples:calendar": "tsx examples/calendar.jsx",
|
|
35
36
|
"labs:urischeme": "tsx examples/labs/urischeme.jsx",
|
|
36
37
|
"filedialog:probe": "node scripts/filedialog-probe.mjs",
|
|
37
38
|
"a11y:probe": "node scripts/a11y-probe.mjs",
|
|
@@ -92,13 +93,14 @@
|
|
|
92
93
|
"node": ">=20.19"
|
|
93
94
|
},
|
|
94
95
|
"dependencies": {
|
|
96
|
+
"ical.js": "^2.2.1",
|
|
95
97
|
"linebreak": "^1.1.0",
|
|
96
98
|
"ntk": "^8.7.0",
|
|
97
99
|
"react-reconciler": "^0.33.0",
|
|
98
100
|
"yoga-layout": "^3.2.1"
|
|
99
101
|
},
|
|
100
102
|
"optionalDependencies": {
|
|
101
|
-
"@windowkit/appkit": "^0.
|
|
103
|
+
"@windowkit/appkit": "^0.8.0",
|
|
102
104
|
"dbus-native": "^0.15.1",
|
|
103
105
|
"x11-dri": "^0.7.0"
|
|
104
106
|
},
|
package/src/cocoa/app.js
CHANGED
|
@@ -25,6 +25,7 @@ import { CocoaGLArea, cocoaGLConfig, resolveCocoaGLRuntime } from './glarea.js';
|
|
|
25
25
|
import { CocoaDockMenu } from './dock.js';
|
|
26
26
|
import { CocoaGlobalMenuExport } from './globalmenu.js';
|
|
27
27
|
import { CocoaStatusItem } from './statusitem.js';
|
|
28
|
+
import { CocoaCalendars } from './calendar.js';
|
|
28
29
|
import { CocoaNotifications } from './notifications.js';
|
|
29
30
|
import { CocoaPaneHost } from './panehost.js';
|
|
30
31
|
import { CocoaPermissions } from './permissions.js';
|
|
@@ -177,6 +178,17 @@ export class CocoaApp {
|
|
|
177
178
|
? new CocoaNotifications(this)
|
|
178
179
|
: null;
|
|
179
180
|
|
|
181
|
+
// EventKit (src/cocoa/calendar.js). Present exactly when the bridge has
|
|
182
|
+
// it (>= 0.8), and its presence is the top rung of
|
|
183
|
+
// src/desktopcalendar.js's ladder for this app — an older bridge leaves
|
|
184
|
+
// the ladder to find `osascript` instead, which answers the same
|
|
185
|
+
// questions out of the same framework.
|
|
186
|
+
this.calendars =
|
|
187
|
+
typeof native.calendars === 'function' &&
|
|
188
|
+
typeof native.eventsBetween === 'function'
|
|
189
|
+
? new CocoaCalendars(this)
|
|
190
|
+
: null;
|
|
191
|
+
|
|
180
192
|
// The GL policy, glbackend.js's shape. No GLX exists here, so the
|
|
181
193
|
// default is 'auto' (the direct backend where the runtime loads);
|
|
182
194
|
// useSupports('shaders') stays false until the first <glarea> resolves
|
|
@@ -767,6 +779,12 @@ export class CocoaApp {
|
|
|
767
779
|
case 'notification-dismissed':
|
|
768
780
|
this.notifications?.route(ev);
|
|
769
781
|
return this._afterInput();
|
|
782
|
+
case 'calendar-store-changed':
|
|
783
|
+
// EventKit's own notification, which names nothing that changed. A
|
|
784
|
+
// watcher's answer is to query again, and that query's result is a
|
|
785
|
+
// render, so the frame goes out the way an input's does.
|
|
786
|
+
this.calendars?.route(ev);
|
|
787
|
+
return this._afterInput();
|
|
770
788
|
case 'animation-end':
|
|
771
789
|
// the presenter that added the animation registered for its id; an
|
|
772
790
|
// id nobody knows is an animation already forgotten (cancelled, or
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// The user's calendars on the cocoa backend — EventKit (`EKEventStore`)
|
|
2
|
+
// through @windowkit/appkit (>= 0.8), the top rung of
|
|
3
|
+
// src/desktopcalendar.js's ladder. `CocoaApp.calendars` is this object and
|
|
4
|
+
// its *presence* is the capability, the rule `filePanels`, `permissions` and
|
|
5
|
+
// `notifications` follow, so the ladder never names a backend.
|
|
6
|
+
//
|
|
7
|
+
// EventKit is the macOS counterpart of Evolution Data Server plus GNOME
|
|
8
|
+
// Online Accounts in one framework: every account the user added in System
|
|
9
|
+
// Settings > Internet Accounts — iCloud, Google, Exchange, CalDAV, a
|
|
10
|
+
// subscribed feed — is served by one store, the desktop did the OAuth, and
|
|
11
|
+
// the app never sees a credential.
|
|
12
|
+
//
|
|
13
|
+
// Three things about the framework shape the code here:
|
|
14
|
+
//
|
|
15
|
+
// - **It expands recurrences itself.** `predicateForEventsWithStartDate…`
|
|
16
|
+
// answers occurrences, so there is no `ical.js` on this rung and no
|
|
17
|
+
// recurrence arithmetic to get wrong. The EDS rung pays for that
|
|
18
|
+
// client-side; this one does not.
|
|
19
|
+
// - **The predicate spans at most four years** (the framework's own limit),
|
|
20
|
+
// so a longer range is asked for in chunks and the pieces are merged —
|
|
21
|
+
// `chunkSpan` in ../desktopcalendar.js, shared with the `osascript` rung
|
|
22
|
+
// because it is the same framework underneath.
|
|
23
|
+
// - **An all-day event ends at the last second of its last day.** Every
|
|
24
|
+
// other rung, and `byDay`, read `end` as exclusive, so it is normalised
|
|
25
|
+
// here — at the edge, once, rather than in every consumer.
|
|
26
|
+
//
|
|
27
|
+
// A change to anything in the store arrives as one `calendar-store-changed`
|
|
28
|
+
// backend event that names nothing, so `watch` reports `kind: 'changed'`
|
|
29
|
+
// with a null calendar and no count. Re-query; do not try to patch.
|
|
30
|
+
|
|
31
|
+
import {
|
|
32
|
+
calendarBackendName,
|
|
33
|
+
chunkSpan,
|
|
34
|
+
hexFromComponents,
|
|
35
|
+
sortEvents,
|
|
36
|
+
withExclusiveEnd,
|
|
37
|
+
} from '../desktopcalendar.js';
|
|
38
|
+
|
|
39
|
+
/** `EKCalendar` as the shape every rung answers with. */
|
|
40
|
+
export function calendarInfo(cal) {
|
|
41
|
+
return {
|
|
42
|
+
uid: String(cal.id),
|
|
43
|
+
name: cal.title ?? '',
|
|
44
|
+
// EventKit has no "unchecked in the sidebar" state to report — a
|
|
45
|
+
// calendar hidden in Calendar.app is still in the store — so every
|
|
46
|
+
// calendar here is enabled, which is what `enabled` means to a caller
|
|
47
|
+
// filtering the list.
|
|
48
|
+
enabled: true,
|
|
49
|
+
color: hexFromComponents(cal.color),
|
|
50
|
+
backend: calendarBackendName(cal.type),
|
|
51
|
+
readOnly: cal.allowsModifications === false || cal.immutable === true,
|
|
52
|
+
// The account the calendar came from, which is what the EDS rung's
|
|
53
|
+
// `account` is too: an `EKSource` is the Internet Accounts entry.
|
|
54
|
+
account: cal.source?.title ?? undefined,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** One occurrence from the bridge as the shape every rung answers with. */
|
|
59
|
+
export function desktopEvent(ev, byId) {
|
|
60
|
+
const meta = byId.get(String(ev.calendar));
|
|
61
|
+
const start = new Date(ev.start);
|
|
62
|
+
return {
|
|
63
|
+
uid: String(ev.id ?? ev.itemId ?? `${ev.calendar}:${ev.start}`),
|
|
64
|
+
summary: ev.title ?? '',
|
|
65
|
+
location: ev.location ?? undefined,
|
|
66
|
+
description: ev.notes ?? undefined,
|
|
67
|
+
start,
|
|
68
|
+
end: withExclusiveEnd(new Date(ev.end), Boolean(ev.allDay)),
|
|
69
|
+
allDay: Boolean(ev.allDay),
|
|
70
|
+
recurring: Boolean(ev.recurring),
|
|
71
|
+
calendar: meta
|
|
72
|
+
? { uid: meta.uid, name: meta.name, color: meta.color }
|
|
73
|
+
: { uid: String(ev.calendar), name: '' },
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export class CocoaCalendars {
|
|
78
|
+
constructor(app) {
|
|
79
|
+
this.app = app;
|
|
80
|
+
this._native = app._native;
|
|
81
|
+
this.backend = 'cocoa';
|
|
82
|
+
this._watchers = new Set();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The TCC grant, read without prompting. `'write-only'` is macOS 14's
|
|
87
|
+
* partial grant, which is a refusal to a reader — but its own word, so a
|
|
88
|
+
* caller that only saves events can tell it from a denial.
|
|
89
|
+
*/
|
|
90
|
+
async access() {
|
|
91
|
+
return this.app.permissions
|
|
92
|
+
? this.app.permissions.status('calendars')
|
|
93
|
+
: 'unknown';
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The system prompt, once per app; the status after the user answered. */
|
|
97
|
+
async requestAccess() {
|
|
98
|
+
return this.app.permissions
|
|
99
|
+
? this.app.permissions.request('calendars')
|
|
100
|
+
: 'unknown';
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
listCalendars() {
|
|
104
|
+
return new Promise((resolve, reject) => {
|
|
105
|
+
this._native.calendars((err, list) =>
|
|
106
|
+
err ? reject(err) : resolve((list ?? []).map(calendarInfo)),
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
_events(range) {
|
|
112
|
+
return new Promise((resolve, reject) => {
|
|
113
|
+
this._native.eventsBetween(range, (err, events) =>
|
|
114
|
+
err ? reject(err) : resolve(events ?? []),
|
|
115
|
+
);
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The occurrences in a range, already expanded by the framework.
|
|
121
|
+
*
|
|
122
|
+
* `errors` is always empty here: there is one store, and a failure in it
|
|
123
|
+
* is a failure of the whole read rather than of one account. The EDS rung
|
|
124
|
+
* is the one where a single unreachable CalDAV server must not blank the
|
|
125
|
+
* month.
|
|
126
|
+
*/
|
|
127
|
+
async eventsBetween(from, to, options = {}) {
|
|
128
|
+
const metas = options.calendars ?? (await this.listCalendars());
|
|
129
|
+
// "These calendars, of which there are none" is not "every calendar",
|
|
130
|
+
// which is what an empty filter means to the predicate underneath. A
|
|
131
|
+
// caller whose filter matched nothing must get nothing.
|
|
132
|
+
if (options.calendars && metas.length === 0) {
|
|
133
|
+
return { events: [], errors: [] };
|
|
134
|
+
}
|
|
135
|
+
const byId = new Map(metas.map((meta) => [meta.uid, meta]));
|
|
136
|
+
const ids = options.calendars ? metas.map((meta) => meta.uid) : undefined;
|
|
137
|
+
|
|
138
|
+
const events = [];
|
|
139
|
+
const seen = new Set();
|
|
140
|
+
for (const [start, end] of chunkSpan(from, to)) {
|
|
141
|
+
const raw = await this._events({ start, end, calendars: ids });
|
|
142
|
+
for (const one of raw) {
|
|
143
|
+
// An occurrence that straddles a chunk boundary is reported by both
|
|
144
|
+
// predicates; the caller must not see it twice.
|
|
145
|
+
const key = `${one.id} ${one.start}`;
|
|
146
|
+
if (seen.has(key)) continue;
|
|
147
|
+
seen.add(key);
|
|
148
|
+
events.push(desktopEvent(one, byId));
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return { events: sortEvents(events), errors: [] };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* `EKEventStoreChangedNotification`, as the ladder's change signal.
|
|
156
|
+
*
|
|
157
|
+
* The notification names neither the calendar nor what happened — a
|
|
158
|
+
* detail EventKit does not have — so the change carries a null calendar
|
|
159
|
+
* and no count, and re-querying is the only correct answer. Every watcher
|
|
160
|
+
* hears every change, whatever range it asked for, because a change
|
|
161
|
+
* outside a range can still move what is inside it (a recurrence master
|
|
162
|
+
* edited months away).
|
|
163
|
+
*/
|
|
164
|
+
async watch(from, to, onChange) {
|
|
165
|
+
const entry = { onChange };
|
|
166
|
+
this._watchers.add(entry);
|
|
167
|
+
return async () => {
|
|
168
|
+
this._watchers.delete(entry);
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** `calendar-store-changed`, routed from the app's backend callback. */
|
|
173
|
+
route() {
|
|
174
|
+
for (const entry of [...this._watchers]) {
|
|
175
|
+
try {
|
|
176
|
+
entry.onChange({ calendar: null, kind: 'changed', count: null });
|
|
177
|
+
} catch {
|
|
178
|
+
// a watcher that throws is not the other watchers' problem
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Nothing to release: the store belongs to the process, and a handle's
|
|
184
|
+
* own watchers are dropped by the stop function `watch` returned. */
|
|
185
|
+
async close() {}
|
|
186
|
+
}
|
package/src/cocoa/native.js
CHANGED
|
@@ -11,10 +11,27 @@
|
|
|
11
11
|
// is CommonJS + a .node binary, so `createRequire` is the honest loader.
|
|
12
12
|
import { createRequire } from 'node:module';
|
|
13
13
|
|
|
14
|
-
const require = createRequire(import.meta.url);
|
|
15
|
-
|
|
16
14
|
const PACKAGE = '@windowkit/appkit';
|
|
17
15
|
|
|
16
|
+
// The loader is made on first use, and from the executable when there is
|
|
17
|
+
// no module URL to make it from. A bundle built for Node's single-executable
|
|
18
|
+
// format (docs/packaging.md, tier 3) is CommonJS, and esbuild leaves
|
|
19
|
+
// `import.meta` an empty object in that output — so a module-scope
|
|
20
|
+
// `createRequire(import.meta.url)` threw ERR_INVALID_ARG_VALUE the moment
|
|
21
|
+
// the backend loaded, in every cocoa app shipped as a SEA, before this
|
|
22
|
+
// could say what it was trying to load. `createRequire(process.execPath)`
|
|
23
|
+
// is the loader Node's SEA docs prescribe (the embedded main's `__filename`
|
|
24
|
+
// *is* the executable), and it answers both specs below: an absolute
|
|
25
|
+
// REACT_X11_CALAYERS_PATH resolves from anywhere, and the bare package name
|
|
26
|
+
// resolves through a `node_modules` beside the binary. Made lazily, a
|
|
27
|
+
// loader that cannot be made at all is reported by the error at the bottom
|
|
28
|
+
// rather than by a throw at import.
|
|
29
|
+
let require = null;
|
|
30
|
+
function load(spec) {
|
|
31
|
+
require ??= createRequire(import.meta.url ?? process.execPath);
|
|
32
|
+
return require(spec);
|
|
33
|
+
}
|
|
34
|
+
|
|
18
35
|
let cached = null;
|
|
19
36
|
|
|
20
37
|
export function loadNative() {
|
|
@@ -30,7 +47,7 @@ export function loadNative() {
|
|
|
30
47
|
const path = process.env.REACT_X11_CALAYERS_PATH;
|
|
31
48
|
for (const spec of [path, PACKAGE].filter(Boolean)) {
|
|
32
49
|
try {
|
|
33
|
-
const mod =
|
|
50
|
+
const mod = load(spec);
|
|
34
51
|
// the package's index.js exports the raw addon as `native`; a direct
|
|
35
52
|
// path to a built checkout may be the addon itself
|
|
36
53
|
cached = mod.native ?? mod;
|
package/src/cocoa/permissions.js
CHANGED
|
@@ -13,23 +13,32 @@
|
|
|
13
13
|
// bundled app must carry the usage-description keys
|
|
14
14
|
// (`NSCameraUsageDescription` and friends) or a request never prompts.
|
|
15
15
|
|
|
16
|
-
/** Apple's
|
|
16
|
+
/** Apple's words as the ladder's. `writeOnly` is macOS 14's partial grant
|
|
17
|
+
* for EventKit — a grant to a writer, a refusal to a reader — and stays its
|
|
18
|
+
* own word for exactly that reason. */
|
|
17
19
|
const STATUS = Object.freeze({
|
|
18
20
|
authorized: 'granted',
|
|
19
21
|
denied: 'denied',
|
|
20
22
|
restricted: 'restricted',
|
|
21
23
|
notDetermined: 'prompt',
|
|
24
|
+
writeOnly: 'write-only',
|
|
22
25
|
});
|
|
23
26
|
|
|
24
27
|
export function statusFromBridge(status) {
|
|
25
28
|
return STATUS[status] ?? 'unknown';
|
|
26
29
|
}
|
|
27
30
|
|
|
28
|
-
/** The bridge's options for a kind: `automation` carries its target
|
|
31
|
+
/** The bridge's options for a kind: `automation` carries its target, and
|
|
32
|
+
* `calendars` the level being asked for (`reminders` has no write-only
|
|
33
|
+
* grant, and the bridge refuses one with a TypeError). */
|
|
29
34
|
function bridgeOptions(kind, options = {}) {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
35
|
+
if (kind === 'automation' && options.target != null) {
|
|
36
|
+
return { target: String(options.target) };
|
|
37
|
+
}
|
|
38
|
+
if (kind === 'calendars' && options.access != null) {
|
|
39
|
+
return { access: String(options.access) };
|
|
40
|
+
}
|
|
41
|
+
return undefined;
|
|
33
42
|
}
|
|
34
43
|
|
|
35
44
|
export class CocoaPermissions {
|
package/src/components/Button.js
CHANGED
|
@@ -7,10 +7,12 @@ import { useAppOrNull } from '../appcontext.js';
|
|
|
7
7
|
import {
|
|
8
8
|
ABS_FILL,
|
|
9
9
|
Bezel,
|
|
10
|
+
NATIVE_BAND,
|
|
10
11
|
NATIVE_RING,
|
|
11
12
|
TITLE_BASELINE,
|
|
12
13
|
bezelNatural,
|
|
13
14
|
bezelShadow,
|
|
15
|
+
nativeFootprintStyle,
|
|
14
16
|
nativeTitleStyle,
|
|
15
17
|
pressWash,
|
|
16
18
|
useNativeControls,
|
|
@@ -94,64 +96,73 @@ export function Button({
|
|
|
94
96
|
'box',
|
|
95
97
|
{
|
|
96
98
|
theme,
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
style: [
|
|
101
|
-
controlStyle,
|
|
102
|
-
{
|
|
103
|
-
flexDirection: 'row',
|
|
104
|
-
alignItems: 'center',
|
|
105
|
-
justifyContent: 'center',
|
|
106
|
-
gap: small ? 6 : 8,
|
|
107
|
-
// AppKit's metrics, not the palette's: a native bezel is
|
|
108
|
-
// designed at its own height, and stretching it is what this
|
|
109
|
-
// mode exists to avoid. Width still follows the label.
|
|
110
|
-
height: nat.height,
|
|
111
|
-
paddingLeft: small ? 10 : 14,
|
|
112
|
-
paddingRight: small ? 10 : 14,
|
|
113
|
-
// The natural box is the bezel's footprint, shadow included;
|
|
114
|
-
// the body is what the title is placed against — and placed,
|
|
115
|
-
// not centred (`TITLE_BASELINE`). A label centred by its
|
|
116
|
-
// capitals sat 1pt low beside a native button.
|
|
117
|
-
paddingTop: shadow.top,
|
|
118
|
-
paddingBottom: shadow.bottom + TITLE_BASELINE[controlSize],
|
|
119
|
-
// The keyboard ring is the renderer's, on this box — shaped by
|
|
120
|
-
// the bezel's corners and hugging it, as AppKit's is
|
|
121
|
-
// (`NATIVE_RING`), rather than the palette's offset rectangle.
|
|
122
|
-
borderRadius: small ? 5 : 6,
|
|
123
|
-
':focus-visible': {
|
|
124
|
-
outlineWidth: NATIVE_RING.width,
|
|
125
|
-
outlineOffset: NATIVE_RING.offset,
|
|
126
|
-
},
|
|
127
|
-
color: disabled
|
|
128
|
-
? theme.textMuted
|
|
129
|
-
: primary
|
|
130
|
-
? theme.accentText
|
|
131
|
-
: theme.text,
|
|
132
|
-
},
|
|
133
|
-
style,
|
|
134
|
-
],
|
|
99
|
+
// The footprint the caller's style sizes; the button below keeps
|
|
100
|
+
// AppKit's height inside it, centred (`nativeFootprintStyle`).
|
|
101
|
+
style: [nativeFootprintStyle(nat), style],
|
|
135
102
|
},
|
|
136
|
-
h(
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
103
|
+
h(
|
|
104
|
+
'box',
|
|
105
|
+
{
|
|
106
|
+
theme,
|
|
107
|
+
role: 'button',
|
|
108
|
+
...props,
|
|
109
|
+
...boxProps,
|
|
110
|
+
style: [
|
|
111
|
+
controlStyle,
|
|
112
|
+
NATIVE_BAND,
|
|
113
|
+
{
|
|
114
|
+
flexDirection: 'row',
|
|
115
|
+
alignItems: 'center',
|
|
116
|
+
justifyContent: 'center',
|
|
117
|
+
gap: small ? 6 : 8,
|
|
118
|
+
// AppKit's metrics, not the palette's: a native bezel is
|
|
119
|
+
// designed at its own height, and stretching it is what this
|
|
120
|
+
// mode exists to avoid. Width still follows the label.
|
|
121
|
+
height: nat.height,
|
|
122
|
+
paddingLeft: small ? 10 : 14,
|
|
123
|
+
paddingRight: small ? 10 : 14,
|
|
124
|
+
// The natural box is the bezel's footprint, shadow included;
|
|
125
|
+
// the body is what the title is placed against — and placed,
|
|
126
|
+
// not centred (`TITLE_BASELINE`). A label centred by its
|
|
127
|
+
// capitals sat 1pt low beside a native button.
|
|
128
|
+
paddingTop: shadow.top,
|
|
129
|
+
paddingBottom: shadow.bottom + TITLE_BASELINE[controlSize],
|
|
130
|
+
// The keyboard ring is the renderer's, on this box — shaped by
|
|
131
|
+
// the bezel's corners and hugging it, as AppKit's is
|
|
132
|
+
// (`NATIVE_RING`), rather than the palette's offset rectangle.
|
|
133
|
+
borderRadius: small ? 5 : 6,
|
|
134
|
+
':focus-visible': {
|
|
135
|
+
outlineWidth: NATIVE_RING.width,
|
|
136
|
+
outlineOffset: NATIVE_RING.offset,
|
|
137
|
+
},
|
|
138
|
+
color: disabled
|
|
139
|
+
? theme.textMuted
|
|
140
|
+
: primary
|
|
141
|
+
? theme.accentText
|
|
142
|
+
: theme.text,
|
|
143
|
+
},
|
|
144
|
+
],
|
|
145
|
+
},
|
|
146
|
+
h(Bezel, {
|
|
147
|
+
kind: 'push',
|
|
148
|
+
controlSize,
|
|
149
|
+
enabled: !disabled,
|
|
150
|
+
// the Return-key accent fill is AppKit's own "default button"
|
|
151
|
+
isDefault: primary && !disabled,
|
|
152
|
+
style: ABS_FILL,
|
|
153
|
+
}),
|
|
154
|
+
labelContent(children ?? label, nativeTitleStyle(controlSize)),
|
|
155
|
+
// The press answer. Last child on purpose: `:active` marks the
|
|
156
|
+
// pressed node and its ancestors, and the topmost child is what the
|
|
157
|
+
// press lands on. No hover tint — AppKit buttons have none.
|
|
158
|
+
h('box', {
|
|
159
|
+
style: [
|
|
160
|
+
ABS_FILL,
|
|
161
|
+
{ borderRadius: small ? 5 : 6 },
|
|
162
|
+
!disabled && { ':active': { backgroundColor: pressWash(theme) } },
|
|
163
|
+
],
|
|
164
|
+
}),
|
|
165
|
+
),
|
|
155
166
|
);
|
|
156
167
|
}
|
|
157
168
|
const background = !solid
|
package/src/components/Select.js
CHANGED
|
@@ -9,11 +9,13 @@ import { Icon } from './Icon.js';
|
|
|
9
9
|
import {
|
|
10
10
|
ABS_FILL,
|
|
11
11
|
Bezel,
|
|
12
|
+
NATIVE_BAND,
|
|
12
13
|
NATIVE_MENU,
|
|
13
14
|
NATIVE_RING,
|
|
14
15
|
TITLE_BASELINE,
|
|
15
16
|
bezelNatural,
|
|
16
17
|
bezelShadow,
|
|
18
|
+
nativeFootprintStyle,
|
|
17
19
|
nativeTitleStyle,
|
|
18
20
|
pressWash,
|
|
19
21
|
useNativeControls,
|
|
@@ -438,7 +440,11 @@ export function Select({
|
|
|
438
440
|
if (open) scrollRef.current?.scrollIntoView(activeRef.current);
|
|
439
441
|
}, [open, activeIndex]);
|
|
440
442
|
|
|
441
|
-
|
|
443
|
+
// AppKit's own popup metrics, which the trigger below is laid out at —
|
|
444
|
+
// and only the trigger: the footprint it sits in is the caller's to size.
|
|
445
|
+
const nat = nativeControls ? bezelNatural(app, 'popup') : null;
|
|
446
|
+
|
|
447
|
+
const trigger = h(
|
|
442
448
|
'box',
|
|
443
449
|
{
|
|
444
450
|
theme,
|
|
@@ -465,7 +471,8 @@ export function Select({
|
|
|
465
471
|
flexDirection: 'row',
|
|
466
472
|
alignItems: 'center',
|
|
467
473
|
gap: 8,
|
|
468
|
-
|
|
474
|
+
...NATIVE_BAND,
|
|
475
|
+
height: nat.height,
|
|
469
476
|
paddingLeft: TRIGGER_PAD_LEFT,
|
|
470
477
|
paddingRight: 26,
|
|
471
478
|
// The title sits where NSPopUpButtonCell puts it — on the
|
|
@@ -515,7 +522,10 @@ export function Select({
|
|
|
515
522
|
':hover': { backgroundColor: theme.surfaceHover },
|
|
516
523
|
':active': { backgroundColor: theme.surfaceActive },
|
|
517
524
|
},
|
|
518
|
-
style,
|
|
525
|
+
// The caller's style, on the trigger where the trigger is the whole
|
|
526
|
+
// control. Under a native bezel it sizes the footprint below instead
|
|
527
|
+
// and the trigger keeps AppKit's height (`nativeFootprintStyle`).
|
|
528
|
+
!nativeControls && style,
|
|
519
529
|
],
|
|
520
530
|
},
|
|
521
531
|
// `pressed` while the menu is down: AppKit's popup answers being open
|
|
@@ -635,4 +645,11 @@ export function Select({
|
|
|
635
645
|
),
|
|
636
646
|
),
|
|
637
647
|
);
|
|
648
|
+
|
|
649
|
+
// The bezel and its title are one unit at NSPopUpButton's height; the box
|
|
650
|
+
// around them is what a `height`, a `flexGrow` or a parent's align-stretch
|
|
651
|
+
// is free to make taller, with the control centred in it (issue #510).
|
|
652
|
+
return nativeControls
|
|
653
|
+
? h('box', { theme, style: [nativeFootprintStyle(nat), style] }, trigger)
|
|
654
|
+
: trigger;
|
|
638
655
|
}
|
package/src/components/native.js
CHANGED
|
@@ -101,6 +101,47 @@ export function nativeTitleStyle(controlSize = 'regular') {
|
|
|
101
101
|
return { alignSelf: 'flex-end', fontSize: CONTROL_FONT_SIZE[controlSize] };
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
+
/**
|
|
105
|
+
* The **footprint** a native control sits in: the box the caller's style
|
|
106
|
+
* sizes, with the control — bezel, title and press wash, one unit at
|
|
107
|
+
* AppKit's own metrics — centred inside it.
|
|
108
|
+
*
|
|
109
|
+
* Two boxes rather than one, because the title is *placed* and not centred:
|
|
110
|
+
* it rides `TITLE_BASELINE` above the bezel body's bottom edge, and that is
|
|
111
|
+
* only where the cell puts it while the box is exactly `bezelNatural` tall.
|
|
112
|
+
* The caller's style is applied last and flex stretches a box regardless, so
|
|
113
|
+
* it was not always: `style={{ height: 44 }}`, a `height: '100%'` in a taller
|
|
114
|
+
* parent and a bare `flexGrow: 1` each made the box taller, and the two
|
|
115
|
+
* halves — the bezel absolutely filling it, the title glued to its bottom —
|
|
116
|
+
* came apart, the label landing below the control it names (issue #510). The
|
|
117
|
+
* drawn path never had this, because it centres its label: a stretched drawn
|
|
118
|
+
* control is merely roomy, where a stretched native one was broken.
|
|
119
|
+
*
|
|
120
|
+
* So the height a caller can move belongs to a box the control sits in, and
|
|
121
|
+
* the control keeps AppKit's. The default here is exactly today's box — an
|
|
122
|
+
* untouched control lays out as it always did, and the explicit height still
|
|
123
|
+
* resists a row's align-stretch — and the caller's style, applied after it,
|
|
124
|
+
* moves the footprint alone. The control stretches across it (align-stretch,
|
|
125
|
+
* the default), so a footprint given a width is a bezel that wide.
|
|
126
|
+
*
|
|
127
|
+
* The control, not the footprint, stays the *control*: the role, the
|
|
128
|
+
* handlers, the focus ring and the ref go on it, so a press in the slack
|
|
129
|
+
* above a 22pt popup in a 46pt hole does nothing at all — as it does in
|
|
130
|
+
* AppKit — rather than firing a button with no visible answer to the press.
|
|
131
|
+
*/
|
|
132
|
+
export function nativeFootprintStyle(nat) {
|
|
133
|
+
return { height: nat.height, justifyContent: 'center' };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The rest of what a native control's own box says about its size: nothing
|
|
138
|
+
* shrinks it. A squashed bezel is as wrong as a stretched one, and a
|
|
139
|
+
* footprint shorter than the control is a caller asking for something the
|
|
140
|
+
* cell's metrics cannot answer — so it overflows, visibly, rather than
|
|
141
|
+
* quietly drawing a title where the bezel is not.
|
|
142
|
+
*/
|
|
143
|
+
export const NATIVE_BAND = Object.freeze({ flexShrink: 0 });
|
|
144
|
+
|
|
104
145
|
/**
|
|
105
146
|
* NSMenu's geometry, for the menu a native popup bezel opens — read off
|
|
106
147
|
* `NSMenu.size` on macOS 15 rather than off a screenshot, so it is the
|