Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions app/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"@types/node": "^25.6.0",
"@types/three": "^0.184.0",
"@vitest/coverage-v8": "^4.1.7",
"axe-core": "^4.12.1",
"canvas": "^3.2.3",
"eslint": "^10.3.0",
"eslint-config-prettier": "^10.1.8",
Expand Down
8 changes: 4 additions & 4 deletions app/src/components/BranchSelect/BranchSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,17 +73,17 @@ export function BranchSelect({ url, value, onChange, onError }: BranchSelectProp

return (
<div class="branch-select">
<label>Branch</label>
<label htmlFor="branch-select">Branch</label>
{state.status === BranchStatus.Loading && (
<div class="branch-select-status">
<LoaderCircle class="lucide-icon branch-select-spinner" />
<div class="branch-select-status" role="status">
<LoaderCircle class="lucide-icon branch-select-spinner" aria-hidden="true" />
Resolving branches…
</div>
)}
{state.status === BranchStatus.Ready && (
<select
id="branch-select"
class="form-input form-input--select"
aria-label="Branch"
value={value || state.def || ''}
onChange={(e) => onChange((e.target as HTMLSelectElement).value)}
>
Expand Down
13 changes: 12 additions & 1 deletion app/src/components/City.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,5 +70,16 @@ export function City() {
};
}, []);

return <canvas id="city" ref={canvasRef} />;
// A canvas is non-text content, so it needs a text alternative (WCAG 1.1.1).
// role=img + a description of what the scene shows; keyboard users browse the
// same data through the Explore file tree and Search panels, and selecting a
// building is announced by <SelectionAnnouncer>.
return (
<canvas
id="city"
ref={canvasRef}
role="img"
aria-label="3D city map of the repository. Files are buildings, directories are streets, commits are trees. Browse it with the file tree and search panels."
/>
);
}
5 changes: 4 additions & 1 deletion app/src/components/ColorInput/ColorInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,16 @@ export interface ColorInputProps {
value: string;
onCommit: (hex: string) => void;
describedBy?: string;
/** id so a row label can associate via htmlFor. */
id?: string;
}

export function ColorInput({ value, onCommit, describedBy }: ColorInputProps) {
export function ColorInput({ value, onCommit, describedBy, id }: ColorInputProps) {
return (
<input
type="color"
class="theme-color"
id={id}
value={normalizeHex(value)}
aria-describedby={describedBy}
onInput={(e) => onCommit((e.currentTarget as HTMLInputElement).value)}
Expand Down
24 changes: 15 additions & 9 deletions app/src/components/CopyButton/CopyButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,14 +49,20 @@ export function CopyButton({
};

return (
<button
type="button"
class={`btn-icon btn-icon--no-drag${copied ? ' is-copied' : ''}`}
title={label}
aria-label={label}
onClick={onClick}
>
<Copy class="lucide-icon" />
</button>
<>
<button
type="button"
class={`btn-icon btn-icon--no-drag${copied ? ' is-copied' : ''}`}
title={label}
aria-label={label}
onClick={onClick}
>
<Copy class="lucide-icon" />
</button>
{/* The copied state is otherwise a color-only flash; announce it. */}
<span class="sr-only" role="status">
{copied ? 'Copied' : ''}
</span>
</>
);
}
17 changes: 16 additions & 1 deletion app/src/components/Field.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export function Field({ store, fieldKey }: FieldProps) {
// catches — so in practice the hook always runs with a real field.
const binding = useField(store, fieldKey);
const descId = useId();
const controlId = useId();
const describedBy = def?.tip ? descId : undefined;
if (!def) {
// Misconfigured schema (a (store, key) with no field def). The
Expand All @@ -62,6 +63,7 @@ export function Field({ store, fieldKey }: FieldProps) {
value={binding.value as string}
onCommit={binding.onCommit}
describedBy={describedBy}
id={controlId}
/>
);
break;
Expand All @@ -74,6 +76,7 @@ export function Field({ store, fieldKey }: FieldProps) {
step={def.step!}
onCommit={binding.onCommit}
describedBy={describedBy}
id={controlId}
/>
);
break;
Expand All @@ -86,12 +89,18 @@ export function Field({ store, fieldKey }: FieldProps) {
step={def.step!}
onCommit={binding.onCommit}
describedBy={describedBy}
id={controlId}
/>
);
break;
case FieldKind.Toggle:
control = (
<Toggle checked={!!binding.value} onCommit={binding.onCommit} describedBy={describedBy} />
<Toggle
checked={!!binding.value}
onCommit={binding.onCommit}
describedBy={describedBy}
id={controlId}
/>
);
break;
case FieldKind.Select:
Expand All @@ -101,6 +110,7 @@ export function Field({ store, fieldKey }: FieldProps) {
options={def.options!}
onCommit={binding.onCommit}
describedBy={describedBy}
label={def.label}
/>
);
break;
Expand All @@ -115,6 +125,8 @@ export function Field({ store, fieldKey }: FieldProps) {
step={def.step!}
onCommit={(l, h) => binding.onCommit([l, h] as never)}
describedBy={describedBy}
id={controlId}
label={def.label}
/>
);
break;
Expand All @@ -134,6 +146,9 @@ export function Field({ store, fieldKey }: FieldProps) {
tip={def.tip}
inline={inline}
descId={def.tip ? descId : undefined}
// Select renders a button group (no single labelable field); it's named
// in its own control, so don't point the row label at a missing id.
htmlFor={def.kind === FieldKind.Select ? undefined : controlId}
store={store}
keys={[fieldKey]}
>
Expand Down
4 changes: 3 additions & 1 deletion app/src/components/HueMapField/HueMapField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,16 @@ export function HueMapField({ store, fieldKey }: FieldProps) {
const disabled = value === defaultVal;
const tip = `Hue (0 to 359 degrees) for files with this extension.`;
const descId = `${baseId}-${k}`;
const controlId = `${baseId}-${k}-c`;
return (
<ThemeRow label={k} tip={tip} descId={descId} key={k}>
<ThemeRow label={k} tip={tip} descId={descId} htmlFor={controlId} key={k}>
<Slider
value={value}
min={0}
max={359}
step={1}
describedBy={descId}
id={controlId}
onCommit={(v) => commit({ ...map, [k]: v })}
/>
<span class="theme-hue-preview" style={{ background: fileTagHsl(value) }} />
Expand Down
11 changes: 8 additions & 3 deletions app/src/components/NewProjectForm/NewProjectForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,19 +95,24 @@ export function NewProjectForm({ allowLocalRepos, prefill, onSubmit }: NewProjec
}}
>
<div class="new-project-field">
<label>{sourceLabel}</label>
<label htmlFor="new-project-source">{sourceLabel}</label>
<input
id="new-project-source"
class={hasError ? 'form-input form-input--error' : 'form-input'}
type="text"
aria-label={sourceLabel}
aria-invalid={hasError ? 'true' : undefined}
aria-describedby={fieldError ? 'new-project-error' : undefined}
autoComplete="off"
spellcheck={false}
placeholder={placeholder}
value={source}
onInput={(e) => onSourceInput((e.target as HTMLInputElement).value)}
/>
{fieldError && <p class="new-project-error">{fieldError}</p>}
{fieldError && (
<p id="new-project-error" role="alert" class="new-project-error">
{fieldError}
</p>
)}
</div>

{showLocalNotice && (
Expand Down
13 changes: 12 additions & 1 deletion app/src/components/NumberInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,24 @@ export interface NumberInputProps {
step: number;
onCommit: (v: number) => void;
describedBy?: string;
/** id so a row label can associate via htmlFor. */
id?: string;
}

export function NumberInput({ value, min, max, step, onCommit, describedBy }: NumberInputProps) {
export function NumberInput({
value,
min,
max,
step,
onCommit,
describedBy,
id,
}: NumberInputProps) {
return (
<input
type="number"
class="form-input form-input--mono"
id={id}
min={String(min)}
max={String(max)}
step={String(step)}
Expand Down
14 changes: 11 additions & 3 deletions app/src/components/Pane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
// <PaneEmpty> is the shared centered icon + headline + subtitle block used
// for every "nothing selected / nothing to show" empty state.

import type { ComponentChildren, Ref } from 'preact';
import type { ComponentChildren, JSX, Ref } from 'preact';
import type { LucideIcon } from 'lucide-preact';
import { PaneHeader } from './PaneHeader/PaneHeader';

Expand Down Expand Up @@ -40,11 +40,14 @@ export interface PaneProps {
/** Ref to the `.pane-body` element — for panes that mount body content
* imperatively (e.g. FilePreviewPane's highlight.js output). */
bodyRef?: Ref<HTMLDivElement>;
/** Extra attributes spread onto the `.pane-body` (e.g. tabpanel role/id for a
* tabbed pane whose body is the panel). Only applies when Pane owns the body. */
bodyProps?: JSX.HTMLAttributes<HTMLDivElement>;
/** Content rendered AFTER the body (e.g. ControlsPane's sticky action bar),
* outside the scrolling body region. */
footerSlot?: ComponentChildren;
/** Ref to the outer `.pane` element — for panes that need to query their own
* DOM (e.g. ControlsPane collapsing its `<details>` sections). */
* DOM. */
paneRef?: Ref<HTMLDivElement>;
children?: ComponentChildren;
}
Expand All @@ -62,6 +65,7 @@ export function Pane({
closeTitle,
bodyClass,
bodyRef,
bodyProps,
footerSlot,
paneRef,
children,
Expand All @@ -82,7 +86,11 @@ export function Pane({
/>
)}
{ownsBody ? (
<div class={bodyClass ? `pane-body ${bodyClass}` : 'pane-body'} ref={bodyRef}>
<div
class={bodyClass ? `pane-body ${bodyClass}` : 'pane-body'}
ref={bodyRef}
{...bodyProps}
>
{children}
</div>
) : (
Expand Down
41 changes: 39 additions & 2 deletions app/src/components/PaneTabs/PaneTabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,22 +17,59 @@ export interface PaneTabsProps {
onSelect: (id: string) => void;
/** Extra class on the strip root for context-specific spacing (e.g. modal). */
class?: string;
/** id of the tabpanel these tabs control. Wires aria-controls on every tab
* and gives each tab a stable id (`${panelId}-tab-${tabId}`) so the panel
* can point its aria-labelledby back at the active tab. */
panelId?: string;
}

export function PaneTabs({ tabs, active, onSelect, class: className }: PaneTabsProps) {
export function PaneTabs({ tabs, active, onSelect, class: className, panelId }: PaneTabsProps) {
// Arrow keys move selection within the tablist (automatic activation, like a
// click), wrapping at the ends; Home/End jump to the first/last. Focus follows
// to the newly selected tab, which becomes the strip's single tab stop.
function onKeyDown(e: KeyboardEvent, idx: number) {
let next: number;
switch (e.key) {
case 'ArrowRight':
case 'ArrowDown':
next = (idx + 1) % tabs.length;
break;
case 'ArrowLeft':
case 'ArrowUp':
next = (idx - 1 + tabs.length) % tabs.length;
break;
case 'Home':
next = 0;
break;
case 'End':
next = tabs.length - 1;
break;
default:
return;
}
e.preventDefault();
onSelect(tabs[next].id);
const strip = (e.currentTarget as HTMLElement).parentElement;
(strip?.children[next] as HTMLElement | undefined)?.focus();
}

return (
<div class={className ? `pane-tabs ${className}` : 'pane-tabs'} role="tablist">
{tabs.map((t) => {
{tabs.map((t, idx) => {
const Icon = t.icon;
const selected = t.id === active;
return (
<button
key={t.id}
type="button"
role="tab"
id={panelId ? `${panelId}-tab-${t.id}` : undefined}
aria-selected={selected ? 'true' : 'false'}
aria-controls={panelId}
tabIndex={selected ? 0 : -1}
class={selected ? 'pane-tab pane-tab--active focus-inset' : 'pane-tab focus-inset'}
onClick={() => onSelect(t.id)}
onKeyDown={(e) => onKeyDown(e, idx)}
>
{Icon && <Icon class="lucide-icon" aria-hidden="true" />}
<span>{t.label}</span>
Expand Down
6 changes: 5 additions & 1 deletion app/src/components/PathBreadcrumbs/PathBreadcrumbs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,11 @@ export function PathBreadcrumbs({
const isLeaf = i === crumbs.length - 1;
return (
<Fragment key={`seg-${i}`}>
{i > 0 && <span class="app-header-sep">›</span>}
{i > 0 && (
<span class="app-header-sep" aria-hidden="true">
</span>
)}
<button
type="button"
class={`btn-icon btn-icon--text btn-icon--no-drag app-header-seg${isLeaf ? ' is-leaf' : ''}`}
Expand Down
Loading
Loading