- {mediaDirectoryLabel}}
+ handleInputChange('archive_destination', e.currentTarget.value)}
@@ -804,9 +804,8 @@ export function SettingsPage() {
position='top'
/>
- {mediaDirectoryLabel}}
+ handleInputChange('videos_destination', e.currentTarget.value)}
@@ -814,9 +813,8 @@ export function SettingsPage() {
- {mediaDirectoryLabel}}
+ handleInputChange('map_destination', e.currentTarget.value)}
@@ -824,9 +822,8 @@ export function SettingsPage() {
- {mediaDirectoryLabel}}
+ handleInputChange('zims_destination', e.currentTarget.value)}
@@ -834,9 +831,8 @@ export function SettingsPage() {
- {mediaDirectoryLabel}}
+ handleInputChange('playlists_destination', e.currentTarget.value)}
diff --git a/app/src/components/ui/Inputs.tsx b/app/src/components/ui/Inputs.tsx
index 330f56a7..8a3f5d74 100644
--- a/app/src/components/ui/Inputs.tsx
+++ b/app/src/components/ui/Inputs.tsx
@@ -1,4 +1,4 @@
-import React, {forwardRef} from 'react';
+import React, {forwardRef, useId} from 'react';
import {
Checkbox as MCheckbox,
MultiSelect,
@@ -58,6 +58,66 @@ export function Toggle({style, className, ...props}: ToggleProps) {
/>
}
+export interface PathInputProps
+ extends Omit, 'prefix'> {
+ label?: React.ReactNode;
+ description?: React.ReactNode;
+ error?: React.ReactNode;
+ /**
+ * A fixed, non-editable path the value hangs off — the media directory, say. It is
+ * not part of the value, and typing never changes it.
+ */
+ prefix: string;
+}
+
+/**
+ * A path input with a fixed prefix shown to the left of what the user types.
+ *
+ * The prefix is a *sibling* of the input rather than a section layered inside it, which is
+ * the point: Mantine positions `leftSection` absolutely and reserves room for it with
+ * `--input-padding-inline-start`, so a call site that gets that width wrong prints the
+ * prefix straight through the value. Passing `leftSectionWidth='auto'` did exactly that
+ * — `padding-inline-start: auto` is invalid, so it resolved to zero and every one of these
+ * fields rendered its path on top of its own contents.
+ *
+ * As two boxes in a flex row there is no width to compute and nothing to overlap. It also
+ * sizes itself to any prefix, which matters here: the media directory is `/media/wrolpi`
+ * on a Pi and something else entirely under Docker.
+ */
+export const PathInput = forwardRef((
+ {prefix, label, description, error, required, disabled, className, id, ...props}, ref
+) => {
+ const generated = useId();
+ const inputId = id ?? `${generated}-input`;
+ const prefixId = `${generated}-prefix`;
+
+ return
+ {label && }
+
+ {/*
+ Described rather than hidden: a screen reader should hear which directory the
+ typed path is relative to, since that is the whole point of showing it.
+ */}
+ {prefix}
+
+
+ {description &&
{description}
}
+ {error &&
{error}
}
+
+});
+PathInput.displayName = 'PathInput';
+
export interface ActionInputProps extends React.ComponentProps {
/** Button (or buttons) attached to the input's trailing edge. */
action?: React.ReactNode;
diff --git a/app/src/components/ui/ui.css b/app/src/components/ui/ui.css
index 464da8c8..561453d2 100644
--- a/app/src/components/ui/ui.css
+++ b/app/src/components/ui/ui.css
@@ -390,6 +390,91 @@ html[data-theme="amber"] .wrolpi-form-field input:focus {
background: var(--bg);
}
+/* --------------------------------------------------------------- Path input */
+/*
+ * Prefix and input are siblings in a flex row sharing one outline, so the prefix cannot
+ * print over the value -- the failure that Mantine's absolutely-positioned `leftSection`
+ * allowed when its reserved width was wrong. The prefix takes its natural width, so any
+ * media directory fits without a number being maintained anywhere.
+ */
+
+.wrolpi-path-input-label {
+ display: block;
+ font-weight: 600;
+ font-size: 13px;
+ margin-bottom: 4px;
+}
+
+.wrolpi-path-input-control {
+ display: flex;
+ align-items: stretch;
+ background: var(--panel);
+ border: 1px solid var(--border);
+}
+
+.wrolpi-path-input-control:focus-within {
+ border-color: var(--blue);
+}
+
+html[data-theme="night"] .wrolpi-path-input-control:focus-within,
+html[data-theme="amber"] .wrolpi-path-input-control:focus-within {
+ border-color: var(--text);
+}
+
+.wrolpi-path-input-control[data-error] {
+ border: 1.5px dashed var(--red);
+}
+
+.wrolpi-path-input-control[data-disabled] {
+ background: var(--bg);
+}
+
+/* Muted and on the header surface: it reads as context, not as something to edit. */
+.wrolpi-path-input-prefix {
+ display: flex;
+ align-items: center;
+ flex: none;
+ padding: 0 6px 0 9px;
+ background: var(--head);
+ border-right: 1px solid var(--border);
+ color: var(--muted);
+ font-size: 12px;
+ white-space: nowrap;
+ user-select: none;
+}
+
+.wrolpi-path-input-control input {
+ flex: 1;
+ min-width: 0;
+ padding: 7px 9px;
+ background: none;
+ border: none;
+ outline: none;
+ color: var(--text);
+ font: inherit;
+ font-size: 13px;
+}
+
+.wrolpi-path-input-control input::placeholder {
+ color: var(--muted);
+}
+
+.wrolpi-path-input-control input:disabled {
+ color: var(--muted);
+}
+
+.wrolpi-path-input-description {
+ font-size: 12px;
+ color: var(--muted);
+ margin-top: 3px;
+}
+
+.wrolpi-path-input-error {
+ font-size: 12px;
+ color: var(--red);
+ margin-top: 3px;
+}
+
/* ---------------------------------------------------------- Action inputs */
/*
* Input and button share one outline: the input gives up its right border and
diff --git a/app/src/components/ui/ui.test.js b/app/src/components/ui/ui.test.js
index f0cac345..5fdce00a 100644
--- a/app/src/components/ui/ui.test.js
+++ b/app/src/components/ui/ui.test.js
@@ -25,6 +25,7 @@ import {
Message,
Modal,
Panel,
+ PathInput,
Progress,
resolveIconName,
Status,
@@ -280,6 +281,111 @@ describe('Header', () => {
});
});
+describe('PathInput', () => {
+ /*
+ * These need no mocks at all -- no context, no API, no router. That is the point of
+ * putting the field in the library: the page that uses it needs a pile of provider and
+ * fetch mocks, so its inputs were never covered, which is how a field shipped printing
+ * its own prefix over its own value.
+ */
+
+ it('keeps the prefix out of the value', () => {
+ // The bug this exists to prevent: the prefix must be its own element, never part of
+ // the text the user is editing, so it cannot be typed over or submitted.
+ renderUI();
+
+ const input = screen.getByLabelText(/Archive Directory/);
+ expect(input).toHaveValue('archive/%(domain)s');
+ expect(input.value).not.toContain('/media/wrolpi');
+ // A sibling, not a child: an element inside the box could overlap it.
+ expect(input.querySelector('*')).toBeNull();
+ expect(screen.getByText('/media/wrolpi/')).not.toBe(input);
+ });
+
+ it('reports only what was typed, never the prefix', async () => {
+ // Read the value inside the handler: React nulls `currentTarget` once the event has
+ // been dispatched, so inspecting it afterwards from mock.calls throws.
+ const seen = [];
+ const onChange = jest.fn(event => seen.push(event.currentTarget.value));
+ renderUI();
+
+ await userEvent.type(screen.getByLabelText(/Map Directory/), 'map');
+
+ expect(seen).toEqual(['m', 'ma', 'map']);
+ seen.forEach(value => expect(value).not.toContain('/media/wrolpi'));
+ });
+
+ it('tells assistive technology what the path is relative to', () => {
+ // Sighted users get that from the prefix; hiding it would leave everyone else
+ // typing a relative path with no idea what it is relative to.
+ renderUI();
+
+ expect(screen.getByLabelText(/Zims Directory/)).toHaveAccessibleDescription('/media/wrolpi/');
+ });
+
+ it('disables the input, not just the wrapper', async () => {
+ const onChange = jest.fn();
+ renderUI();
+
+ const input = screen.getByLabelText(/Videos Directory/);
+ expect(input).toBeDisabled();
+ await userEvent.type(input, 'x');
+ expect(onChange).not.toHaveBeenCalled();
+ });
+
+ it('marks an invalid path so the dashed danger treatment applies', () => {
+ const {container} = renderUI();
+
+ expect(container.querySelector('[data-error]')).toBeInTheDocument();
+ expect(screen.getByText('Not a directory')).toBeInTheDocument();
+ });
+
+ it('forwards a ref to the input, for callers that focus it', () => {
+ const ref = React.createRef();
+ renderUI();
+
+ expect(ref.current.tagName).toBe('INPUT');
+ });
+});
+
+describe('no input reserves space for a section it cannot measure', () => {
+ it('nothing passes leftSectionWidth="auto"', () => {
+ /*
+ * `leftSectionWidth` becomes `--input-padding-inline-start`, and
+ * `padding-inline-start: auto` is not valid CSS -- it resolves to zero, so the
+ * section prints straight over the value. Five fields on the Settings page shipped
+ * that way and were unreadable. It fails silently, in CSS, at runtime only, which
+ * is exactly the kind of thing worth a source check.
+ */
+ const offenders = [];
+ const walk = (dir) => {
+ for (const entry of fs.readdirSync(dir, {withFileTypes: true})) {
+ const full = path.join(dir, entry.name);
+ if (entry.isDirectory()) walk(full);
+ else if (/\.(js|jsx|tsx)$/.test(entry.name) && !/\.test\./.test(entry.name)) {
+ // Comments are stripped first: the component that replaced this
+ // pattern documents it by name, and prose about a mistake is not the
+ // mistake.
+ const source = fs.readFileSync(full, 'utf8')
+ .replace(/\/\*[\s\S]*?\*\//g, '')
+ .replace(/^\s*\/\/.*$/gm, '');
+ if (/leftSectionWidth\s*=\s*['"`]auto['"`]/.test(source)
+ || /rightSectionWidth\s*=\s*['"`]auto['"`]/.test(source)) {
+ offenders.push(path.relative(path.join(__dirname, '..', '..'), full));
+ }
+ }
+ }
+ };
+ walk(path.join(__dirname, '..', '..'));
+
+ expect(offenders).toEqual([]);
+ });
+});
+
describe('ActionInput', () => {
it('joins the input and its action into one control', () => {
const {container} = renderUI(