@domicile-desktop/component-library 0.0.0-alpha-c7034d321c61 → 0.0.0-alpha-8f092ec11368

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-c7034d321c61",
6
- "@domicile-desktop/system-apps": "0.0.0-alpha-c7034d321c61",
5
+ "@domicile-desktop/sdk": "0.0.0-alpha-8f092ec11368",
6
+ "@domicile-desktop/system-apps": "0.0.0-alpha-8f092ec11368",
7
7
  "@phosphor-icons/react": "^2.1.10"
8
8
  },
9
9
  "description": "React components and the Panda CSS design system that Domicile's shells are built from.",
@@ -106,5 +106,5 @@
106
106
  "test:unit": "bun test"
107
107
  },
108
108
  "type": "module",
109
- "version": "0.0.0-alpha-c7034d321c61"
109
+ "version": "0.0.0-alpha-8f092ec11368"
110
110
  }
@@ -23,32 +23,33 @@ import { ImageIcon } from "@phosphor-icons/react/dist/ssr/Image";
23
23
  import { LockIcon } from "@phosphor-icons/react/dist/ssr/Lock";
24
24
  import { MagnifyingGlassIcon } from "@phosphor-icons/react/dist/ssr/MagnifyingGlass";
25
25
  import { MusicNotesIcon } from "@phosphor-icons/react/dist/ssr/MusicNotes";
26
- import type { KeyboardEvent, ReactNode, Ref } from "react";
26
+ import { WarningIcon } from "@phosphor-icons/react/dist/ssr/Warning";
27
+ import type { FocusEvent, KeyboardEvent, ReactNode, Ref } from "react";
27
28
  import { useId, useState } from "react";
28
29
 
29
30
  import { css } from "../../styled-system/css";
30
31
  import { flex, grid, hstack, vstack } from "../../styled-system/patterns";
32
+ import { focusOnAttach } from "../_control/focusOnAttach";
33
+ import { useStableRef } from "../_control/useStableRef";
31
34
  import { Button } from "../Button/Button";
32
- import { Field } from "../Field/Field";
33
35
  import { Input } from "../Input/Input";
34
36
  import { Kbd } from "../Kbd/Kbd";
35
37
  import { highlightIn, keepInView, stepOf, steppedTo } from "../list-walk";
36
38
  import { Select } from "../Select/Select";
39
+ import { Clash, clashOf } from "./clash";
37
40
  import { FileKind, fileKindOf, kindLabel } from "./file-kind";
38
41
  import type { FileFilter, FileRequest } from "./file-request";
39
42
  import { ChooserMode } from "./file-request";
40
43
  import { pathIn } from "./path-in";
41
44
  import type { Row } from "./rows";
42
45
  import { RowKind, rowsIn } from "./rows";
46
+ import { stemEnd } from "./stem";
43
47
  import { ListingState, useListing } from "./useListing";
44
48
  import { crumbsOf, parentOf, shownPath, walked } from "./walk-path";
45
49
 
46
50
  /** The filter box's accessible name. */
47
51
  const PROMPT = "Filter or go to a path";
48
52
 
49
- /** The filter box's placeholder, listing the path shortcuts. */
50
- const PLACEHOLDER = "Filter, or type a path: / root ~ home .. up";
51
-
52
53
  /** The icon size for rows and the filter box. */
53
54
  const ICON_SIZE = 16;
54
55
 
@@ -69,7 +70,10 @@ const STANDARD_PLACES: readonly (readonly [string, typeof FolderIcon])[] = [
69
70
  ];
70
71
 
71
72
  type Props = {
72
- /** The filter box, for the caller to focus while the picker is open. */
73
+ /**
74
+ * The box that has the keyboard: the filter, or the name when saving. The
75
+ * caller focuses it while the picker is open.
76
+ */
73
77
  ref?: Ref<HTMLInputElement> | undefined;
74
78
  /** What to pick and how to answer. */
75
79
  request: FileRequest;
@@ -82,10 +86,13 @@ type Place = { glyph: typeof FolderIcon; label: string; path: string };
82
86
  * A file picker drawn over its positioned parent only, so the rest of the
83
87
  * screen stays usable.
84
88
  *
85
- * Starts in `currentFolder`, or the home, and lists one directory at a time; nothing is
86
- * indexed. Typing filters; a `/` walks the path like a shell (see `walked`).
87
- * A click or arrow key selects a file; Enter, a double click or the button
88
- * chooses it. Clicking a directory enters it.
89
+ * Starts in `currentFolder`, or the home, and lists one directory at a time;
90
+ * nothing is indexed. One box has the keyboard: the filter, or the file name
91
+ * when saving. A `/` in it walks the path like a shell (see `walked`).
92
+ * Opening, a click or arrow key selects a file; Enter, a double click or the
93
+ * button chooses it. Saving, the arrows pick a folder to enter and Enter
94
+ * saves, asking first before replacing a file. Clicking a directory enters
95
+ * it.
89
96
  *
90
97
  * Keys: arrows move, Enter opens, Backspace in an empty box goes up,
91
98
  * Ctrl+Enter confirms, Escape cancels, and Tab marks a file in multi-select,
@@ -94,16 +101,21 @@ type Place = { glyph: typeof FolderIcon; label: string; path: string };
94
101
  export const FilePicker = ({ ref, request }: Props) => {
95
102
  const titleId = useId();
96
103
  const listId = useId();
104
+ const replaceTitleId = useId();
105
+ const replaceNoteId = useId();
106
+ const [box, setBox] = useStableRef(ref);
97
107
  const [directory, setDirectory] = useState(
98
108
  request.currentFolder ?? request.home,
99
109
  );
100
110
  const [query, setQuery] = useState("");
101
111
  // The highlighted row, or `undefined` for the default: the first row below
102
- // `..`.
112
+ // `..`, or none when saving, so Enter saves.
103
113
  const [stepped, setStepped] = useState<number | undefined>(undefined);
104
114
  // Marked file paths for multi-select, in marking order.
105
115
  const [marks, setMarks] = useState<readonly string[]>([]);
106
116
  const [name, setName] = useState(request.suggestedName);
117
+ // Whether the user is asked to confirm replacing a file.
118
+ const [replacing, setReplacing] = useState(false);
107
119
  const [filter, setFilter] = useState<FileFilter | undefined>(
108
120
  request.filters?.[request.currentFilter ?? 0],
109
121
  );
@@ -113,26 +125,34 @@ export const FilePicker = ({ ref, request }: Props) => {
113
125
  request.choose(paths, filterIndex);
114
126
  };
115
127
 
128
+ const saving = request.mode === ChooserMode.Save;
116
129
  const listing = useListing(request.list, directory);
130
+ const entries = listing.state === ListingState.Listed ? listing.entries : [];
117
131
  // Lists the home directory to find which standard folders exist.
118
132
  const homeListing = useListing(request.list, request.home);
133
+ const clash = saving ? clashOf(entries, name) : Clash.None;
119
134
  const rows = rowsIn({
120
135
  accept: filter === undefined ? request.accept : filter.extensions,
121
136
  directory,
122
- entries: listing.state === ListingState.Listed ? listing.entries : [],
137
+ entries,
123
138
  filter: query,
124
139
  mode: request.mode,
125
140
  });
126
- const highlighted = highlightIn(rows.length, stepped ?? firstOf(rows));
141
+ const highlighted =
142
+ saving && stepped === undefined
143
+ ? undefined
144
+ : highlightIn(rows.length, stepped ?? firstOf(rows));
127
145
  const current = highlighted === undefined ? undefined : rows[highlighted];
128
146
  const multiple = request.mode === ChooserMode.OpenMultiple;
129
147
  const answer = answerOf({
148
+ clash,
130
149
  current,
131
150
  directory,
132
151
  marks,
133
152
  mode: request.mode,
134
153
  name,
135
154
  });
155
+ const asking = replacing && clash === Clash.File;
136
156
 
137
157
  const go = (to: string) => {
138
158
  setDirectory(to);
@@ -140,12 +160,23 @@ export const FilePicker = ({ ref, request }: Props) => {
140
160
  setStepped(undefined);
141
161
  };
142
162
 
163
+ // Chooses the answer, asking first if it replaces a file.
143
164
  const confirm = () => {
144
165
  if (answer !== undefined) {
145
- choose(answer);
166
+ if (clash === Clash.File) {
167
+ setReplacing(true);
168
+ } else {
169
+ choose(answer);
170
+ }
146
171
  }
147
172
  };
148
173
 
174
+ // Back from the replace question to the name.
175
+ const reconsider = () => {
176
+ setReplacing(false);
177
+ box.current?.focus();
178
+ };
179
+
149
180
  // Enter or double click: enters a directory, or chooses a file (or names
150
181
  // it, when saving).
151
182
  const open = (row: Row) => {
@@ -175,6 +206,7 @@ export const FilePicker = ({ ref, request }: Props) => {
175
206
  }
176
207
  case ChooserMode.Save: {
177
208
  setName(row.name);
209
+ setStepped(undefined);
178
210
  break;
179
211
  }
180
212
  }
@@ -184,25 +216,19 @@ export const FilePicker = ({ ref, request }: Props) => {
184
216
  // it as the mode requires.
185
217
  const pick = (row: Row, at: number) => {
186
218
  if (row.kind === RowKind.File) {
187
- setStepped(at);
188
219
  if (multiple) {
220
+ setStepped(at);
189
221
  setMarks(toggled(marks, row.path));
190
- } else if (request.mode === ChooserMode.Save) {
191
- setName(row.name);
222
+ } else if (saving) {
223
+ openFile(row);
224
+ } else {
225
+ setStepped(at);
192
226
  }
193
227
  } else {
194
228
  go(row.path);
195
229
  }
196
230
  };
197
231
 
198
- // Ctrl+Enter in either field confirms the current selection.
199
- const confirmOnChord = (event: KeyboardEvent) => {
200
- if (event.key === "Enter" && (event.ctrlKey || event.metaKey)) {
201
- event.preventDefault();
202
- confirm();
203
- }
204
- };
205
-
206
232
  return (
207
233
  // biome-ignore lint/a11y/noNoninteractiveElementInteractions: Escape closes the dialog wherever focus is inside it
208
234
  <dialog
@@ -211,28 +237,26 @@ export const FilePicker = ({ ref, request }: Props) => {
211
237
  onKeyDown={(event) => {
212
238
  if (event.key === "Escape") {
213
239
  event.preventDefault();
214
- request.cancel();
240
+ if (asking) {
241
+ reconsider();
242
+ } else {
243
+ request.cancel();
244
+ }
215
245
  }
216
246
  }}
217
247
  open
218
248
  >
219
249
  <div className={panelStyles}>
250
+ <header className={headerStyles}>
251
+ <span className={modeGlyphStyles}>
252
+ <ModeGlyph mode={request.mode} />
253
+ </span>
254
+ <h2 className={titleStyles} id={titleId}>
255
+ {request.title ?? titleOf(request.mode)}
256
+ </h2>
257
+ </header>
220
258
  <aside className={sidebarStyles}>
221
- <div className={brandStyles}>
222
- <span className={brandTileStyles}>
223
- <ModeGlyph mode={request.mode} />
224
- </span>
225
- <div className={brandTextStyles}>
226
- <h2 className={titleStyles} id={titleId}>
227
- {request.title ?? titleOf(request.mode)}
228
- </h2>
229
- <span className={subtitleStyles}>{subtitleOf(request.mode)}</span>
230
- </div>
231
- </div>
232
259
  <nav aria-label="Places" className={placesStyles}>
233
- <span aria-hidden className={sectionLabelStyles}>
234
- Places
235
- </span>
236
260
  {placesOf(
237
261
  request.home,
238
262
  homeListing.state === ListingState.Listed
@@ -287,7 +311,7 @@ export const FilePicker = ({ ref, request }: Props) => {
287
311
  onMouseDown={(event) => {
288
312
  event.preventDefault();
289
313
  }}
290
- size="xs"
314
+ size="sm"
291
315
  variant="ghost"
292
316
  >
293
317
  {crumb.label}
@@ -301,7 +325,7 @@ export const FilePicker = ({ ref, request }: Props) => {
301
325
  }
302
326
  aria-controls={listId}
303
327
  aria-expanded
304
- aria-label={PROMPT}
328
+ aria-label={saving ? "Name" : PROMPT}
305
329
  onChange={(event) => {
306
330
  const to = walked({
307
331
  directory,
@@ -309,9 +333,14 @@ export const FilePicker = ({ ref, request }: Props) => {
309
333
  typed: event.target.value,
310
334
  });
311
335
  setDirectory(to.directory);
312
- setQuery(to.filter);
336
+ if (saving) {
337
+ setName(to.filter);
338
+ } else {
339
+ setQuery(to.filter);
340
+ }
313
341
  setStepped(undefined);
314
342
  }}
343
+ onFocus={saving ? selectStem : undefined}
315
344
  onKeyDown={(event) => {
316
345
  const step = stepOf(event);
317
346
  const up = parentOf(directory);
@@ -341,22 +370,30 @@ export const FilePicker = ({ ref, request }: Props) => {
341
370
  }
342
371
  } else if (
343
372
  event.key === "Backspace" &&
344
- query === "" &&
373
+ event.currentTarget.value === "" &&
345
374
  up !== undefined
346
375
  ) {
347
376
  event.preventDefault();
348
377
  go(up);
349
- } else {
350
- confirmOnChord(event);
378
+ } else if (isConfirmChord(event)) {
379
+ event.preventDefault();
380
+ confirm();
351
381
  }
352
382
  }}
353
- placeholder={PLACEHOLDER}
354
- prefixIcon={<MagnifyingGlassIcon size={ICON_SIZE} />}
355
- ref={ref}
383
+ placeholder={saving ? "Name the file" : "Filter, or type a path"}
384
+ prefixIcon={
385
+ saving ? (
386
+ <NameGlyph name={name} />
387
+ ) : (
388
+ <MagnifyingGlassIcon size={ICON_SIZE} />
389
+ )
390
+ }
391
+ ref={setBox}
356
392
  role="combobox"
393
+ size={saving ? "lg" : "md"}
357
394
  // File names are not prose.
358
395
  spellCheck={false}
359
- value={query}
396
+ value={saving ? name : query}
360
397
  />
361
398
  <div className={resultsStyles}>
362
399
  <div aria-hidden className={columnsStyles}>
@@ -383,6 +420,13 @@ export const FilePicker = ({ ref, request }: Props) => {
383
420
  className={rowStyles}
384
421
  data-highlighted={at === highlighted ? "" : undefined}
385
422
  data-marked={marked ? "" : undefined}
423
+ data-replaced={
424
+ clash === Clash.File &&
425
+ row.kind === RowKind.File &&
426
+ row.name === name
427
+ ? ""
428
+ : undefined
429
+ }
386
430
  data-tone={toneOf(row)}
387
431
  id={rowId(listId, at)}
388
432
  key={`${toneOf(row)}:${row.path}`}
@@ -394,7 +438,7 @@ export const FilePicker = ({ ref, request }: Props) => {
394
438
  openFile(row);
395
439
  }
396
440
  }}
397
- // Keeps focus in the filter box.
441
+ // Keeps focus in the box.
398
442
  onMouseDown={(event) => {
399
443
  event.preventDefault();
400
444
  }}
@@ -421,66 +465,88 @@ export const FilePicker = ({ ref, request }: Props) => {
421
465
  shown={rows.some(({ kind }) => kind !== RowKind.Parent)}
422
466
  />
423
467
  </div>
424
- {request.mode === ChooserMode.Save && (
425
- <Field label="Name">
426
- <Input
427
- onChange={(event) => {
428
- setName(event.target.value);
429
- }}
430
- onKeyDown={(event) => {
431
- if (event.key === "Enter") {
432
- event.preventDefault();
433
- confirm();
434
- }
435
- }}
436
- spellCheck={false}
437
- value={name}
438
- />
439
- </Field>
440
- )}
441
468
  </section>
442
- <footer className={footerStyles}>
443
- <output
444
- aria-label="Selection"
445
- className={selectionStyles}
446
- data-empty={answer === undefined ? "" : undefined}
469
+ {asking && answer !== undefined ? (
470
+ <footer
471
+ aria-describedby={replaceNoteId}
472
+ aria-labelledby={replaceTitleId}
473
+ className={replaceStyles}
474
+ role="alertdialog"
447
475
  >
448
- <Selection
449
- answer={answer}
450
- home={request.home}
451
- marks={marks}
452
- mode={request.mode}
453
- />
454
- </output>
455
- <div className={actionsStyles}>
456
- {request.controls}
457
- {request.filters !== undefined && filter !== undefined && (
458
- <Select
459
- aria-label="File type"
460
- onValueChange={(picked) => {
461
- if (picked !== null) {
462
- setFilter(picked);
463
- }
476
+ <span className={replaceGlyphStyles}>
477
+ <WarningIcon size={20} weight="fill" />
478
+ </span>
479
+ <div className={replaceTextStyles}>
480
+ <strong className={replaceTitleStyles} id={replaceTitleId}>
481
+ {`Replace “${name}”?`}
482
+ </strong>
483
+ <span className={replaceNoteStyles} id={replaceNoteId}>
484
+ {`It is already in ${shownPath(directory, request.home)}. Its contents will be lost.`}
485
+ </span>
486
+ </div>
487
+ <div className={actionsStyles}>
488
+ <Button onClick={reconsider} variant="ghost">
489
+ Go back
490
+ </Button>
491
+ <Button
492
+ onClick={() => {
493
+ choose(answer);
464
494
  }}
465
- options={request.filters.map((group) => ({
466
- label: group.name,
467
- value: group,
468
- }))}
469
- value={filter}
470
- />
495
+ ref={focusOnAttach}
496
+ variant="danger"
497
+ >
498
+ Replace
499
+ </Button>
500
+ </div>
501
+ </footer>
502
+ ) : (
503
+ <footer className={footerStyles}>
504
+ {saving ? (
505
+ <ClashHint clash={clash} />
506
+ ) : (
507
+ <output
508
+ aria-label="Selection"
509
+ className={selectionStyles}
510
+ data-empty={answer === undefined ? "" : undefined}
511
+ >
512
+ <Selection
513
+ answer={answer}
514
+ home={request.home}
515
+ marks={marks}
516
+ mode={request.mode}
517
+ />
518
+ </output>
471
519
  )}
472
- <Button onClick={request.cancel} variant="ghost">
473
- Cancel
474
- </Button>
475
- <Button
476
- disabled={answer === undefined}
477
- onClick={confirm}
478
- variant="accent"
479
- >
480
- {request.acceptLabel ?? confirmOf(request.mode)}
481
- </Button>
482
- </div>
483
- </footer>
520
+ <div className={actionsStyles}>
521
+ {request.controls}
522
+ {request.filters !== undefined && filter !== undefined && (
523
+ <Select
524
+ aria-label="File type"
525
+ onValueChange={(picked) => {
526
+ if (picked !== null) {
527
+ setFilter(picked);
528
+ }
529
+ }}
530
+ options={request.filters.map((group) => ({
531
+ label: group.name,
532
+ value: group,
533
+ }))}
534
+ value={filter}
535
+ />
536
+ )}
537
+ <Button onClick={request.cancel} variant="ghost">
538
+ Cancel
539
+ </Button>
540
+ <Button
541
+ disabled={answer === undefined}
542
+ onClick={confirm}
543
+ variant="accent"
544
+ >
545
+ {request.acceptLabel ?? confirmOf(request.mode)}
546
+ </Button>
547
+ </div>
548
+ </footer>
549
+ )}
484
550
  </div>
485
551
  </dialog>
486
552
  );
@@ -509,12 +575,12 @@ const scrimStyles = css({
509
575
  transition: "opacity {durations.normal} {easings.out}",
510
576
  });
511
577
 
512
- // The library's glass surface (see `ModalDialog`). Two columns, sidebar and
513
- // folder, with the footer across both.
578
+ // The library's glass surface (see `ModalDialog`). A header and footer across
579
+ // two columns, places and folder.
514
580
  const panelStyles = grid({
515
581
  _before: {
516
582
  background:
517
- "linear-gradient(90deg, transparent, color-mix(in oklab, {colors.foreground} 45%, transparent), transparent)",
583
+ "linear-gradient(90deg, transparent, color-mix(in oklab, {colors.foreground} 40%, transparent), transparent)",
518
584
  blockSize: "1px",
519
585
  content: '""',
520
586
  insetBlockStart: 0,
@@ -524,78 +590,58 @@ const panelStyles = grid({
524
590
  },
525
591
  _starting: {
526
592
  opacity: 0,
527
- transform: "translateY({spacing.4}) scale(0.96)",
593
+ transform: "translateY({spacing.3}) scale(0.96)",
528
594
  },
529
595
  "& :has(> [data-control])": {
530
596
  backgroundColor:
531
597
  "color-mix(in oklab, {colors.background} 45%, transparent)",
532
598
  },
533
- backdropFilter: "blur({spacing.6}) saturate(180%)",
599
+ backdropFilter: "blur({spacing.5}) saturate(180%)",
534
600
  // More opaque than `ModalDialog`'s glass, for readable rows over anything.
535
601
  backgroundColor: "color-mix(in oklab, {colors.card} 86%, transparent)",
536
- blockSize: "min(86vh, {spacing.180})",
537
- border: "1px solid color-mix(in oklab, {colors.foreground} 14%, transparent)",
538
- borderRadius: "2xl",
602
+ blockSize: "min(86vh, {spacing.168})",
603
+ border: "1px solid color-mix(in oklab, {colors.foreground} 16%, transparent)",
604
+ borderRadius: "xl",
539
605
  boxShadow: "modal",
540
606
  gap: 0,
541
- gridTemplateColumns: "{spacing.60} minmax(0, 1fr)",
542
- gridTemplateRows: "minmax(0, 1fr) auto",
607
+ gridTemplateColumns: "{spacing.52} minmax(0, 1fr)",
608
+ gridTemplateRows: "auto minmax(0, 1fr) auto",
543
609
  inlineSize: "100%",
544
- maxInlineSize: 256,
610
+ maxInlineSize: 240,
545
611
  overflow: "hidden",
546
612
  position: "relative",
547
613
  transition:
548
- "opacity {durations.normal} {easings.out}, transform {durations.slow} {easings.outBack}",
614
+ "opacity {durations.normal} {easings.out}, transform {durations.normal} {easings.out}",
549
615
  });
550
616
 
551
- const sidebarStyles = flex({
552
- backgroundColor: "color-mix(in oklab, {colors.foreground} 4%, transparent)",
553
- backgroundImage:
554
- "radial-gradient(120% 60% at 0% 0%, color-mix(in oklab, {colors.accent} 14%, transparent), transparent 70%)",
555
- borderInlineEnd:
556
- "1px solid color-mix(in oklab, {colors.foreground} 8%, transparent)",
557
- direction: "column",
558
- gap: 6,
559
- minBlockSize: 0,
560
- overflowY: "auto",
561
- paddingBlock: 5,
562
- paddingInline: 4,
563
- });
564
-
565
- const brandStyles = hstack({
566
- gap: 3,
567
- });
568
-
569
- const brandTileStyles = css({
570
- backgroundImage:
571
- "linear-gradient(160deg, color-mix(in oklab, {colors.accent} 80%, {colors.foreground}), {colors.accent} 60%, color-mix(in oklab, {colors.accent} 70%, {colors.background}))",
572
- blockSize: 11,
573
- borderRadius: "lg",
574
- boxShadow:
575
- "0 {spacing.2} {spacing.5} color-mix(in oklab, {colors.accent} 40%, transparent), inset 0 1px 0 color-mix(in oklab, {colors.foreground} 35%, transparent)",
576
- color: "background",
577
- display: "grid",
578
- flexShrink: 0,
579
- inlineSize: 11,
580
- placeItems: "center",
617
+ const headerStyles = hstack({
618
+ borderBlockEnd: "1px solid {colors.border}",
619
+ gap: 2.5,
620
+ gridColumn: "1 / -1",
621
+ paddingBlock: 3.5,
622
+ paddingInline: 5,
581
623
  });
582
624
 
583
- const brandTextStyles = flex({
584
- direction: "column",
585
- minInlineSize: 0,
625
+ const modeGlyphStyles = css({
626
+ color: "accent",
627
+ display: "inline-flex",
586
628
  });
587
629
 
588
630
  const titleStyles = css({
589
631
  fontSize: "md",
590
632
  fontWeight: "semibold",
591
- letterSpacing: "tight",
592
633
  lineHeight: "tight",
593
634
  margin: 0,
594
635
  });
595
636
 
596
- const subtitleStyles = css({
597
- color: "muted",
598
- fontSize: "xs",
637
+ const sidebarStyles = flex({
638
+ backgroundColor: "color-mix(in oklab, {colors.foreground} 3%, transparent)",
639
+ borderInlineEnd: "1px solid {colors.border}",
640
+ direction: "column",
641
+ gap: 6,
642
+ minBlockSize: 0,
643
+ overflowY: "auto",
644
+ padding: 3,
599
645
  });
600
646
 
601
647
  const placesStyles = flex({
@@ -605,7 +651,7 @@ const placesStyles = flex({
605
651
  justifyContent: "flex-start",
606
652
  },
607
653
  "& > button[aria-current=location]": {
608
- backgroundColor: "color-mix(in oklab, {colors.accent} 18%, transparent)",
654
+ backgroundColor: "color-mix(in oklab, {colors.accent} 16%, transparent)",
609
655
  color: "accent",
610
656
  fontWeight: "semibold",
611
657
  },
@@ -613,35 +659,20 @@ const placesStyles = flex({
613
659
  gap: 0.5,
614
660
  });
615
661
 
616
- const sectionLabelStyles = css({
617
- color: "textTertiary",
618
- fontSize: "2xs",
619
- fontWeight: "semibold",
620
- letterSpacing: "widest",
621
- marginBlockEnd: 1,
622
- paddingInline: 2,
623
- textTransform: "uppercase",
624
- });
625
-
626
662
  const mainStyles = flex({
627
663
  direction: "column",
628
664
  gap: 3,
629
665
  minBlockSize: 0,
630
666
  minInlineSize: 0,
631
- padding: 5,
667
+ padding: 4,
632
668
  });
633
669
 
634
670
  const pathBarStyles = flex({
635
671
  "& [aria-current=location]": { color: "foreground", fontWeight: "semibold" },
636
672
  alignItems: "center",
637
- alignSelf: "flex-start",
638
- backgroundColor: "color-mix(in oklab, {colors.foreground} 5%, transparent)",
639
- border: "1px solid color-mix(in oklab, {colors.foreground} 8%, transparent)",
640
- borderRadius: "full",
641
673
  color: "muted",
642
674
  flexWrap: "wrap",
643
- maxInlineSize: "100%",
644
- paddingInline: 1,
675
+ marginInlineStart: -2,
645
676
  rowGap: 0.5,
646
677
  });
647
678
 
@@ -657,8 +688,8 @@ const crumbSeparatorStyles = css({
657
688
 
658
689
  const resultsStyles = css({
659
690
  backgroundColor: "color-mix(in oklab, {colors.background} 30%, transparent)",
660
- border: "1px solid color-mix(in oklab, {colors.foreground} 7%, transparent)",
661
- borderRadius: "xl",
691
+ border: "1px solid {colors.border}",
692
+ borderRadius: "lg",
662
693
  flex: "1 1 auto",
663
694
  minBlockSize: 40,
664
695
  overflowY: "auto",
@@ -697,6 +728,9 @@ const listStyles = flex({
697
728
  });
698
729
 
699
730
  const rowStyles = grid({
731
+ _hover: {
732
+ backgroundColor: "color-mix(in oklab, {colors.foreground} 5%, transparent)",
733
+ },
700
734
  // The tile color shows the file kind. Tones that blend two hues mix in
701
735
  // OKLCH, since OKLab mixing passes through gray.
702
736
  "& [data-row-tile]": {
@@ -718,6 +752,10 @@ const rowStyles = grid({
718
752
  borderColor: "accent",
719
753
  color: "background",
720
754
  },
755
+ // The file a save would replace.
756
+ "&[data-replaced]": {
757
+ backgroundColor: "color-mix(in oklab, {colors.warning} 14%, transparent)",
758
+ },
721
759
  "&[data-tone=archive]": { "--tone": "{colors.warning}" },
722
760
  "&[data-tone=audio]": {
723
761
  "--tone": "color-mix(in oklch, {colors.warning} 50%, {colors.danger})",
@@ -805,15 +843,59 @@ const placeholderNoteStyles = css({
805
843
  fontSize: "sm",
806
844
  });
807
845
 
846
+ // Both footers share a height, so the list keeps its size between them.
808
847
  const footerStyles = hstack({
809
- backgroundColor: "color-mix(in oklab, {colors.foreground} 3%, transparent)",
810
- borderBlockStart:
811
- "1px solid color-mix(in oklab, {colors.foreground} 8%, transparent)",
848
+ borderBlockStart: "1px solid {colors.border}",
812
849
  gap: 4,
813
850
  gridColumn: "1 / -1",
814
851
  justifyContent: "space-between",
815
- paddingBlock: 3.5,
816
- paddingInline: 5,
852
+ minBlockSize: 16,
853
+ paddingBlock: 3,
854
+ paddingInline: 4,
855
+ });
856
+
857
+ // The footer while asking whether to replace a file.
858
+ const replaceStyles = hstack({
859
+ backgroundColor: "color-mix(in oklab, {colors.warning} 12%, transparent)",
860
+ borderBlockStart:
861
+ "1px solid color-mix(in oklab, {colors.warning} 40%, transparent)",
862
+ gap: 3,
863
+ gridColumn: "1 / -1",
864
+ minBlockSize: 16,
865
+ paddingBlock: 3,
866
+ paddingInline: 4,
867
+ });
868
+
869
+ const replaceGlyphStyles = css({
870
+ color: "warning",
871
+ display: "inline-flex",
872
+ flexShrink: 0,
873
+ });
874
+
875
+ const replaceTextStyles = flex({
876
+ direction: "column",
877
+ flex: "1 1 auto",
878
+ minInlineSize: 0,
879
+ });
880
+
881
+ const replaceTitleStyles = css({
882
+ fontSize: "sm",
883
+ fontWeight: "semibold",
884
+ overflow: "hidden",
885
+ textOverflow: "ellipsis",
886
+ whiteSpace: "nowrap",
887
+ });
888
+
889
+ const replaceNoteStyles = css({
890
+ color: "muted",
891
+ fontSize: "xs",
892
+ });
893
+
894
+ const clashHintStyles = hstack({
895
+ "&[data-clash=file]": { color: "warning" },
896
+ color: "muted",
897
+ fontSize: "sm",
898
+ gap: 2,
817
899
  });
818
900
 
819
901
  // Drawn as a chip only when there is a selection.
@@ -821,14 +903,11 @@ const selectionStyles = hstack({
821
903
  "&[data-empty]": {
822
904
  backgroundColor: "transparent",
823
905
  borderColor: "transparent",
824
- boxShadow: "none",
825
906
  paddingInline: 0,
826
907
  },
827
908
  backgroundColor: "color-mix(in oklab, {colors.accent} 12%, transparent)",
828
909
  border: "1px solid color-mix(in oklab, {colors.accent} 30%, transparent)",
829
910
  borderRadius: "full",
830
- boxShadow:
831
- "0 0 0 {spacing.1} color-mix(in oklab, {colors.accent} 6%, transparent)",
832
911
  flex: "0 1 auto",
833
912
  fontSize: "sm",
834
913
  gap: 2,
@@ -836,7 +915,7 @@ const selectionStyles = hstack({
836
915
  paddingBlock: 1,
837
916
  paddingInline: 3,
838
917
  transition:
839
- "background-color {durations.fast} {easings.out}, border-color {durations.fast} {easings.out}, box-shadow {durations.fast} {easings.out}",
918
+ "background-color {durations.fast} {easings.out}, border-color {durations.fast} {easings.out}",
840
919
  });
841
920
 
842
921
  const selectionGlyphStyles = css({
@@ -885,20 +964,21 @@ const actionsStyles = hstack({
885
964
  gap: 2,
886
965
  });
887
966
 
888
- const keysStyles = flex({
967
+ // Two columns, keys and what they do, aligned across every row.
968
+ const keysStyles = grid({
969
+ alignItems: "center",
889
970
  color: "muted",
890
- direction: "column",
971
+ columnGap: 3,
891
972
  fontSize: "xs",
892
- gap: 1.5,
973
+ gridTemplateColumns: "max-content minmax(0, 1fr)",
893
974
  margin: 0,
894
975
  marginBlockStart: "auto",
895
976
  paddingInline: 2,
977
+ rowGap: 2,
896
978
  });
897
979
 
898
- const keyStyles = grid({
899
- alignItems: "center",
900
- columnGap: 3,
901
- gridTemplateColumns: "{spacing.16} minmax(0, 1fr)",
980
+ const keyStyles = css({
981
+ display: "contents",
902
982
  });
903
983
 
904
984
  const keyCapsStyles = hstack({
@@ -913,16 +993,16 @@ const keyWhatStyles = css({
913
993
  const ModeGlyph = ({ mode }: { mode: ChooserMode }) => {
914
994
  switch (mode) {
915
995
  case ChooserMode.Open: {
916
- return <FileArrowUpIcon size={22} weight="fill" />;
996
+ return <FileArrowUpIcon size={20} weight="fill" />;
917
997
  }
918
998
  case ChooserMode.OpenMultiple: {
919
- return <FilesIcon size={22} weight="fill" />;
999
+ return <FilesIcon size={20} weight="fill" />;
920
1000
  }
921
1001
  case ChooserMode.OpenFolder: {
922
- return <FolderOpenIcon size={22} weight="fill" />;
1002
+ return <FolderOpenIcon size={20} weight="fill" />;
923
1003
  }
924
1004
  case ChooserMode.Save: {
925
- return <FloppyDiskIcon size={22} weight="fill" />;
1005
+ return <FloppyDiskIcon size={20} weight="fill" />;
926
1006
  }
927
1007
  }
928
1008
  };
@@ -935,6 +1015,36 @@ const CrumbGlyph = ({ label }: { label: string }) =>
935
1015
  <HardDrivesIcon size={14} weight="fill" />
936
1016
  );
937
1017
 
1018
+ /** The name box's icon: the kind of file the name makes. */
1019
+ const NameGlyph = ({ name }: { name: string }) => {
1020
+ const Glyph = glyphOf(fileKindOf(name));
1021
+ return <Glyph size={ICON_SIZE} weight="duotone" />;
1022
+ };
1023
+
1024
+ /** What saving under the name would hit. Holds its place when empty. */
1025
+ const ClashHint = ({ clash }: { clash: Clash }) => {
1026
+ switch (clash) {
1027
+ case Clash.None: {
1028
+ return <span />;
1029
+ }
1030
+ case Clash.File: {
1031
+ return (
1032
+ <span className={clashHintStyles} data-clash="file">
1033
+ <WarningIcon size={14} weight="fill" />
1034
+ Replaces the file with this name
1035
+ </span>
1036
+ );
1037
+ }
1038
+ case Clash.Folder: {
1039
+ return (
1040
+ <span className={clashHintStyles} data-clash="folder">
1041
+ <FolderIcon size={14} weight="fill" />A folder has this name
1042
+ </span>
1043
+ );
1044
+ }
1045
+ }
1046
+ };
1047
+
938
1048
  /**
939
1049
  * A row's icon: a check if marked, else its kind. Filled when highlighted,
940
1050
  * since an outline icon loses contrast on the highlight, as in the launcher.
@@ -1107,18 +1217,26 @@ const SelectedPath = ({ path }: { path: string }) => {
1107
1217
  };
1108
1218
 
1109
1219
  /** The key help at the foot of the sidebar. */
1110
- const Keys = ({ mode }: { mode: ChooserMode }) => (
1111
- <dl className={keysStyles}>
1112
- <Key keys={["↑", "↓"]} what="Move" />
1113
- <Key keys={["↵"]} what="Open" />
1114
- <Key keys={["⌫"]} what="Up a folder" />
1115
- {mode === ChooserMode.OpenMultiple && <Key keys={["tab"]} what="Mark" />}
1116
- {(mode === ChooserMode.OpenFolder || mode === ChooserMode.Save) && (
1117
- <Key keys={["ctrl", "↵"]} what={confirmOf(mode)} />
1118
- )}
1119
- <Key keys={["esc"]} what="Cancel" />
1120
- </dl>
1121
- );
1220
+ const Keys = ({ mode }: { mode: ChooserMode }) =>
1221
+ mode === ChooserMode.Save ? (
1222
+ <dl className={keysStyles}>
1223
+ <Key keys={["↑", "↓"]} what="Pick a folder" />
1224
+ <Key keys={["↵"]} what="Open it, or save" />
1225
+ <Key keys={["/"]} what="Type a path" />
1226
+ <Key keys={["esc"]} what="Cancel" />
1227
+ </dl>
1228
+ ) : (
1229
+ <dl className={keysStyles}>
1230
+ <Key keys={["↑", "↓"]} what="Move" />
1231
+ <Key keys={["↵"]} what="Open" />
1232
+ <Key keys={["⌫"]} what="Up a folder" />
1233
+ {mode === ChooserMode.OpenMultiple && <Key keys={["tab"]} what="Mark" />}
1234
+ {mode === ChooserMode.OpenFolder && (
1235
+ <Key keys={["ctrl", "↵"]} what={confirmOf(mode)} />
1236
+ )}
1237
+ <Key keys={["esc"]} what="Cancel" />
1238
+ </dl>
1239
+ );
1122
1240
 
1123
1241
  const Key = ({ keys, what }: { keys: readonly string[]; what: string }) => (
1124
1242
  <div className={keyStyles}>
@@ -1150,15 +1268,18 @@ const firstOf = (rows: readonly Row[]): number =>
1150
1268
  /**
1151
1269
  * The paths the picker would return now, or `undefined` if nothing valid is
1152
1270
  * selected. A folder request returns the current directory; multi-select
1153
- * returns the marks, or the highlighted file if none.
1271
+ * returns the marks, or the highlighted file if none; a save returns the name
1272
+ * in the current directory, unless a folder has it.
1154
1273
  */
1155
1274
  const answerOf = ({
1275
+ clash,
1156
1276
  current,
1157
1277
  directory,
1158
1278
  marks,
1159
1279
  mode,
1160
1280
  name,
1161
1281
  }: {
1282
+ clash: Clash;
1162
1283
  current: Row | undefined;
1163
1284
  directory: string;
1164
1285
  marks: readonly string[];
@@ -1177,7 +1298,9 @@ const answerOf = ({
1177
1298
  return [directory];
1178
1299
  }
1179
1300
  case ChooserMode.Save: {
1180
- return name === "" ? undefined : [pathIn(directory, name)];
1301
+ return name === "" || clash === Clash.Folder
1302
+ ? undefined
1303
+ : [pathIn(directory, name)];
1181
1304
  }
1182
1305
  }
1183
1306
  };
@@ -1293,23 +1416,6 @@ const titleOf = (mode: ChooserMode): string => {
1293
1416
  }
1294
1417
  };
1295
1418
 
1296
- const subtitleOf = (mode: ChooserMode): string => {
1297
- switch (mode) {
1298
- case ChooserMode.Open: {
1299
- return "Pick a file";
1300
- }
1301
- case ChooserMode.OpenMultiple: {
1302
- return "Pick one or more";
1303
- }
1304
- case ChooserMode.OpenFolder: {
1305
- return "Pick a folder";
1306
- }
1307
- case ChooserMode.Save: {
1308
- return "Pick where it goes";
1309
- }
1310
- }
1311
- };
1312
-
1313
1419
  const confirmOf = (mode: ChooserMode): string => {
1314
1420
  switch (mode) {
1315
1421
  case ChooserMode.Open:
@@ -1328,3 +1434,12 @@ const confirmOf = (mode: ChooserMode): string => {
1328
1434
  /** A row's id, for `aria-activedescendant`. */
1329
1435
  const rowId = (listId: string, at: number): string =>
1330
1436
  `${listId}-${at.toString()}`;
1437
+
1438
+ /** Ctrl+Enter, or Cmd+Enter: confirms whatever is highlighted. */
1439
+ const isConfirmChord = (event: KeyboardEvent): boolean =>
1440
+ event.key === "Enter" && (event.ctrlKey || event.metaKey);
1441
+
1442
+ /** Selects the name up to its extension, so typing keeps the extension. */
1443
+ const selectStem = (event: FocusEvent<HTMLInputElement>) => {
1444
+ event.currentTarget.setSelectionRange(0, stemEnd(event.currentTarget.value));
1445
+ };
@@ -0,0 +1,17 @@
1
+ // What a save would overwrite.
2
+
3
+ /** What already has the name a save would use. */
4
+ export enum Clash {
5
+ None,
6
+ File,
7
+ Folder,
8
+ }
9
+
10
+ /** What in `entries` has `name`; subdirectory entries end in `/`. */
11
+ export const clashOf = (entries: readonly string[], name: string): Clash => {
12
+ if (entries.includes(name)) {
13
+ return Clash.File;
14
+ } else {
15
+ return entries.includes(`${name}/`) ? Clash.Folder : Clash.None;
16
+ }
17
+ };
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Where a file name's stem ends: before its last extension. A leading dot
3
+ * starts a hidden name, not an extension.
4
+ */
5
+ export const stemEnd = (name: string): number => {
6
+ const dot = name.lastIndexOf(".");
7
+ return dot > 0 ? dot : name.length;
8
+ };
@@ -9,6 +9,7 @@ import {
9
9
  } from "@domicile-desktop/sdk/portal";
10
10
  import { useState } from "react";
11
11
  import { css } from "../../styled-system/css";
12
+ import { focusOnAttach } from "../_control/focusOnAttach";
12
13
  import { FilePicker } from "../FilePicker/FilePicker";
13
14
  import { ChooserMode } from "../FilePicker/file-request";
14
15
  import { useScreenRegion } from "../Screen/DisplayProvider";
@@ -29,7 +30,8 @@ type Props = {
29
30
 
30
31
  /**
31
32
  * An application's file chooser: the `FilePicker` over `screen`, or the
32
- * whole page, with the application's choices beside its buttons.
33
+ * whole page, with the application's choices beside its buttons. Its box
34
+ * takes the keyboard on open.
33
35
  */
34
36
  export const FileChooserDialog = ({ answer, body, list, screen }: Props) => {
35
37
  const region = useScreenRegion(screen);
@@ -39,6 +41,7 @@ export const FileChooserDialog = ({ answer, body, list, screen }: Props) => {
39
41
  return (
40
42
  <div className={regionStyles} style={region}>
41
43
  <FilePicker
44
+ ref={focusOnAttach}
42
45
  request={{
43
46
  accept: [],
44
47
  acceptLabel: body.acceptLabel ?? acceptOf(body.mode),
@@ -0,0 +1,9 @@
1
+ /**
2
+ * A callback ref that focuses its element as it mounts. React passes `null`
3
+ * on detach, which needs nothing.
4
+ */
5
+ export const focusOnAttach = (element: HTMLElement | null) => {
6
+ if (element !== null) {
7
+ element.focus();
8
+ }
9
+ };