@domicile-desktop/component-library 0.0.0-alpha-7eb8011677fe → 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-
|
|
6
|
-
"@domicile-desktop/system-apps": "0.0.0-alpha-
|
|
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-
|
|
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
|
|
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
|
-
/**
|
|
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;
|
|
86
|
-
* indexed.
|
|
87
|
-
* A
|
|
88
|
-
*
|
|
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
|
|
137
|
+
entries,
|
|
123
138
|
filter: query,
|
|
124
139
|
mode: request.mode,
|
|
125
140
|
});
|
|
126
|
-
const highlighted =
|
|
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
|
-
|
|
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 (
|
|
191
|
-
|
|
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
|
-
|
|
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="
|
|
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
|
-
|
|
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
|
-
|
|
373
|
+
event.currentTarget.value === "" &&
|
|
345
374
|
up !== undefined
|
|
346
375
|
) {
|
|
347
376
|
event.preventDefault();
|
|
348
377
|
go(up);
|
|
349
|
-
} else {
|
|
350
|
-
|
|
378
|
+
} else if (isConfirmChord(event)) {
|
|
379
|
+
event.preventDefault();
|
|
380
|
+
confirm();
|
|
351
381
|
}
|
|
352
382
|
}}
|
|
353
|
-
placeholder={
|
|
354
|
-
prefixIcon={
|
|
355
|
-
|
|
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
|
|
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
|
-
|
|
443
|
-
<
|
|
444
|
-
aria-
|
|
445
|
-
|
|
446
|
-
|
|
469
|
+
{asking && answer !== undefined ? (
|
|
470
|
+
<footer
|
|
471
|
+
aria-describedby={replaceNoteId}
|
|
472
|
+
aria-labelledby={replaceTitleId}
|
|
473
|
+
className={replaceStyles}
|
|
474
|
+
role="alertdialog"
|
|
447
475
|
>
|
|
448
|
-
<
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
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
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
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
|
-
<
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
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`).
|
|
513
|
-
//
|
|
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}
|
|
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.
|
|
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.
|
|
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.
|
|
537
|
-
border: "1px solid color-mix(in oklab, {colors.foreground}
|
|
538
|
-
borderRadius: "
|
|
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.
|
|
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:
|
|
610
|
+
maxInlineSize: 240,
|
|
545
611
|
overflow: "hidden",
|
|
546
612
|
position: "relative",
|
|
547
613
|
transition:
|
|
548
|
-
"opacity {durations.normal} {easings.out}, transform {durations.
|
|
614
|
+
"opacity {durations.normal} {easings.out}, transform {durations.normal} {easings.out}",
|
|
549
615
|
});
|
|
550
616
|
|
|
551
|
-
const
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
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
|
|
584
|
-
|
|
585
|
-
|
|
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
|
|
597
|
-
|
|
598
|
-
|
|
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}
|
|
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:
|
|
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
|
-
|
|
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
|
|
661
|
-
borderRadius: "
|
|
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
|
-
|
|
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
|
-
|
|
816
|
-
|
|
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}
|
|
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
|
-
|
|
967
|
+
// Two columns, keys and what they do, aligned across every row.
|
|
968
|
+
const keysStyles = grid({
|
|
969
|
+
alignItems: "center",
|
|
889
970
|
color: "muted",
|
|
890
|
-
|
|
971
|
+
columnGap: 3,
|
|
891
972
|
fontSize: "xs",
|
|
892
|
-
|
|
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 =
|
|
899
|
-
|
|
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={
|
|
996
|
+
return <FileArrowUpIcon size={20} weight="fill" />;
|
|
917
997
|
}
|
|
918
998
|
case ChooserMode.OpenMultiple: {
|
|
919
|
-
return <FilesIcon size={
|
|
999
|
+
return <FilesIcon size={20} weight="fill" />;
|
|
920
1000
|
}
|
|
921
1001
|
case ChooserMode.OpenFolder: {
|
|
922
|
-
return <FolderOpenIcon size={
|
|
1002
|
+
return <FolderOpenIcon size={20} weight="fill" />;
|
|
923
1003
|
}
|
|
924
1004
|
case ChooserMode.Save: {
|
|
925
|
-
return <FloppyDiskIcon size={
|
|
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
|
-
|
|
1112
|
-
<
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
<
|
|
1120
|
-
|
|
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 === ""
|
|
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
|
+
};
|
|
@@ -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),
|