Skip to content
Merged
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
2 changes: 1 addition & 1 deletion apps/ui/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ void settings;
<div class="foot">
<div v-if="state.healthError" class="s-failed">Can't reach the server</div>
<div v-else-if="state.health" class="row" style="gap: 4px">
<span v-if="authState.user" class="foot-who">{{ authState.user.username }}</span>
<span v-if="authState.user" class="foot-who" :title="authState.user.username">{{ authState.user.username }}</span>
<span v-else-if="authState.root && authState.checked" class="foot-who">token access</span>
<!-- The role, permanently, next to who you are: it is the reason half this rail is or is
not there. Root holds no role — "token access" already says it outranks all four. -->
Expand Down
56 changes: 52 additions & 4 deletions apps/ui/src/components/ActionButton.vue
Original file line number Diff line number Diff line change
Expand Up @@ -25,20 +25,31 @@
* attribute and `run` was emitted by nothing. Those buttons rendered, took the click, and did
* nothing at all. Implementing the contract they were written against is the fix.
*/
import { onBeforeUnmount, ref } from 'vue';
import { computed, onBeforeUnmount, ref, useId } from 'vue';

const props = defineProps<{
pending?: boolean;
disabled?: boolean;
variant?: 'primary' | 'danger' | 'ghost';
/** Shown as a native tooltip; use it to say WHY a disabled control is disabled. */
/** Says WHY a disabled control is disabled; read out as the control's description. */
title?: string;
/** When set, the click is two-stage and this is the question asked in between. */
confirm?: string;
}>();

const emit = defineEmits<{ (e: 'run'): void }>();

/**
* BLOCKED BY A PRECONDITION, not natively disabled. `disabled` takes the button out of the tab
* order, and with it the `title` that explains why — so keyboard, touch and screen-reader users
* met a dead control with no reason attached. `aria-disabled` keeps it focusable and the reason
* reachable; `onClick` does the blocking the attribute used to do.
*
* `pending` stays natively disabled: transiently unavailable, nothing to explain.
*/
const blocked = computed(() => Boolean(props.disabled && props.title && !props.pending));
const whyId = useId();

const armed = ref(false);
let timer: ReturnType<typeof setTimeout> | null = null;

Expand All @@ -48,7 +59,15 @@ function disarm(): void {
timer = null;
}

function onClick(): void {
function onClick(e: MouseEvent): void {
if (props.pending || props.disabled) {
// An aria-disabled button still takes clicks and still submits a form. preventDefault kills the
// implicit submit; stopImmediatePropagation drops the parent's own fallthrough @click, which
// Vue merges onto this element after this handler.
e.preventDefault();
e.stopImmediatePropagation();
return;
}
if (!props.confirm) return; // plain button: the parent's own @click handles it
if (armed.value) {
disarm();
Expand All @@ -67,7 +86,9 @@ onBeforeUnmount(disarm);
<template>
<button
:class="[variant, armed && 'armed']"
:disabled="pending || disabled"
:disabled="pending || (disabled && !title)"
:aria-disabled="blocked || undefined"
:aria-describedby="blocked ? whyId : undefined"
:title="title"
:aria-busy="pending ? 'true' : undefined"
@click="onClick"
Expand All @@ -77,5 +98,32 @@ onBeforeUnmount(disarm);
<span v-if="armed">{{ confirm }}</span>
<slot v-else />
<span v-if="pending" class="progress" aria-hidden="true" />
<!-- Teleported, not a second root node: a fragment root would stop the calling view's scoped
styles from reaching this button, and left inside it the reason would join the button's
accessible NAME instead of describing it. -->
<Teleport v-if="blocked" to="body">
<span :id="whyId" class="ab-why">{{ title }}</span>
</Teleport>
</button>
</template>

<style scoped>
/* app.css paints `button:disabled`; a blocked button is aria-disabled instead, so it repaints the
same look here — minus the focus ring, which a now-focusable button needs. */
button[aria-disabled='true'] {
opacity: 0.45;
cursor: not-allowed;
}
button[aria-disabled='true']:not(:focus-visible) {
box-shadow: none;
}
.ab-why {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
</style>
7 changes: 3 additions & 4 deletions apps/ui/src/components/CommandPalette.vue
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
* and an operator who has to remember the exact spelling is back to using the sidebar. Ties break
* toward earlier and more contiguous matches, so an exact prefix always wins.
*/
import { Search } from 'lucide-vue-next';
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { api } from '../api/client';
Expand Down Expand Up @@ -169,10 +170,7 @@ defineExpose({ show });
<div v-if="open" class="scrim palette-scrim" @click.self="hide">
<div class="palette" role="dialog" aria-modal="true" aria-label="Command palette">
<div class="palette-q">
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<circle cx="11" cy="11" r="7" fill="none" stroke="currentColor" stroke-width="2" />
<path d="M16.5 16.5 21 21" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
<Search :size="16" aria-hidden="true" />
<input
ref="input"
v-model="q"
Expand All @@ -195,6 +193,7 @@ defineExpose({ show });
class="palette-row"
:data-on="i === cursor"
@click="choose(r.it)"
@focus="cursor = i"
@mousemove="cursor = i"
>
<span class="palette-label">{{ r.it.label }}</span>
Expand Down
12 changes: 6 additions & 6 deletions apps/ui/src/components/EquivalentCommand.vue
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ async function copy(): Promise<void> {
await navigator.clipboard.writeText(text.value);
toast('ok', 'Copied.');
} catch {
toast('error', 'Could not copy — your browser blocked clipboard access.');
toast('error', 'Copy failed — select it and copy by hand.');
}
}
</script>
Expand All @@ -74,11 +74,12 @@ async function copy(): Promise<void> {

<Transition name="fade">
<div v-if="open" class="eqc-panel">
<div class="eqc-tabs" role="tablist">
<!-- Two toggles, not a tablist: no tabpanel, no roving tabindex, no arrow keys. `role="tab"`
would promise an APG keyboard contract nothing here implements. -->
<div class="eqc-tabs">
<button
class="ghost sm"
role="tab"
:aria-selected="tab === 'curl'"
:aria-pressed="tab === 'curl'"
:data-on="tab === 'curl'"
@click="tab = 'curl'"
>
Expand All @@ -87,8 +88,7 @@ async function copy(): Promise<void> {
<button
v-if="cli"
class="ghost sm"
role="tab"
:aria-selected="tab === 'cli'"
:aria-pressed="tab === 'cli'"
:data-on="tab === 'cli'"
@click="tab = 'cli'"
>
Expand Down
2 changes: 1 addition & 1 deletion apps/ui/src/components/ErrorNote.vue
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ defineProps<{ text: string; title?: string }>();

<template>
<div class="banner failed" role="alert">
<b>{{ title ?? 'The server refused this.' }}</b>
<b>{{ title ?? 'That did not work.' }}</b>
<pre class="raw">{{ text }}</pre>
</div>
</template>
1 change: 0 additions & 1 deletion apps/ui/src/components/InfoHint.vue
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,6 @@ onBeforeUnmount(() => {
v-if="pinned || hovered"
class="hint-bub"
:class="[props.align === 'end' ? 'to-end' : 'to-start', props.side === 'top' ? 'above' : 'below']"
role="tooltip"
>
<slot />
</span>
Expand Down
14 changes: 12 additions & 2 deletions apps/ui/src/components/LogViewer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ async function copy(): Promise<void> {
await navigator.clipboard.writeText(text);
toast('ok', 'Copied.');
} catch {
toast('error', 'Could not copy — your browser blocked clipboard access.');
toast('error', 'Copy failed — select it and copy by hand.');
}
}

Expand All @@ -94,7 +94,17 @@ onBeforeUnmount(() => {});
<slot name="actions" />
</div>

<div ref="box" class="lv-box" role="log" aria-live="polite" @scroll.passive="onScroll">
<!-- `tabindex="0"`: Safari does not make an overflow scroller focusable on its own, so without
it the log is unscrollable without a mouse. A focusable region needs a name. -->
<div
ref="box"
class="lv-box"
role="log"
aria-label="Log output"
aria-live="polite"
tabindex="0"
@scroll.passive="onScroll"
>
<p v-if="!rows.length" class="lv-empty">{{ emptyText ?? 'No output.' }}</p>
<template v-for="r in rows" :key="r.key">
<div v-if="r.separator" class="lv-sep"><span>{{ r.text }}</span></div>
Expand Down
2 changes: 1 addition & 1 deletion apps/ui/src/components/RefreshButton.vue
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ async function go(): Promise<void> {

<template>
<button
class="ghost sm refresh"
class="ghost refresh"
:disabled="spinning || busy"
:title="title ?? 'Read this again from the server'"
:aria-busy="spinning ? 'true' : undefined"
Expand Down
2 changes: 1 addition & 1 deletion apps/ui/src/components/SelectMenu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ const value = defineModel<string>({ required: true });
d="m5 13 4 4L19 7"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
Expand Down
37 changes: 21 additions & 16 deletions apps/ui/src/components/ShortcutSheet.vue
Original file line number Diff line number Diff line change
@@ -1,26 +1,31 @@
<script setup lang="ts">
/** The `?` sheet. Closes on Escape (handled globally), on the scrim, and on its own button. */
/**
* The `?` sheet.
*
* Reka owns modality, and that is the whole point of it not being a hand-rolled scrim any more:
* the previous version declared `aria-modal="true"` while doing none of what that promises —
* opening it left focus on `<body>`, and three Tabs put you on a nav link BEHIND the sheet. Reka
* brings the focus trap, focus restore on close, the scroll lock and Escape, and it is already
* this app's idiom (`HelpModal`, `FindingsModal`).
*/
import {
DialogRoot, DialogPortal, DialogOverlay, DialogContent, DialogTitle, DialogClose,
} from 'reka-ui';
import { SHORTCUTS } from '../composables/useShortcuts';

defineProps<{ open: boolean }>();
const emit = defineEmits<{ close: [] }>();
</script>

<template>
<Transition name="fade">
<div
v-if="open"
class="scrim"
role="dialog"
aria-modal="true"
aria-label="Keyboard shortcuts"
@click.self="emit('close')"
>
<div class="sheet">
<DialogRoot :open="open" @update:open="(v: boolean) => !v && emit('close')">
<DialogPortal>
<DialogOverlay class="scrim help-scrim" />
<DialogContent class="sheet" aria-label="Keyboard shortcuts">
<div class="row">
<h2 style="font-size: var(--t-lg); font-weight: 650">Keyboard shortcuts</h2>
<DialogTitle style="font-size: var(--t-lg); font-weight: 650">Keyboard shortcuts</DialogTitle>
<span class="grow" />
<button class="ghost sm" @click="emit('close')">Close</button>
<DialogClose class="ghost sm">Close</DialogClose>
</div>
<dl>
<template v-for="s in SHORTCUTS" :key="s.keys">
Expand All @@ -30,7 +35,7 @@ const emit = defineEmits<{ close: [] }>();
<dd>{{ s.what }}</dd>
</template>
</dl>
</div>
</div>
</Transition>
</DialogContent>
</DialogPortal>
</DialogRoot>
</template>
2 changes: 1 addition & 1 deletion apps/ui/src/composables/useDeploymentActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ export async function act(
void router.push(`/jobs/${encodeURIComponent(job.id)}`);
return;
}
actionError.value = 'The action was accepted, but the response carried nothing to follow.';
actionError.value = 'Started, but no job to follow — check Jobs.';
return;
}
if (r.status === 409) return onConflict(r.body);
Expand Down
Loading
Loading