diff --git a/docs/public/screenshots/create-site-modal.png b/docs/public/screenshots/create-site-modal.png
index 6f4d9108..13dc8fa3 100644
Binary files a/docs/public/screenshots/create-site-modal.png and b/docs/public/screenshots/create-site-modal.png differ
diff --git a/src/renderer/components/create-site-dialog.jsx b/src/renderer/components/create-site-dialog.jsx
index ee846806..9830f614 100644
--- a/src/renderer/components/create-site-dialog.jsx
+++ b/src/renderer/components/create-site-dialog.jsx
@@ -8,7 +8,6 @@ import { __ } from '@wordpress/i18n';
import { Button, Dialog, InputControl, Notice, Stack } from '@wordpress/ui';
import { DEFAULT_PROJECT_TYPE } from '../../project-type.cjs';
import { createSiteProblem, projectChoices, projectHelp } from '../create-site.cjs';
-import { directoryFromFileEntry } from '../site-folder.cjs';
import { FolderField } from './folder-field.jsx';
// The dialog's three answers and the button that sends them. It is inside
@@ -35,21 +34,6 @@ function CreateSiteForm({ formId, submitting, defaultDir, onCreate }) {
} catch {}
}, []);
- // Not reached by the intended route, which is the system's dialog above:
- // a folder dropped on the input arrives here.
- const takeFiles = useCallback((event) => {
- const input = event.target;
- const files = input.files;
- if (files && files.length > 0) {
- const resolved = directoryFromFileEntry(files[0], input.value);
- setDir(resolved);
- // Clearing the error only when there is a directory: a selection that
- // resolved to nothing has not fixed anything the message was about.
- if (resolved) setError('');
- }
- input.value = '';
- }, []);
-
const submit = (event) => {
event.preventDefault();
const problem = createSiteProblem({ name, dir });
@@ -91,7 +75,6 @@ function CreateSiteForm({ formId, submitting, defaultDir, onCreate }) {
value={dir}
disabled={submitting}
onChoose={chooseFolder}
- onFiles={takeFiles}
/>
{error ? (
// An alert, which is said as it appears. The notice is told to
diff --git a/src/renderer/components/folder-field.jsx b/src/renderer/components/folder-field.jsx
index 463ede15..bb5e57f4 100644
--- a/src/renderer/components/folder-field.jsx
+++ b/src/renderer/components/folder-field.jsx
@@ -1,14 +1,16 @@
+import { useId } from 'react';
import { __ } from '@wordpress/i18n';
-import { Field, Text } from '@wordpress/ui';
+import { Button, Field, Text } from '@wordpress/ui';
/**
* A field whose answer is a folder (#557, #559).
*
- * The field is a file input, which is what says "choose a folder" without a
- * word, and the folder it holds is said under it: the app asks the system
- * for a folder itself, since a page is not told where one is, and the input
- * is never left holding a selection. The create-site dialog and the settings
- * both ask with it.
+ * The field is a button that asks the system for a folder, since a page is
+ * not told where one is, and the folder it holds is said under it. Not a
+ * file input: Chromium draws that one's button and its "No file chosen" in
+ * its own language, which is not always the window's, and a folder dropped
+ * on it never had a path to give (#228, #655). The create-site dialog and
+ * the settings both ask with it.
*
* @param {Object} props
* @param {string} props.label What the folder is for.
@@ -17,34 +19,23 @@ import { Field, Text } from '@wordpress/ui';
* @param {string} [props.empty] What to say in place of a folder when there is none.
* @param {boolean} [props.disabled]
* @param {Function} props.onChoose Asked to open the system's dialog.
- * @param {Function} [props.onFiles] A folder dropped on the input, which arrives as files.
*/
-export function FolderField({ label, description, value, empty, disabled = false, onChoose, onFiles }) {
+export function FolderField({ label, description, value, empty, disabled = false, onChoose }) {
+ const labelId = useId();
+ const textId = useId();
+ // Named by the label and by what the button says, so "Choose folder" is
+ // in the name a voice control user would say (#655), and described by the
+ // folder it holds, so the folder is heard where the button is.
return (
- {label}
+ {label}
- }
+ render={}
+ aria-labelledby={`${labelId} ${textId}`}
disabled={disabled}
- onChange={onFiles}
- onClick={(event) => { event.preventDefault(); onChoose(); }}
- onKeyDown={(event) => {
- if (event.key === 'Enter' || event.key === ' ') {
- event.preventDefault();
- onChoose();
- }
- }}
+ onClick={onChoose}
/>
- {value || empty || __('No folder selected yet.')}
+ }>{value || empty || __('No folder selected yet.')}
{description ? {description} : null}
);
diff --git a/src/renderer/shell.css b/src/renderer/shell.css
index d5488c96..e1d87602 100644
--- a/src/renderer/shell.css
+++ b/src/renderer/shell.css
@@ -1310,21 +1310,10 @@ body {
}
/* A folder field (#557, #559), in the create-site dialog and the settings.
- The folder is asked for with a file input, as wide as the dialog, and the
- folder that was chosen is said under it: a path has no spaces to break
- at. */
+ The folder that was chosen is said under its button: a path has no
+ spaces to break at. The button keeps its own width, not the field's. */
.folder-field .file-field-control {
- width: 100%;
- min-height: var(--wpds-dimension-size-md);
- font: inherit;
- color: inherit;
-}
-
-/* The ring the design system's own controls have, where the browser would
- draw its own. */
-.folder-field .file-field-control:focus-visible {
- outline: var(--wpds-border-width-focus) solid var(--wpds-color-stroke-focus);
- outline-offset: var(--wpds-border-width-focus);
+ align-self: flex-start;
}
.folder-field .file-field-value {
diff --git a/src/renderer/site-folder.cjs b/src/renderer/site-folder.cjs
index 4fceb0db..c494af33 100644
--- a/src/renderer/site-folder.cjs
+++ b/src/renderer/site-folder.cjs
@@ -1,7 +1,7 @@
// Where a new site goes, and what its folder is called.
//
-// Three decisions the Create site modal makes before `setupWordPress` is ever
-// called, all of them string work on paths the renderer cannot hand to Node's
+// Two decisions the Create site modal makes before `setupWordPress` is ever
+// called, both of them string work on paths the renderer cannot hand to Node's
// `path` module: it has none. The chosen root arrives in the platform's native
// form — `C:\Users\me` on Windows, `/Users/me` elsewhere — so joining a folder
// name onto it means picking the separator by looking at the string.
@@ -53,48 +53,4 @@ function resolveTargetDir(root, folder) {
return `${normalizedRoot}${separator}${folder}`;
}
-/**
- * The directory an `` selection points at.
- *
- * Nothing reaches this by the intended route: the input's click and keyboard
- * handlers are intercepted and go to the native dialog. It runs only when a
- * folder is dropped onto the control — a route the app deliberately does not
- * support, decided in #228 and closed there. Extracted as it stood, dead branch
- * included, because #216 is a refactor and not the place to change what it
- * answers:
- *
- * - `path` plus `webkitRelativePath`, and `path` alone, are the two shapes this
- * was written for. Electron removed the `path` augmentation on `File` in v32
- * in favour of `webUtils.getPathForFile`; this app pins Electron 43 and
- * bridges no `webUtils`, so neither branch is reachable.
- * - What is left is the input's own `value`, which is a fiction: a file input's
- * value is empty or the literal `C:\fakepath\` prefix on every platform. So a
- * dropped folder resolves to '' or to `C:\fakepath`, and the modal presents
- * the second as a real destination.
- *
- * @param {*} file The first entry of the input's `files` list.
- * @param {*} inputValue The input's `value`, read only when `file` has no path.
- * @return {string} The directory without a trailing separator, or '' when none
- * could be derived.
- */
-function directoryFromFileEntry(file, inputValue) {
- const relative = file?.webkitRelativePath || '';
- const rawPath = file?.path || '';
- let resolved = '';
-
- if (rawPath) {
- if (relative) {
- resolved = rawPath.slice(0, rawPath.length - relative.length);
- } else {
- resolved = rawPath.replace(/[\\/][^\\/]*$/, '');
- }
- }
-
- if (!resolved && inputValue) {
- resolved = String(inputValue).replace(/[^\\/]*$/, '');
- }
-
- return resolved.replace(/[\\/]+$/, '');
-}
-
-module.exports = { sanitizeSiteFolder, resolveTargetDir, directoryFromFileEntry, FALLBACK_FOLDER };
+module.exports = { sanitizeSiteFolder, resolveTargetDir, FALLBACK_FOLDER };
diff --git a/tests/e2e/journeys/create-site.spec.js b/tests/e2e/journeys/create-site.spec.js
index 008fea24..d8b290fb 100644
--- a/tests/e2e/journeys/create-site.spec.js
+++ b/tests/e2e/journeys/create-site.spec.js
@@ -48,7 +48,7 @@ test( 'the create-site dialog refuses a missing name or location, starts clean e
const dialog = ui.createSiteDialog( page );
const name = dialog.getByLabel( 'Site name', { exact: true } );
- const location = dialog.getByLabel( 'Location', { exact: true } );
+ const location = dialog.getByRole( 'button', { name: 'Location Choose folder…', exact: true } );
const core = dialog.getByRole( 'radio', { name: 'WordPress Core', exact: true } );
const gutenberg = dialog.getByRole( 'radio', { name: 'Gutenberg', exact: true } );
const create = dialog.getByRole( 'button', { name: 'Create site', exact: true } );
@@ -64,6 +64,10 @@ test( 'the create-site dialog refuses a missing name or location, starts clean e
const projects = dialog.getByRole( 'radiogroup', { name: 'Project', exact: true } );
await expect( projects ).toHaveAccessibleDescription( `${ projectHelp( 'core' ).about } ${ projectHelp( 'core' ).lasting }` );
expect( projectHelp( 'core' ).lasting ).toBe( 'A site’s project cannot be changed later.' );
+ // The folder is said where its button is, before what the field is for
+ // (#655).
+ const LOCATION_HELP = 'Choose the parent folder where you want this new site created. A new subdirectory will be created for the site.';
+ await expect( location ).toHaveAccessibleDescription( `No folder selected yet. ${ LOCATION_HELP }` );
// INVARIANT — it says which answer is missing, one at a time, as an
// alert, and starts nothing while one is: a name of spaces is no name.
@@ -80,6 +84,7 @@ test( 'the create-site dialog refuses a missing name or location, starts clean e
// about it away.
await location.press( 'Enter' );
await expect( dialog.getByText( parent, { exact: true } ) ).toBeVisible();
+ await expect( location ).toHaveAccessibleDescription( `${ parent } ${ LOCATION_HELP }` );
await expect( dialog.getByText( 'Please choose where to create the site.', { exact: true } ) ).toHaveCount( 0 );
await gutenberg.click();
await expect( gutenberg ).toBeChecked();
diff --git a/tests/e2e/journeys/i18n.spec.js b/tests/e2e/journeys/i18n.spec.js
index 20c1ad0b..e176223f 100644
--- a/tests/e2e/journeys/i18n.spec.js
+++ b/tests/e2e/journeys/i18n.spec.js
@@ -102,6 +102,10 @@ test( 'the first-run screen and the create-site dialog are fully translatable',
await expect( dialog ).toBeVisible();
await expect( dialog.getByRole( 'button', { name: /^\[/ } ).first() ).toBeVisible();
expect( await unwrapped( dialog ) ).toEqual( [] );
+ // The folder's button is the app's own, not a file input, whose button
+ // and "No file chosen" Chromium draws in its own language, where the scan
+ // cannot see them (#655).
+ await expect( dialog.getByRole( 'button', { name: `${ pseudoLocalize( 'Location' ) } ${ pseudoLocalize( 'Choose folder…' ) }`, exact: true } ) ).toHaveText( pseudoLocalize( 'Choose folder…' ) );
// A validation error is wrapped too.
await dialog.getByRole( 'button', { name: pseudoLocalize( 'Create site' ), exact: true } ).click();
@@ -116,6 +120,7 @@ test( 'the settings dialog is fully translatable, on both of its tabs', async (
await expect( dialog ).toBeVisible();
await expect( dialog.getByText( pseudoLocalize( 'Not set: the create-site dialog asks each time.' ), { exact: true } ) ).toBeVisible();
expect( await unwrapped( dialog ) ).toEqual( [] );
+ await expect( dialog.getByRole( 'button', { name: `${ pseudoLocalize( 'New sites go here' ) } ${ pseudoLocalize( 'Choose folder…' ) }`, exact: true } ) ).toHaveText( pseudoLocalize( 'Choose folder…' ) );
// The Sites tab, once it has the PHP versions: their numbers are not
// words and stay as they are.
diff --git a/tests/e2e/journeys/settings.spec.js b/tests/e2e/journeys/settings.spec.js
index 832c67a5..7d097687 100644
--- a/tests/e2e/journeys/settings.spec.js
+++ b/tests/e2e/journeys/settings.spec.js
@@ -41,7 +41,7 @@ test( 'the folder new sites go in is chosen in the settings, used by the create-
// INVARIANT — a folder chosen is shown, kept, and offered to be forgotten.
await session.answerFileDialog( [ parent ] );
- const field = dialog.getByLabel( 'New sites go here', { exact: true } );
+ const field = dialog.getByRole( 'button', { name: 'New sites go here Choose folder…', exact: true } );
await field.press( 'Enter' );
await expect( dialog.getByText( parent, { exact: true } ) ).toBeVisible();
await expect( notSet ).toHaveCount( 0 );
@@ -67,7 +67,7 @@ test( 'the folder new sites go in is chosen in the settings, used by the create-
await expect( create.getByText( 'No folder selected yet.', { exact: true } ) ).toHaveCount( 0 );
const other = session.track( fs.mkdtempSync( path.join( os.tmpdir(), 'wpct-e2e-other-' ) ) );
await session.answerFileDialog( [ other ] );
- await create.getByLabel( 'Location', { exact: true } ).press( 'Enter' );
+ await create.getByRole( 'button', { name: 'Location Choose folder…', exact: true } ).press( 'Enter' );
await expect( create.getByText( other, { exact: true } ) ).toBeVisible();
await ui.closeDialogButton( create ).click();
await expect( create ).toHaveCount( 0 );
diff --git a/tests/e2e/real-setup/create-site.spec.js b/tests/e2e/real-setup/create-site.spec.js
index 7050ca14..513c6299 100644
--- a/tests/e2e/real-setup/create-site.spec.js
+++ b/tests/e2e/real-setup/create-site.spec.js
@@ -72,7 +72,7 @@ for ( const target of TARGETS ) {
await name.fill( 'real-setup' );
await expect( name ).toHaveValue( 'real-setup', { timeout: 2_000 } );
} ).toPass( { timeout: 30_000 } );
- await modal.getByLabel( 'Location', { exact: true } ).press( 'Enter' );
+ await modal.getByRole( 'button', { name: 'Location Choose folder…', exact: true } ).press( 'Enter' );
await modal.getByRole( 'button', { name: 'Create site', exact: true } ).click();
} );
diff --git a/tests/unit/site-folder.test.cjs b/tests/unit/site-folder.test.cjs
index e741d762..f2e57e3a 100644
--- a/tests/unit/site-folder.test.cjs
+++ b/tests/unit/site-folder.test.cjs
@@ -8,7 +8,6 @@ const assert = require('node:assert/strict');
const {
sanitizeSiteFolder,
resolveTargetDir,
- directoryFromFileEntry,
FALLBACK_FOLDER
} = require('../../src/renderer/site-folder.cjs');
@@ -64,38 +63,3 @@ test('resolveTargetDir with no root is the folder name alone', () => {
assert.equal(resolveTargetDir('', 'my-site'), 'my-site');
assert.equal(resolveTargetDir(null, 'my-site'), 'my-site');
});
-
-// What follows pins what this function does with the entries it actually gets,
-// which is not the same as what it was written for. The `path` property it
-// prefers was removed from `File` in Electron 32, this app pins Electron 43,
-// and no `webUtils` bridge replaces it — so every real entry takes the
-// fallback. Asserting the `path` shapes would be green and prove nothing.
-//
-// The only route that reaches this at all is dropping a folder on the control,
-// which the app deliberately does not support — #228, closed as not planned.
-// So these record where an unsupported route ends, and are the tests a change
-// of mind would have to rewrite.
-
-test('directoryFromFileEntry gets nothing from a real dropped entry', () => {
- // A File in Electron 43. No `path`, and `webkitRelativePath` alone carries
- // no absolute part to cut it off.
- assert.equal(directoryFromFileEntry({ webkitRelativePath: 'sites/inner/file.txt' }, ''), '');
- assert.equal(directoryFromFileEntry({}, ''), '');
- assert.equal(directoryFromFileEntry(null, ''), '');
- assert.equal(directoryFromFileEntry(undefined, undefined), '');
-});
-
-test('directoryFromFileEntry passes C:\\fakepath through — #228', () => {
- // Recorded, not endorsed. A file input's `value` is either empty or this
- // literal prefix on every platform, browsers substituting it for the real
- // path, so the fallback's "typed path" is a fiction. The modal shows this
- // as the chosen folder and submit hands it to setup.
- assert.equal(directoryFromFileEntry({}, 'C:\\fakepath\\my-folder'), 'C:\\fakepath');
-});
-
-test('directoryFromFileEntry returns nothing rather than a wrong directory', () => {
- // '' is what the caller checks before it clears the chosen directory — a
- // bare segment is not a directory anyone chose.
- assert.equal(directoryFromFileEntry({}, 'file.txt'), '');
- assert.equal(directoryFromFileEntry({}, ''), '');
-});