diff --git a/apps/ui/src/App.vue b/apps/ui/src/App.vue index 59c398d..b24f6c8 100644 --- a/apps/ui/src/App.vue +++ b/apps/ui/src/App.vue @@ -191,7 +191,7 @@ void settings;
Can't reach the server
- {{ authState.user.username }} + {{ authState.user.username }} token access diff --git a/apps/ui/src/components/ActionButton.vue b/apps/ui/src/components/ActionButton.vue index cce9442..11e4a40 100644 --- a/apps/ui/src/components/ActionButton.vue +++ b/apps/ui/src/components/ActionButton.vue @@ -25,13 +25,13 @@ * 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; @@ -39,6 +39,17 @@ const props = defineProps<{ 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 | null = null; @@ -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(); @@ -67,7 +86,9 @@ onBeforeUnmount(disarm); + + diff --git a/apps/ui/src/components/CommandPalette.vue b/apps/ui/src/components/CommandPalette.vue index 68ac358..4cc461c 100644 --- a/apps/ui/src/components/CommandPalette.vue +++ b/apps/ui/src/components/CommandPalette.vue @@ -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'; @@ -169,10 +170,7 @@ defineExpose({ show });