M9 S5 (frontend): shared BaseModal for the standard modals + BaseInput in Account
- BaseModal.vue (new): the backdrop + dialog-panel shell (dimmed fixed overlay, bordered rounded panel, role=dialog, close on Escape + backdrop mousedown). Caller sizes/pads/shadows the panel via `panelClass`, picks start/center `align`, and sets an `ariaLabel` for header-less panels. - LabelsModal, CommandPalette, and the AppShell keyboard-shortcuts overlay drop their hand-rolled backdrop+panel shells and slot their content into BaseModal (~12 lines of overlay boilerplate each → gone). - NoteEditor deliberately keeps its own shell: its backdrop mousedown is drag-guarded and its Esc/⌘-Enter handling is bespoke (unsaved-edit safety), so folding it in would risk regressing the app's core editing surface (rule 28). - AccountView's one device-name field now uses the shared BaseInput. SettingsView is intentionally NOT converted — its rows are a horizontal label+control pattern (checkbox/number/text, direct value mutation), a different shape than BaseInput's vertical form field. Frontend-only; CI vue-tsc is the type/template gate (no local typecheck, rule 10). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
This commit is contained in:
@@ -7,6 +7,7 @@ import { useLabelsStore } from "../stores/labels";
|
|||||||
import { useSavedFiltersStore, type SavedFilter } from "../stores/savedFilters";
|
import { useSavedFiltersStore, type SavedFilter } from "../stores/savedFilters";
|
||||||
import { useReminderStore } from "../stores/reminders";
|
import { useReminderStore } from "../stores/reminders";
|
||||||
import { useUiStore } from "../stores/ui";
|
import { useUiStore } from "../stores/ui";
|
||||||
|
import BaseModal from "./BaseModal.vue";
|
||||||
import CommandPalette from "./CommandPalette.vue";
|
import CommandPalette from "./CommandPalette.vue";
|
||||||
import Icon from "./Icon.vue";
|
import Icon from "./Icon.vue";
|
||||||
import ImportNotes from "./ImportNotes.vue";
|
import ImportNotes from "./ImportNotes.vue";
|
||||||
@@ -382,16 +383,12 @@ async function signOut() {
|
|||||||
|
|
||||||
<CommandPalette v-if="paletteOpen" @close="paletteOpen = false" />
|
<CommandPalette v-if="paletteOpen" @close="paletteOpen = false" />
|
||||||
|
|
||||||
<div
|
<BaseModal
|
||||||
v-if="showShortcuts"
|
v-if="showShortcuts"
|
||||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
|
panel-class="w-full max-w-sm p-5 shadow-xl"
|
||||||
@click.self="showShortcuts = false"
|
align="center"
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="w-full max-w-sm rounded-xl border border-neutral-200 bg-white p-5 shadow-xl dark:border-neutral-700 dark:bg-neutral-900"
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-label="Keyboard shortcuts"
|
aria-label="Keyboard shortcuts"
|
||||||
|
@close="showShortcuts = false"
|
||||||
>
|
>
|
||||||
<div class="mb-3 flex items-center justify-between">
|
<div class="mb-3 flex items-center justify-between">
|
||||||
<h2 class="text-sm font-semibold">Keyboard shortcuts</h2>
|
<h2 class="text-sm font-semibold">Keyboard shortcuts</h2>
|
||||||
@@ -417,7 +414,6 @@ async function signOut() {
|
|||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</BaseModal>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// The backdrop + centered dialog panel every modal shares: a fixed dimmed overlay and
|
||||||
|
// a bordered, rounded panel with role="dialog". Emits `close` on Escape (while focus is
|
||||||
|
// inside the panel — matching the app's existing modals) and on a backdrop pointer-down
|
||||||
|
// (mousedown outside the panel). The caller sizes/shadows/pads the panel via `panelClass`
|
||||||
|
// and fills it (its own header + content) through the default slot.
|
||||||
|
//
|
||||||
|
// NoteEditor deliberately does NOT use this — its backdrop mousedown is drag-guarded and
|
||||||
|
// its Escape/⌘-Enter handling is bespoke (unsaved-edit safety), so it keeps its own shell.
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
/** Panel size / shadow / padding classes (structure — rounded/border/bg — is fixed). */
|
||||||
|
panelClass?: string;
|
||||||
|
/** Vertical placement of the panel within the overlay. */
|
||||||
|
align?: "start" | "center";
|
||||||
|
/** aria-label for the panel when it has no visible heading (e.g. the command palette). */
|
||||||
|
ariaLabel?: string;
|
||||||
|
}>(),
|
||||||
|
{ panelClass: "w-full max-w-sm shadow-xl", align: "start" },
|
||||||
|
);
|
||||||
|
const emit = defineEmits<{ (e: "close"): void }>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-50 flex justify-center overflow-y-auto bg-black/40 p-4"
|
||||||
|
:class="align === 'center' ? 'items-center' : 'items-start pt-[12vh]'"
|
||||||
|
@mousedown.self="emit('close')"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="rounded-xl border border-neutral-200 bg-white dark:border-neutral-700 dark:bg-neutral-900"
|
||||||
|
:class="panelClass"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
:aria-label="ariaLabel"
|
||||||
|
@keydown.esc="emit('close')"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -4,6 +4,7 @@ import { useRouter } from "vue-router";
|
|||||||
import { useSessionStore } from "../stores/session";
|
import { useSessionStore } from "../stores/session";
|
||||||
import { useTitlesStore } from "../stores/titles";
|
import { useTitlesStore } from "../stores/titles";
|
||||||
import { useUiStore } from "../stores/ui";
|
import { useUiStore } from "../stores/ui";
|
||||||
|
import BaseModal from "./BaseModal.vue";
|
||||||
|
|
||||||
const emit = defineEmits<{ (e: "close"): void }>();
|
const emit = defineEmits<{ (e: "close"): void }>();
|
||||||
|
|
||||||
@@ -94,15 +95,10 @@ onMounted(async () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<BaseModal
|
||||||
class="fixed inset-0 z-50 flex items-start justify-center bg-black/40 p-4 pt-[12vh]"
|
panel-class="w-full max-w-lg overflow-hidden shadow-2xl"
|
||||||
@mousedown.self="emit('close')"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="w-full max-w-lg overflow-hidden rounded-xl border border-neutral-200 bg-white shadow-2xl dark:border-neutral-700 dark:bg-neutral-900"
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-label="Command palette"
|
aria-label="Command palette"
|
||||||
|
@close="emit('close')"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
ref="input"
|
ref="input"
|
||||||
@@ -135,6 +131,5 @@ onMounted(async () => {
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p v-else class="px-4 py-6 text-center text-sm text-neutral-400">No matches</p>
|
<p v-else class="px-4 py-6 text-center text-sm text-neutral-400">No matches</p>
|
||||||
</div>
|
</BaseModal>
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { computed, ref } from "vue";
|
import { computed, ref } from "vue";
|
||||||
import { useLabelsStore, type Label } from "../stores/labels";
|
import { useLabelsStore, type Label } from "../stores/labels";
|
||||||
import { NOTE_COLOR_KEYS, NOTE_COLOR_LABELS, NOTE_SWATCH_CLASSES, type NoteColor } from "../notes/colors";
|
import { NOTE_COLOR_KEYS, NOTE_COLOR_LABELS, NOTE_SWATCH_CLASSES, type NoteColor } from "../notes/colors";
|
||||||
|
import BaseModal from "./BaseModal.vue";
|
||||||
import Icon from "./Icon.vue";
|
import Icon from "./Icon.vue";
|
||||||
|
|
||||||
const emit = defineEmits<{ (e: "close"): void }>();
|
const emit = defineEmits<{ (e: "close"): void }>();
|
||||||
@@ -54,16 +55,7 @@ async function doMerge(sourceId: string, targetId: string) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<BaseModal panel-class="w-full max-w-sm shadow-xl" @close="emit('close')">
|
||||||
class="fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/40 p-4 pt-[12vh]"
|
|
||||||
@mousedown.self="emit('close')"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="w-full max-w-sm rounded-xl border border-neutral-200 bg-white shadow-xl dark:border-neutral-700 dark:bg-neutral-900"
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
@keydown.esc="emit('close')"
|
|
||||||
>
|
|
||||||
<div class="flex items-center justify-between border-b border-neutral-100 px-4 py-3 dark:border-neutral-800">
|
<div class="flex items-center justify-between border-b border-neutral-100 px-4 py-3 dark:border-neutral-800">
|
||||||
<h2 class="text-sm font-semibold">Manage labels</h2>
|
<h2 class="text-sm font-semibold">Manage labels</h2>
|
||||||
<button type="button" class="icon-btn" aria-label="Close" @click="emit('close')"><Icon name="close" /></button>
|
<button type="button" class="icon-btn" aria-label="Close" @click="emit('close')"><Icon name="close" /></button>
|
||||||
@@ -161,6 +153,5 @@ async function doMerge(sourceId: string, targetId: string) {
|
|||||||
No labels yet — create one above.
|
No labels yet — create one above.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</BaseModal>
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { onMounted, ref } from "vue";
|
|||||||
import { useDevicesStore } from "../stores/devices";
|
import { useDevicesStore } from "../stores/devices";
|
||||||
import { useUiStore } from "../stores/ui";
|
import { useUiStore } from "../stores/ui";
|
||||||
import BaseButton from "../components/BaseButton.vue";
|
import BaseButton from "../components/BaseButton.vue";
|
||||||
|
import BaseInput from "../components/BaseInput.vue";
|
||||||
import Icon from "../components/Icon.vue";
|
import Icon from "../components/Icon.vue";
|
||||||
|
|
||||||
// Per-user (not admin) management of linked native clients — the Tauri desktop and
|
// Per-user (not admin) management of linked native clients — the Tauri desktop and
|
||||||
@@ -124,18 +125,13 @@ onMounted(load);
|
|||||||
|
|
||||||
<!-- Create a device token -->
|
<!-- Create a device token -->
|
||||||
<form class="mb-8 flex items-end gap-3" @submit.prevent="link">
|
<form class="mb-8 flex items-end gap-3" @submit.prevent="link">
|
||||||
<div class="flex flex-1 flex-col gap-1">
|
<BaseInput
|
||||||
<label for="device-name" class="text-sm font-medium text-neutral-800 dark:text-neutral-200"
|
|
||||||
>Link a new device</label
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
id="device-name"
|
id="device-name"
|
||||||
v-model="newName"
|
v-model="newName"
|
||||||
type="text"
|
label="Link a new device"
|
||||||
placeholder="e.g. My laptop, Pixel phone"
|
placeholder="e.g. My laptop, Pixel phone"
|
||||||
class="rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-900 shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100"
|
class="flex-1"
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
<BaseButton type="submit" :loading="creating">Create token</BaseButton>
|
<BaseButton type="submit" :loading="creating">Create token</BaseButton>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user