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({}, ''), ''); -});