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

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-c4fe91522852",
6
+ "@domicile-desktop/system-apps": "0.0.0-alpha-c4fe91522852",
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-c4fe91522852"
118
118
  }
@@ -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,