@domicile-desktop/component-library 0.0.0-alpha-183ca460bbae → 0.0.0-alpha-3e5b976f2009

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 CHANGED
@@ -2,8 +2,8 @@
2
2
  "dependencies": {
3
3
  "@base-ui/react": "^1.8.0",
4
4
  "@cprussin/option-result": "^2.0.1",
5
- "@domicile-desktop/sdk": "0.0.0-alpha-183ca460bbae",
6
- "@domicile-desktop/system-apps": "0.0.0-alpha-183ca460bbae",
5
+ "@domicile-desktop/sdk": "0.0.0-alpha-3e5b976f2009",
6
+ "@domicile-desktop/system-apps": "0.0.0-alpha-3e5b976f2009",
7
7
  "@pandacss/dev": "^1.12.1",
8
8
  "@phosphor-icons/react": "^2.1.10"
9
9
  },
@@ -114,5 +114,5 @@
114
114
  "test:unit": "bun test"
115
115
  },
116
116
  "type": "module",
117
- "version": "0.0.0-alpha-183ca460bbae"
117
+ "version": "0.0.0-alpha-3e5b976f2009"
118
118
  }
@@ -294,6 +294,7 @@ const Dialog = ({
294
294
  answer={answer}
295
295
  asker={asker}
296
296
  body={request.body}
297
+ list={list}
297
298
  screen={screen}
298
299
  />
299
300
  );
@@ -1,4 +1,5 @@
1
1
  import type {
2
+ PageRange,
2
3
  PortalAnswer,
3
4
  PrintBody,
4
5
  Printer,
@@ -11,19 +12,26 @@ import {
11
12
  PrintQuality,
12
13
  Sides,
13
14
  } from "@domicile-desktop/sdk/portal";
15
+ import { FilePdfIcon } from "@phosphor-icons/react/dist/ssr/FilePdf";
16
+ import { PrinterIcon } from "@phosphor-icons/react/dist/ssr/Printer";
14
17
  import { useState } from "react";
15
18
  import { css } from "../../styled-system/css";
16
19
  import { grid } from "../../styled-system/patterns";
20
+ import { focusOnAttach } from "../_control/focusOnAttach";
17
21
  import { Button } from "../Button/Button";
22
+ import { Field } from "../Field/Field";
23
+ import { FilePicker } from "../FilePicker/FilePicker";
24
+ import { ChooserMode } from "../FilePicker/file-request";
18
25
  import { Input } from "../Input/Input";
19
26
  import { ModalDialog } from "../ModalDialog/ModalDialog";
27
+ import { useScreenRegion } from "../Screen/DisplayProvider";
20
28
  import { Select } from "../Select/Select";
21
29
  import { pageRanges } from "./page-ranges";
22
30
 
23
31
  const SIDES = {
24
- [Sides.OneSided]: "One-sided",
25
- [Sides.TwoSidedLongEdge]: "Two-sided, long edge",
26
- [Sides.TwoSidedShortEdge]: "Two-sided, short edge",
32
+ [Sides.OneSided]: "Off",
33
+ [Sides.TwoSidedLongEdge]: "Long edge",
34
+ [Sides.TwoSidedShortEdge]: "Short edge",
27
35
  } as const;
28
36
 
29
37
  const COLOR_MODES = {
@@ -34,8 +42,8 @@ const COLOR_MODES = {
34
42
  const ORIENTATIONS = {
35
43
  [Orientation.Portrait]: "Portrait",
36
44
  [Orientation.Landscape]: "Landscape",
37
- [Orientation.ReverseLandscape]: "Reverse landscape",
38
- [Orientation.ReversePortrait]: "Reverse portrait",
45
+ [Orientation.ReverseLandscape]: "Landscape, upside down",
46
+ [Orientation.ReversePortrait]: "Portrait, upside down",
39
47
  } as const;
40
48
 
41
49
  const QUALITIES = {
@@ -44,40 +52,77 @@ const QUALITIES = {
44
52
  [PrintQuality.High]: "High",
45
53
  } as const;
46
54
 
55
+ /** Whether to print every page or the ones typed. */
56
+ enum PageChoice {
57
+ All,
58
+ Custom,
59
+ }
60
+
61
+ const PAGE_CHOICES = [
62
+ { label: "All", value: PageChoice.All },
63
+ { label: "Custom", value: PageChoice.Custom },
64
+ ] as const;
65
+
47
66
  type Props = {
48
67
  answer: (answer: PortalAnswer) => void;
49
68
  /** Who asks, as the dialog names them. */
50
69
  asker: string;
51
70
  body: PrintBody;
71
+ /** Lists a directory for the PDF's file picker; see `FileRequest.list`. */
72
+ list: (path: string) => Promise<readonly string[]>;
52
73
  screen: string | undefined;
53
74
  };
54
75
 
55
76
  /**
56
- * A print dialog: a printer and the options it supports, or word that there
57
- * are no printers. Dismissing it cancels. No preview: the document arrives
58
- * only after it closes.
77
+ * A print dialog: a printer or Save to PDF, and the options each supports.
78
+ * Saving to a PDF picks its file after. Dismissing it cancels. No preview:
79
+ * the document arrives only after it closes.
59
80
  */
60
- export const PrintDialog = ({ answer, asker, body, screen }: Props) => {
61
- const [chosen, setChosen] = useState(() => startingPrinter(body));
62
- return (
81
+ export const PrintDialog = ({ answer, asker, body, list, screen }: Props) => {
82
+ const [chosen, setChosen] = useState(() => startingChoice(body));
83
+ const [picking, setPicking] = useState(false);
84
+ const options = readOptions(chosen);
85
+ const pdf = chosen.printer === body.pdf;
86
+ return picking && options !== undefined ? (
87
+ <PdfPicker
88
+ answer={answer}
89
+ back={() => {
90
+ setPicking(false);
91
+ }}
92
+ body={body}
93
+ list={list}
94
+ options={options}
95
+ screen={screen}
96
+ />
97
+ ) : (
63
98
  <ModalDialog
64
99
  closeButton={false}
65
100
  footer={
66
- chosen === undefined ? (
101
+ <>
67
102
  <Button
68
103
  onClick={() => {
69
104
  answer(Answer.Canceled());
70
105
  }}
106
+ variant="outline"
71
107
  >
72
- Close
108
+ Cancel
73
109
  </Button>
74
- ) : (
75
- <PrinterFooter
76
- acceptLabel={body.acceptLabel ?? "Print"}
77
- answer={answer}
78
- chosen={chosen}
79
- />
80
- )
110
+ <Button
111
+ disabled={options === undefined}
112
+ onClick={() => {
113
+ if (options !== undefined) {
114
+ if (pdf) {
115
+ setPicking(true);
116
+ } else {
117
+ answer(Answer.Print(chosen.printer.name, options));
118
+ }
119
+ }
120
+ }}
121
+ variant="accent"
122
+ >
123
+ {pdf ? "Save…" : (body.acceptLabel ?? "Print")}
124
+ </Button>
125
+ </>
81
126
  }
82
127
  onOpenChange={(open) => {
83
128
  if (!open) {
@@ -89,128 +134,139 @@ export const PrintDialog = ({ answer, asker, body, screen }: Props) => {
89
134
  title="Print"
90
135
  >
91
136
  <p className={askerStyles}>{`${asker} prints “${body.title}”`}</p>
92
- {chosen === undefined ? (
93
- <p className={textStyles}>No printers are set up.</p>
94
- ) : (
95
- <Options
96
- chosen={chosen}
97
- onChange={setChosen}
98
- printers={body.printers}
99
- />
100
- )}
137
+ <div className={formStyles}>
138
+ <div className={wideStyles}>
139
+ <Field
140
+ hint={
141
+ body.printers.length === 0 ? "No printers are set up." : undefined
142
+ }
143
+ label="Destination"
144
+ >
145
+ <Select
146
+ onValueChange={(picked) => {
147
+ if (picked !== null) {
148
+ setChosen(startOf(picked));
149
+ }
150
+ }}
151
+ options={[...body.printers, body.pdf].map((listed) => ({
152
+ label: (
153
+ <span className={destinationStyles}>
154
+ <DestinationIcon pdf={listed === body.pdf} />
155
+ {nameOf(listed)}
156
+ </span>
157
+ ),
158
+ textLabel: nameOf(listed),
159
+ value: listed,
160
+ }))}
161
+ prefixIcon={<DestinationIcon pdf={pdf} />}
162
+ value={chosen.printer}
163
+ />
164
+ </Field>
165
+ </div>
166
+ <Options chosen={chosen} onChange={setChosen} />
167
+ </div>
101
168
  </ModalDialog>
102
169
  );
103
170
  };
104
171
 
172
+ const DestinationIcon = ({ pdf }: { pdf: boolean }) =>
173
+ pdf ? <FilePdfIcon /> : <PrinterIcon />;
174
+
105
175
  /** A printer and the options as the user has set them so far. */
106
176
  type Chosen = {
107
177
  /** As typed. */
108
178
  copies: string;
179
+ pageChoice: PageChoice;
109
180
  /** As typed. */
110
181
  pages: string;
111
182
  options: PrintOptions;
112
183
  printer: Printer;
113
184
  };
114
185
 
115
- const PrinterFooter = ({
116
- acceptLabel,
117
- answer,
118
- chosen,
119
- }: {
120
- acceptLabel: string;
121
- answer: (answer: PortalAnswer) => void;
122
- chosen: Chosen;
123
- }) => {
124
- const options = readOptions(chosen);
125
- return (
126
- <>
127
- <Button
128
- onClick={() => {
129
- answer(Answer.Canceled());
130
- }}
131
- variant="outline"
132
- >
133
- Cancel
134
- </Button>
135
- <Button
136
- disabled={options === undefined}
137
- onClick={() => {
138
- if (options !== undefined) {
139
- answer(Answer.Print(chosen.printer.name, options));
140
- }
141
- }}
142
- >
143
- {acceptLabel}
144
- </Button>
145
- </>
146
- );
147
- };
148
-
186
+ /** The options `chosen.printer` supports, each in a labeled field. */
149
187
  const Options = ({
150
188
  chosen,
151
189
  onChange,
152
- printers,
153
190
  }: {
154
191
  chosen: Chosen;
155
192
  onChange: (chosen: Chosen) => void;
156
- printers: readonly Printer[];
157
193
  }) => {
158
194
  const { options, printer } = chosen;
159
195
  const set = (changed: Partial<PrintOptions>) => {
160
196
  onChange({ ...chosen, options: { ...options, ...changed } });
161
197
  };
198
+ const custom = chosen.pageChoice === PageChoice.Custom;
162
199
  return (
163
- <div className={optionsStyles}>
164
- <Select
165
- aria-label="Printer"
166
- onValueChange={(name) => {
167
- const picked = printers.find((listed) => listed.name === name);
168
- if (picked !== undefined) {
169
- onChange(startOf(picked));
170
- }
171
- }}
172
- options={printers.map((listed) => ({
173
- label: listed.description ?? listed.name,
174
- value: listed.name,
175
- }))}
176
- value={printer.name}
177
- />
200
+ <>
201
+ {printer.pageRanges && (
202
+ <div className={pagesStyles}>
203
+ <Field label="Pages">
204
+ <Select
205
+ onValueChange={(pageChoice) => {
206
+ if (pageChoice !== null) {
207
+ onChange({ ...chosen, pageChoice });
208
+ }
209
+ }}
210
+ options={PAGE_CHOICES}
211
+ value={chosen.pageChoice}
212
+ />
213
+ </Field>
214
+ {custom && (
215
+ <Field
216
+ invalid={chosen.pages !== "" && pagesOf(chosen) === undefined}
217
+ >
218
+ <Input
219
+ aria-label="Page range"
220
+ autoFocus
221
+ onChange={(event) => {
222
+ onChange({ ...chosen, pages: event.target.value });
223
+ }}
224
+ placeholder="e.g. 1-3, 5"
225
+ value={chosen.pages}
226
+ />
227
+ </Field>
228
+ )}
229
+ </div>
230
+ )}
231
+ {printer.copiesMax > 1 && (
232
+ <Field label="Copies">
233
+ <Input
234
+ max={printer.copiesMax}
235
+ min={1}
236
+ onChange={(event) => {
237
+ onChange({ ...chosen, copies: event.target.value });
238
+ }}
239
+ type="number"
240
+ value={chosen.copies}
241
+ />
242
+ </Field>
243
+ )}
178
244
  {printer.media.length > 0 && (
179
- <Select
180
- aria-label="Paper"
181
- onValueChange={(media) => {
182
- set({ media: media ?? undefined });
183
- }}
184
- options={printer.media.map((media) => ({
185
- label: media.label,
186
- value: media.name,
187
- }))}
188
- placeholder="Printer default"
189
- value={options.media ?? null}
190
- />
245
+ <Field label="Paper">
246
+ <Select
247
+ onValueChange={(media) => {
248
+ set({ media: media ?? undefined });
249
+ }}
250
+ options={printer.media.map((media) => ({
251
+ label: media.label,
252
+ value: media.name,
253
+ }))}
254
+ placeholder="Default"
255
+ value={options.media ?? null}
256
+ />
257
+ </Field>
191
258
  )}
192
- <Input
193
- aria-label="Copies"
194
- max={printer.copiesMax}
195
- min={1}
196
- onChange={(event) => {
197
- onChange({ ...chosen, copies: event.target.value });
259
+ <Choice
260
+ label="Layout"
261
+ labels={ORIENTATIONS}
262
+ offered={printer.orientations}
263
+ onChange={(orientation) => {
264
+ set({ orientation });
198
265
  }}
199
- type="number"
200
- value={chosen.copies}
266
+ value={options.orientation}
201
267
  />
202
- {printer.pageRanges && (
203
- <Input
204
- aria-label="Pages"
205
- onChange={(event) => {
206
- onChange({ ...chosen, pages: event.target.value });
207
- }}
208
- placeholder="All, or 1-3, 5"
209
- value={chosen.pages}
210
- />
211
- )}
212
268
  <Choice
213
- label="Sides"
269
+ label="Two-sided"
214
270
  labels={SIDES}
215
271
  offered={printer.sides}
216
272
  onChange={(sides) => {
@@ -227,15 +283,6 @@ const Options = ({
227
283
  }}
228
284
  value={options.colorMode}
229
285
  />
230
- <Choice
231
- label="Orientation"
232
- labels={ORIENTATIONS}
233
- offered={printer.orientations}
234
- onChange={(orientation) => {
235
- set({ orientation });
236
- }}
237
- value={options.orientation}
238
- />
239
286
  <Choice
240
287
  label="Quality"
241
288
  labels={QUALITIES}
@@ -245,11 +292,11 @@ const Options = ({
245
292
  }}
246
293
  value={options.quality}
247
294
  />
248
- </div>
295
+ </>
249
296
  );
250
297
  };
251
298
 
252
- /** A select of the values a printer offers for one option; none if it offers none. */
299
+ /** A labeled select of the values a printer offers; none if it offers none. */
253
300
  const Choice = <V extends number>({
254
301
  label,
255
302
  labels,
@@ -264,43 +311,136 @@ const Choice = <V extends number>({
264
311
  value: V | undefined;
265
312
  }) =>
266
313
  offered.length > 0 && (
267
- <Select
268
- aria-label={label}
269
- onValueChange={(picked) => {
270
- onChange(picked ?? undefined);
271
- }}
272
- options={offered.map((offer) => ({ label: labels[offer], value: offer }))}
273
- placeholder="Printer default"
274
- value={value ?? null}
275
- />
314
+ <Field label={label}>
315
+ <Select
316
+ onValueChange={(picked) => {
317
+ onChange(picked ?? undefined);
318
+ }}
319
+ options={offered.map((offer) => ({
320
+ label: labels[offer],
321
+ value: offer,
322
+ }))}
323
+ placeholder="Default"
324
+ value={value ?? null}
325
+ />
326
+ </Field>
327
+ );
328
+
329
+ /**
330
+ * The file picker for a PDF, over `screen`, starting in the home with the
331
+ * document's title as its name. Canceling goes back to the options.
332
+ */
333
+ const PdfPicker = ({
334
+ answer,
335
+ back,
336
+ body,
337
+ list,
338
+ options,
339
+ screen,
340
+ }: {
341
+ answer: (answer: PortalAnswer) => void;
342
+ back: () => void;
343
+ body: PrintBody;
344
+ list: (path: string) => Promise<readonly string[]>;
345
+ options: PrintOptions;
346
+ screen: string | undefined;
347
+ }) => {
348
+ const region = useScreenRegion(screen);
349
+ return (
350
+ <div className={regionStyles} style={region}>
351
+ <FilePicker
352
+ ref={focusOnAttach}
353
+ request={{
354
+ accept: ["pdf"],
355
+ acceptLabel: "Save",
356
+ cancel: back,
357
+ choose: ([path]) => {
358
+ if (path === undefined) {
359
+ throw new Error("a save chose no file");
360
+ } else {
361
+ answer(Answer.SaveToPdf(path, options));
362
+ }
363
+ },
364
+ home: body.home,
365
+ list,
366
+ mode: ChooserMode.Save,
367
+ suggestedName: pdfName(body.title),
368
+ title: "Save as PDF",
369
+ }}
370
+ />
371
+ </div>
276
372
  );
373
+ };
277
374
 
278
375
  const askerStyles = css({ color: "muted", fontSize: "sm", margin: 0 });
279
376
 
280
- const textStyles = css({ color: "foreground", margin: 0 });
377
+ const formStyles = grid({
378
+ columnGap: 3,
379
+ columns: 2,
380
+ marginBlockStart: 4,
381
+ rowGap: 4,
382
+ });
383
+
384
+ const wideStyles = css({ gridColumn: "1 / -1" });
385
+
386
+ const destinationStyles = css({
387
+ alignItems: "center",
388
+ display: "inline-flex",
389
+ gap: 2,
390
+ });
391
+
392
+ const pagesStyles = grid({
393
+ alignItems: "end",
394
+ columnGap: 3,
395
+ columns: 2,
396
+ gridColumn: "1 / -1",
397
+ });
398
+
399
+ const regionStyles = css({
400
+ inset: 0,
401
+ position: "fixed",
402
+ });
281
403
 
282
- const optionsStyles = grid({ columns: 2, gap: 2, marginBlockStart: 3 });
404
+ /** `printer` for people. */
405
+ const nameOf = (printer: Printer): string =>
406
+ printer.description ?? printer.name;
283
407
 
284
- /** The printer the dialog starts on, if there is any. */
285
- const startingPrinter = (body: PrintBody): Chosen | undefined => {
286
- const printer =
408
+ /** Where the dialog starts: the application's printer, else the first, else a PDF. */
409
+ const startingChoice = (body: PrintBody): Chosen =>
410
+ startOf(
287
411
  body.printers.find((listed) => listed.name === body.printer) ??
288
- body.printers[0];
289
- return printer === undefined ? undefined : startOf(printer);
290
- };
412
+ body.printers[0] ??
413
+ body.pdf,
414
+ );
291
415
 
292
416
  /** `printer` with its initial options. */
293
417
  const startOf = (printer: Printer): Chosen => ({
294
418
  copies: String(printer.initial.copies),
295
419
  options: printer.initial,
296
- pages: "",
420
+ pageChoice:
421
+ printer.initial.pages.length === 0 ? PageChoice.All : PageChoice.Custom,
422
+ pages: printer.initial.pages
423
+ .map(({ first, last }) =>
424
+ first === last ? `${first}` : `${first}-${last}`,
425
+ )
426
+ .join(", "),
297
427
  printer,
298
428
  });
299
429
 
430
+ /** The pages chosen, or `undefined` while custom pages name none it reads. */
431
+ const pagesOf = (chosen: Chosen): readonly PageRange[] | undefined => {
432
+ switch (chosen.pageChoice) {
433
+ case PageChoice.All:
434
+ return [];
435
+ case PageChoice.Custom:
436
+ return chosen.pages.trim() === "" ? undefined : pageRanges(chosen.pages);
437
+ }
438
+ };
439
+
300
440
  /** The options to print with, or `undefined` while copies or pages do not read. */
301
441
  const readOptions = (chosen: Chosen): PrintOptions | undefined => {
302
442
  const copies = Number(chosen.copies);
303
- const pages = pageRanges(chosen.pages);
443
+ const pages = pagesOf(chosen);
304
444
  const copiesRead =
305
445
  Number.isInteger(copies) &&
306
446
  copies >= 1 &&
@@ -309,3 +449,7 @@ const readOptions = (chosen: Chosen): PrintOptions | undefined => {
309
449
  ? { ...chosen.options, copies, pages }
310
450
  : undefined;
311
451
  };
452
+
453
+ /** A file name for a PDF of `title`: no slashes, and `Document` when blank. */
454
+ const pdfName = (title: string): string =>
455
+ `${title.trim() === "" ? "Document" : title.replaceAll("/", "-")}.pdf`;
@@ -1,3 +1,5 @@
1
+ import { Radio } from "@base-ui/react/radio";
2
+ import { RadioGroup } from "@base-ui/react/radio-group";
1
3
  import type { DomicileBrowserWindow } from "@domicile-desktop/sdk/domicile-host";
2
4
  import type {
3
5
  FrozenDesk,
@@ -7,18 +9,16 @@ import type {
7
9
  import { PortalAnswer as Answer } from "@domicile-desktop/sdk/portal";
8
10
  import { GlobeIcon } from "@phosphor-icons/react/dist/ssr/Globe";
9
11
  import { MonitorIcon } from "@phosphor-icons/react/dist/ssr/Monitor";
10
- import type { PointerEvent, ReactNode } from "react";
12
+ import type { CSSProperties, PointerEvent, ReactNode } from "react";
11
13
  import { useEffect, useState } from "react";
12
14
  import { css } from "../../styled-system/css";
13
- import { flex } from "../../styled-system/patterns";
15
+ import { center, flex } from "../../styled-system/patterns";
14
16
  import { Button } from "../Button/Button";
15
17
  import { ModalDialog } from "../ModalDialog/ModalDialog";
16
18
  import type { App } from "../useApps/useApps";
17
19
  import type { FramePoint } from "./frame-point";
18
20
  import { draggedArea, framePoint } from "./frame-point";
19
21
  import { onFrame } from "./on-frame";
20
- import type { SourceTile } from "./SourceTiles";
21
- import { SourceTiles } from "./SourceTiles";
22
22
  import type { ShownWindow } from "./shown-windows";
23
23
  import { ShownWindowKind } from "./shown-windows";
24
24
  import { WindowIcon } from "./WindowIcon";
@@ -69,15 +69,24 @@ const Pick = {
69
69
 
70
70
  type Pick = ReturnType<(typeof Pick)[keyof typeof Pick]>;
71
71
 
72
- /** Something the dialog offers to save, and how it shows it. */
73
- type Choice = { icon: ReactNode; name: string; pick: Pick };
72
+ /**
73
+ * Something the dialog offers to save, and how its row shows it: `name`
74
+ * over `detail`. `label` names it to a screen reader.
75
+ */
76
+ type Choice = {
77
+ detail: string | undefined;
78
+ icon: ReactNode;
79
+ label: string;
80
+ name: string;
81
+ pick: Pick;
82
+ };
74
83
 
75
84
  /**
76
- * Picks what to save of a frozen desk: all of it, a screen, a window, each
77
- * a tile previewing it as the screen cast picker's do, or an area dragged
78
- * over the desk. A window is saved from its own frame, so what is
79
- * over it, such as a notification, is left out. Ctrl+C copies the pick to
80
- * the clipboard instead of saving it. Dismissing it cancels.
85
+ * Picks what to save of a frozen desk: all of it, a screen or a window from
86
+ * a list beside the desk, or an area dragged over the desk. The desk outlines
87
+ * the pick, with its size. A window is saved from its own frame, so what is
88
+ * over it, such as a notification, is left out. Copy, or Ctrl+C, copies the
89
+ * pick to the clipboard instead of saving it. Dismissing it cancels.
81
90
  */
82
91
  export const ScreenshotDialog = ({
83
92
  answer,
@@ -92,7 +101,9 @@ export const ScreenshotDialog = ({
92
101
  const [shown] = useState(shownWindows);
93
102
  const screens: Choice[] = body.monitors.map(
94
103
  ({ area, description, name }) => ({
104
+ detail: description === "" ? undefined : name,
95
105
  icon: <MonitorIcon aria-hidden />,
106
+ label: description === "" ? name : description,
96
107
  name: description === "" ? name : description,
97
108
  pick: Pick.Area(area),
98
109
  }),
@@ -108,7 +119,9 @@ export const ScreenshotDialog = ({
108
119
  ...shown.flatMap((window) => browserChoices(body, browserWindows, window)),
109
120
  ];
110
121
  const whole: Choice = {
122
+ detail: `${String(body.width)} × ${String(body.height)}`,
111
123
  icon: <MonitorIcon aria-hidden />,
124
+ label: "Whole desk",
112
125
  name: "Whole desk",
113
126
  pick: Pick.Area({ height: body.height, width: body.width, x: 0, y: 0 }),
114
127
  };
@@ -133,32 +146,6 @@ export const ScreenshotDialog = ({
133
146
  document.removeEventListener("keydown", copy);
134
147
  };
135
148
  }, [answer, pick]);
136
- // A choice's tile, keyed by its index: names repeat, as two untitled
137
- // windows do, and the order is fixed.
138
- const group = (label: string, members: readonly Choice[], first: number) =>
139
- members.length > 0 && (
140
- <SourceTiles
141
- desk={body}
142
- label={label}
143
- multiple={false}
144
- onValueChange={([value]) => {
145
- // Picking the picked tile again keeps it.
146
- if (value !== undefined) {
147
- setPicked(Number(value));
148
- }
149
- }}
150
- tiles={members.map((choice, offset) =>
151
- tileOf(choice, String(first + offset)),
152
- )}
153
- value={
154
- typeof picked === "number" &&
155
- picked >= first &&
156
- picked < first + members.length
157
- ? [String(picked)]
158
- : []
159
- }
160
- />
161
- );
162
149
  const pointAt = (event: PointerEvent<HTMLDivElement>) =>
163
150
  framePoint(
164
151
  { x: event.clientX, y: event.clientY },
@@ -178,6 +165,14 @@ export const ScreenshotDialog = ({
178
165
  >
179
166
  Cancel
180
167
  </Button>
168
+ <Button
169
+ onClick={() => {
170
+ answer(answerOf(pick, true));
171
+ }}
172
+ variant="outline"
173
+ >
174
+ Copy
175
+ </Button>
181
176
  <Button
182
177
  onClick={() => {
183
178
  answer(answerOf(pick, false));
@@ -198,57 +193,209 @@ export const ScreenshotDialog = ({
198
193
  title="Take a screenshot"
199
194
  >
200
195
  {asker !== undefined && <p className={askerStyles}>{`${asker} asks`}</p>}
201
- <div className={choicesStyles}>
202
- {group("Desk", [whole], 0)}
203
- {group("Screens", screens, 1)}
204
- {group("Windows", windows, 1 + screens.length)}
205
- </div>
206
- {pick.kind === PickKind.Window && pick.area === undefined && (
207
- <p className={askerStyles}>
208
- Not on screen: saved as it last drew itself.
209
- </p>
210
- )}
211
- <div
212
- aria-label="Drag to pick an area"
213
- className={deskStyles}
214
- onPointerDown={(event) => {
215
- const from = pointAt(event);
216
- setDragFrom(from);
217
- setPicked(draggedArea(from, from));
218
- }}
219
- onPointerMove={(event) => {
220
- if (dragFrom !== undefined) {
221
- setPicked(draggedArea(dragFrom, pointAt(event)));
222
- }
223
- }}
224
- onPointerUp={() => {
225
- setDragFrom(undefined);
226
- }}
227
- role="group"
228
- >
229
- <img
230
- alt="The desk"
231
- className={frameStyles}
232
- draggable={false}
233
- src={body.frame}
234
- />
235
- {pick.area !== undefined && (
236
- <div className={pickedStyles} style={placed(pick.area, body)} />
237
- )}
196
+ <div className={layoutStyles}>
197
+ {/* `null` is Base UI's "no radio checked", for an area dragged. */}
198
+ <RadioGroup<number | null>
199
+ aria-label="What to save"
200
+ className={listStyles}
201
+ onValueChange={(value) => {
202
+ if (value === null) {
203
+ throw new Error("a radio unchecked the screenshot choices");
204
+ } else {
205
+ setPicked(value);
206
+ }
207
+ }}
208
+ value={typeof picked === "number" ? picked : null}
209
+ >
210
+ <ChoiceRow choice={whole} index={0} />
211
+ <ChoiceSection choices={screens} first={1} label="Screens" />
212
+ <ChoiceSection
213
+ choices={windows}
214
+ first={1 + screens.length}
215
+ label="Windows"
216
+ />
217
+ </RadioGroup>
218
+ <figure className={stageStyles}>
219
+ <div
220
+ aria-label="Drag to pick an area"
221
+ className={deskStyles}
222
+ onPointerDown={(event) => {
223
+ const from = pointAt(event);
224
+ setDragFrom(from);
225
+ setPicked(draggedArea(from, from));
226
+ }}
227
+ onPointerMove={(event) => {
228
+ if (dragFrom !== undefined) {
229
+ setPicked(draggedArea(dragFrom, pointAt(event)));
230
+ }
231
+ }}
232
+ onPointerUp={() => {
233
+ setDragFrom(undefined);
234
+ }}
235
+ role="group"
236
+ style={fitted(body)}
237
+ >
238
+ <img
239
+ alt="The desk"
240
+ className={frameStyles}
241
+ draggable={false}
242
+ src={body.frame}
243
+ />
244
+ {pick.area !== undefined && (
245
+ <div className={pickedStyles} style={placed(pick.area, body)}>
246
+ <span className={sizeStyles}>
247
+ {`${String(pick.area.width)} × ${String(pick.area.height)}`}
248
+ </span>
249
+ </div>
250
+ )}
251
+ </div>
252
+ <figcaption className={captionStyles}>
253
+ {pick.kind === PickKind.Window && pick.area === undefined
254
+ ? "Not on screen: saved as it last drew itself."
255
+ : "Drag over the desk to pick an area."}
256
+ </figcaption>
257
+ </figure>
238
258
  </div>
239
259
  </ModalDialog>
240
260
  );
241
261
  };
242
262
 
263
+ /**
264
+ * A labeled run of rows, numbered from `first` in the dialog's choices, or
265
+ * nothing when there are none.
266
+ */
267
+ const ChoiceSection = ({
268
+ choices,
269
+ first,
270
+ label,
271
+ }: {
272
+ choices: readonly Choice[];
273
+ first: number;
274
+ label: string;
275
+ }) =>
276
+ choices.length > 0 && (
277
+ <div aria-label={label} className={sectionStyles} role="group">
278
+ <span aria-hidden className={sectionLabelStyles}>
279
+ {label}
280
+ </span>
281
+ {choices.map((choice, offset) => (
282
+ // Keyed by index: names repeat, as two untitled windows do, and the
283
+ // order is fixed.
284
+ <ChoiceRow
285
+ choice={choice}
286
+ index={first + offset}
287
+ key={first + offset}
288
+ />
289
+ ))}
290
+ </div>
291
+ );
292
+
293
+ /** `choice`'s row, picking it as the dialog's choice `index`. */
294
+ const ChoiceRow = ({ choice, index }: { choice: Choice; index: number }) => (
295
+ <Radio.Root aria-label={choice.label} className={rowStyles} value={index}>
296
+ {choice.icon}
297
+ <span className={rowTextStyles}>
298
+ <span className={rowNameStyles}>{choice.name}</span>
299
+ {choice.detail !== undefined && (
300
+ <span className={rowDetailStyles}>{choice.detail}</span>
301
+ )}
302
+ </span>
303
+ </Radio.Root>
304
+ );
305
+
243
306
  const askerStyles = css({ color: "muted", fontSize: "sm", margin: 0 });
244
307
 
245
- const choicesStyles = flex({
246
- direction: "column",
308
+ const layoutStyles = css({
309
+ alignItems: "stretch",
310
+ display: "grid",
247
311
  gap: 4,
248
- marginBlock: 3,
312
+ gridTemplateColumns: "{spacing.64} minmax(0, 1fr)",
313
+ });
314
+
315
+ // As tall as the desk beside it, scrolling when the rows run longer: a zero
316
+ // height keeps the rows out of the grid row's height.
317
+ const listStyles = flex({
318
+ blockSize: 0,
319
+ direction: "column",
320
+ gap: 3,
321
+ minBlockSize: "full",
322
+ overflowY: "auto",
323
+ // Room for the focus ring, which the scrolling would clip.
324
+ padding: 1,
325
+ });
326
+
327
+ const sectionStyles = flex({ direction: "column", gap: 0.5 });
328
+
329
+ const sectionLabelStyles = css({
330
+ color: "muted",
331
+ fontSize: "xs",
332
+ fontWeight: "medium",
333
+ letterSpacing: "wide",
334
+ paddingBlockEnd: 1,
335
+ paddingInline: 2.5,
336
+ textTransform: "uppercase",
337
+ });
338
+
339
+ const rowStyles = css({
340
+ _checked: {
341
+ backgroundColor: "color-mix(in oklab, {colors.accent} 20%, transparent)",
342
+ borderColor: "accent",
343
+ },
344
+ _hover: {
345
+ backgroundColor: "color-mix(in oklab, {colors.foreground} 6%, transparent)",
346
+ },
347
+ "& > :is(svg, img)": {
348
+ blockSize: 5,
349
+ color: "muted",
350
+ flexShrink: 0,
351
+ inlineSize: 5,
352
+ },
353
+ alignItems: "center",
354
+ border: "1px solid transparent",
355
+ borderRadius: "md",
356
+ color: "foreground",
357
+ cursor: "pointer",
358
+ display: "flex",
359
+ flexShrink: 0,
360
+ gap: 2.5,
361
+ minInlineSize: 0,
362
+ paddingBlock: 1.5,
363
+ paddingInline: 2.5,
364
+ transition: "background-color {durations.fast} {easings.out}",
365
+ });
366
+
367
+ const rowTextStyles = flex({ direction: "column", minInlineSize: 0 });
368
+
369
+ const rowNameStyles = css({
370
+ fontSize: "sm",
371
+ overflow: "hidden",
372
+ textOverflow: "ellipsis",
373
+ whiteSpace: "nowrap",
374
+ });
375
+
376
+ const rowDetailStyles = css({
377
+ color: "muted",
378
+ fontSize: "xs",
379
+ overflow: "hidden",
380
+ textOverflow: "ellipsis",
381
+ whiteSpace: "nowrap",
382
+ });
383
+
384
+ const stageStyles = flex({
385
+ align: "center",
386
+ backgroundColor: "background",
387
+ border: "1px solid {colors.border}",
388
+ borderRadius: "lg",
389
+ direction: "column",
390
+ gap: 3,
391
+ justify: "center",
392
+ margin: 0,
393
+ minBlockSize: 72,
394
+ padding: 4,
249
395
  });
250
396
 
251
397
  const deskStyles = css({
398
+ borderRadius: "sm",
252
399
  cursor: "crosshair",
253
400
  // Keeps the shade around the picked area on the frame.
254
401
  overflow: "hidden",
@@ -257,17 +404,47 @@ const deskStyles = css({
257
404
  userSelect: "none",
258
405
  });
259
406
 
260
- const frameStyles = css({ display: "block", width: "full" });
407
+ const frameStyles = css({
408
+ blockSize: "full",
409
+ display: "block",
410
+ inlineSize: "full",
411
+ });
261
412
 
262
413
  const pickedStyles = css({
263
- borderColor: "accent",
264
- borderStyle: "solid",
265
- borderWidth: "2px",
266
- boxShadow: "0 0 0 9999px rgb(0 0 0 / 0.45)",
414
+ border: "2px solid {colors.accent}",
415
+ boxShadow: "0 0 0 100vmax {colors.backdrop}",
267
416
  pointerEvents: "none",
268
417
  position: "absolute",
269
418
  });
270
419
 
420
+ const sizeStyles = center({
421
+ backgroundColor: "accent",
422
+ borderEndEndRadius: "sm",
423
+ color: "background",
424
+ fontSize: "xs",
425
+ fontVariantNumeric: "tabular-nums",
426
+ fontWeight: "medium",
427
+ insetBlockStart: 0,
428
+ insetInlineStart: 0,
429
+ paddingBlock: 0.5,
430
+ paddingInline: 1.5,
431
+ position: "absolute",
432
+ whiteSpace: "nowrap",
433
+ });
434
+
435
+ const captionStyles = css({ color: "muted", fontSize: "xs" });
436
+
437
+ /**
438
+ * The desk's box in the stage: as wide as the stage allows, at the frame's
439
+ * aspect ratio, no taller than 56% of the screen so the dialog fits on it.
440
+ */
441
+ const fitted = (frame: { width: number; height: number }): CSSProperties => {
442
+ const ratio = `${String(frame.width)} / ${String(frame.height)}`;
443
+ return {
444
+ aspectRatio: ratio,
445
+ inlineSize: `min(100%, calc(56cqh * ${ratio}))`,
446
+ };
447
+ };
271
448
  /** `area`'s place over the drawn frame, in percent of `frame`. */
272
449
  const placed = (
273
450
  area: ShotRect,
@@ -279,14 +456,6 @@ const placed = (
279
456
  width: `${String((area.width / frame.width) * 100)}%`,
280
457
  });
281
458
 
282
- /** `choice`'s tile under `key`, previewing what it saves. */
283
- const tileOf = (choice: Choice, key: string): SourceTile => ({
284
- area: choice.pick.area,
285
- icon: choice.icon,
286
- key,
287
- name: choice.name,
288
- });
289
-
290
459
  /** The name of a window titled `title`, which may be empty. */
291
460
  const titled = (title: string): string =>
292
461
  title === "" ? "Untitled window" : title;
@@ -346,8 +515,10 @@ const browserChoices = (
346
515
  ? []
347
516
  : [
348
517
  {
518
+ detail: "Browser",
349
519
  icon: <GlobeIcon aria-hidden />,
350
- name: `Browser: ${titled(browser.title)}`,
520
+ label: `Browser: ${titled(browser.title)}`,
521
+ name: titled(browser.title),
351
522
  pick: Pick.BrowserWindow(browser.id, area),
352
523
  },
353
524
  ];
@@ -361,7 +532,9 @@ const windowChoice = (
361
532
  title: string,
362
533
  app: App | undefined,
363
534
  ): Choice => ({
535
+ detail: app?.name,
364
536
  icon: <WindowIcon app={app} />,
365
- name: app === undefined ? title : `${app.name}: ${title}`,
537
+ label: app === undefined ? title : `${app.name}: ${title}`,
538
+ name: title,
366
539
  pick,
367
540
  });
@@ -29,7 +29,7 @@ type Props = {
29
29
  /**
30
30
  * A labeled grid of sources to pick, each a tile previewing it from the
31
31
  * frozen desk, or showing its icon large without a preview, over its icon
32
- * and name. The screen cast and screenshot pickers share it.
32
+ * and name, for the screen cast picker.
33
33
  */
34
34
  export const SourceTiles = ({
35
35
  desk,