@olares/lares-mobile 0.15.4 → 0.15.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +6 -2
  2. package/package.json +2 -2
  3. package/src/App.vue +924 -184
  4. package/src/chat/ChatBar.vue +2 -2
  5. package/src/chat/Composer.vue +467 -16
  6. package/src/chat/Deliverables.vue +22 -10
  7. package/src/chat/HistoryPanel.vue +747 -55
  8. package/src/chat/MessageActions.vue +88 -34
  9. package/src/chat/QuestionCard.vue +5 -5
  10. package/src/chat/StageRow.vue +8 -5
  11. package/src/chat/Transcript.vue +20 -6
  12. package/src/chat/composer-tokens.js +32 -0
  13. package/src/chat/format.js +37 -0
  14. package/src/desktop/DesktopComposer.vue +871 -0
  15. package/src/desktop/DesktopContextMeter.vue +81 -0
  16. package/src/desktop/DesktopGoal.vue +88 -0
  17. package/src/desktop/DesktopPreview.vue +58 -0
  18. package/src/desktop/DesktopPreviewTabs.vue +82 -0
  19. package/src/desktop/DesktopQuestions.vue +118 -0
  20. package/src/desktop/DesktopQueue.vue +203 -0
  21. package/src/desktop/DesktopShell.vue +203 -0
  22. package/src/desktop/DesktopSidebar.vue +645 -0
  23. package/src/desktop/DesktopTodos.vue +52 -0
  24. package/src/desktop/DirectoryBrowser.vue +170 -0
  25. package/src/desktop/DirectoryNewFolder.vue +124 -0
  26. package/src/desktop/ui/PcDialog.vue +85 -0
  27. package/src/desktop/ui/PcPopover.vue +110 -0
  28. package/src/desktop/ui/PcTooltip.vue +95 -0
  29. package/src/desktop/ui/place.js +24 -0
  30. package/src/desktop/workspace-view.js +121 -0
  31. package/src/host.js +20 -2
  32. package/src/index.d.ts +2 -0
  33. package/src/mobile/MobileDirectoryNewFolder.vue +121 -0
  34. package/src/mobile/MobileDirectorySheet.vue +162 -0
  35. package/src/mobile/MobileNewSession.vue +217 -0
  36. package/src/mobile/MobileShell.vue +367 -0
  37. package/src/motion.css +166 -0
  38. package/src/preview/Preview.vue +91 -150
  39. package/src/preview/PreviewBody.vue +185 -0
  40. package/src/settings/AgentSettings.vue +126 -36
  41. package/src/settings/Select.vue +183 -0
  42. package/src/settings/SettingRow.vue +5 -4
  43. package/src/settings/Sheet.vue +4 -4
  44. package/src/styles.css +9 -0
@@ -0,0 +1,52 @@
1
+ <template>
2
+ <Transition name="lares-panel">
3
+ <div v-if="rows.length" class="desktop-todos">
4
+ <button type="button" class="desktop-todos__header" :aria-expanded="open" @click="open = !open">
5
+ <span>{{ t("todo.progress", { done, total: rows.length }) }}</span>
6
+ <span class="desktop-todos__chevron" :data-open="open ? 'true' : 'false'" aria-hidden="true">⌄</span>
7
+ </button>
8
+ <ul class="desktop-todos__list" :data-open="open ? 'true' : 'false'">
9
+ <li v-for="(row, index) in rows" :key="`${index}-${row.content}`" :data-status="row.status">
10
+ <span aria-hidden="true">{{ row.status === "completed" ? "✓" : row.status === "in_progress" ? "●" : "○" }}</span>
11
+ <span>{{ row.content }}</span>
12
+ </li>
13
+ </ul>
14
+ </div>
15
+ </Transition>
16
+ </template>
17
+
18
+ <script>
19
+ export default {
20
+ name: "LaresDesktopTodos",
21
+ props: {
22
+ rows: { type: Array, default: () => [] },
23
+ t: { type: Function, required: true },
24
+ },
25
+ data() {
26
+ return { open: true };
27
+ },
28
+ computed: {
29
+ done() {
30
+ return this.rows.filter((row) => row.status === "completed").length;
31
+ },
32
+ },
33
+ };
34
+ </script>
35
+
36
+ <style scoped>
37
+ .desktop-todos { box-sizing:border-box; width:calc(100% - 24px); margin:0 auto -3px; border:1px solid var(--q-separator); border-bottom:0; border-radius:12px 12px 0 0; padding:3px 0 6px; background:var(--q-background-3); color:var(--q-ink-2); font-size:13px; }
38
+ .desktop-todos__header { display:flex; width:100%; height:32px; align-items:center; justify-content:space-between; border:0; padding:0 12px; background:transparent; color:inherit; font:inherit; font-weight:500; cursor:pointer; }
39
+ .desktop-todos__chevron { display:inline-block; transition:transform var(--lares-duration-fast, 120ms) var(--lares-ease-out, ease); }
40
+ .desktop-todos__chevron[data-open="false"] { transform:rotate(180deg); }
41
+ .desktop-todos__list { max-height:0; overflow:hidden; margin:0; padding:0 12px; list-style:none; opacity:0; transition:max-height var(--lares-duration-normal, 220ms) var(--lares-ease-out, cubic-bezier(0.32, 0.72, 0, 1)), opacity var(--lares-duration-normal, 220ms) var(--lares-ease-out, cubic-bezier(0.32, 0.72, 0, 1)); }
42
+ .desktop-todos__list[data-open="true"] { max-height:120px; overflow:auto; opacity:1; }
43
+ @media (prefers-reduced-motion: reduce) {
44
+ .desktop-todos__chevron,
45
+ .desktop-todos__list { transition:none; }
46
+ .desktop-todos__list[data-open="false"] { opacity:1; }
47
+ }
48
+ .desktop-todos li { display:flex; min-height:25px; align-items:flex-start; gap:8px; }
49
+ .desktop-todos li > span:first-child { color:var(--q-ink-3); }
50
+ .desktop-todos li[data-status="in_progress"] > span:first-child { color:var(--q-blue-default); }
51
+ .desktop-todos li[data-status="completed"] { color:var(--q-ink-3); text-decoration:line-through; }
52
+ </style>
@@ -0,0 +1,170 @@
1
+ <template>
2
+ <LaresPcDialog
3
+ :open="state.open"
4
+ :title="t('desktop.chooseFolder')"
5
+ :close-label="t('desktop.close')"
6
+ :busy="state.creating || state.creatingFolder"
7
+ :width="620"
8
+ @close="$emit('close')"
9
+ >
10
+ <div class="directory-browser">
11
+ <nav v-if="state.listing" class="directory-browser__crumbs" :aria-label="t('desktop.folderPath')">
12
+ <button v-for="crumb in state.listing.crumbs" :key="crumb.path" type="button" @click="$emit('browse', crumb.path)">
13
+ {{ crumb.path === state.listing.home ? t("desktop.home") : crumb.name }}
14
+ </button>
15
+ </nav>
16
+ <div class="directory-browser__body">
17
+ <p v-if="state.loading" class="directory-browser__status">{{ t("desktop.loadingFolders") }}</p>
18
+ <p v-else-if="state.error" class="directory-browser__error">{{ state.error }}</p>
19
+ <template v-else-if="state.listing">
20
+ <div
21
+ v-for="entry in visibleEntries"
22
+ :key="entry.path"
23
+ class="directory-browser__row"
24
+ role="button"
25
+ tabindex="0"
26
+ @dblclick="$emit('browse', entry.path)"
27
+ @click="selected = entry.path"
28
+ @keydown.enter="$emit('browse', entry.path)"
29
+ :data-selected="selected === entry.path"
30
+ >
31
+ <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3.5 7.5h6l1.6 2H20.5v9H3.5z" /></svg>
32
+ <span>{{ entry.name }}</span>
33
+ <button type="button" class="directory-browser__open" @click.stop="$emit('browse', entry.path)">
34
+ <svg viewBox="0 0 24 24" aria-hidden="true"><path d="m9 6 6 6-6 6" /></svg>
35
+ </button>
36
+ </div>
37
+ <p v-if="visibleEntries.length === 0" class="directory-browser__status">{{ t("desktop.noFolders") }}</p>
38
+ </template>
39
+ </div>
40
+ </div>
41
+ <template #footer>
42
+ <div class="directory-browser__footer">
43
+ <p class="directory-browser__path">{{ selected || state.listing?.path || "" }}</p>
44
+ <div class="directory-browser__footer-row">
45
+ <div class="directory-browser__footer-left">
46
+ <button
47
+ type="button"
48
+ class="directory-browser__new-folder"
49
+ :disabled="!state.listing?.path || state.loading || state.creatingFolder"
50
+ @click="newFolderOpen = true"
51
+ >
52
+ <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 5v14M5 12h14" /></svg>
53
+ <span>{{ t("desktop.newFolder") }}</span>
54
+ </button>
55
+ <label class="directory-browser__hidden">
56
+ <input
57
+ type="checkbox"
58
+ :checked="state.showHidden"
59
+ @change="$emit('toggle-hidden', $event.target.checked)"
60
+ />
61
+ <span>{{ t("desktop.showHiddenFiles") }}</span>
62
+ </label>
63
+ </div>
64
+ <div class="directory-browser__footer-right">
65
+ <button type="button" class="directory-browser__cancel" :disabled="state.creating" @click="$emit('close')">
66
+ {{ t("desktop.cancel") }}
67
+ </button>
68
+ <button
69
+ type="button"
70
+ class="directory-browser__choose"
71
+ :disabled="state.loading || state.creating || !(selected || state.listing?.path)"
72
+ @click="$emit('choose', selected || state.listing.path)"
73
+ >
74
+ {{ state.creating ? t("desktop.addingWorkspace") : t("desktop.addWorkspace") }}
75
+ </button>
76
+ </div>
77
+ </div>
78
+ </div>
79
+ </template>
80
+ </LaresPcDialog>
81
+ <LaresDirectoryNewFolder
82
+ :open="newFolderOpen"
83
+ :busy="state.creatingFolder"
84
+ :t="t"
85
+ @close="newFolderOpen = false"
86
+ @create="onCreateFolder"
87
+ />
88
+ </template>
89
+
90
+ <script>
91
+ import LaresPcDialog from "./ui/PcDialog.vue";
92
+ import LaresDirectoryNewFolder from "./DirectoryNewFolder.vue";
93
+
94
+ export default {
95
+ name: "LaresDirectoryBrowser",
96
+ components: { LaresPcDialog, LaresDirectoryNewFolder },
97
+ props: {
98
+ state: { type: Object, required: true },
99
+ t: { type: Function, required: true },
100
+ },
101
+ emits: ["close", "browse", "choose", "create-folder", "toggle-hidden"],
102
+ data() {
103
+ return { selected: "", newFolderOpen: false };
104
+ },
105
+ computed: {
106
+ visibleEntries() {
107
+ const entries = this.state.listing?.entries ?? [];
108
+ return this.state.showHidden ? entries : entries.filter((entry) => !entry.hidden);
109
+ },
110
+ },
111
+ watch: {
112
+ "state.listing.path"() {
113
+ this.selected = this.state.selectPath || "";
114
+ },
115
+ "state.selectPath"(path) {
116
+ if (path) this.selected = path;
117
+ },
118
+ "state.creatingFolder"(busy, was) {
119
+ if (was && !busy && !this.state.error) this.newFolderOpen = false;
120
+ },
121
+ "state.open"(open) {
122
+ if (!open) {
123
+ this.newFolderOpen = false;
124
+ this.selected = "";
125
+ }
126
+ },
127
+ },
128
+ methods: {
129
+ onCreateFolder(name) {
130
+ this.$emit("create-folder", name);
131
+ },
132
+ },
133
+ };
134
+ </script>
135
+
136
+ <style scoped>
137
+ .directory-browser { display:flex; height:min(440px, calc(100vh - 180px)); flex-direction:column; margin:-16px; }
138
+ .directory-browser svg { width:18px; height:18px; fill:none; stroke:currentColor; stroke-width:1.7; stroke-linecap:round; stroke-linejoin:round; }
139
+ .directory-browser__crumbs { display:flex; gap:3px; min-height:38px; align-items:center; overflow:auto; padding:0 14px; border-bottom:1px solid var(--q-separator); }
140
+ .directory-browser__crumbs button { flex-shrink:0; border:0; border-radius:5px; padding:4px 6px; background:transparent; color:var(--q-ink-3); font-size:14px; }
141
+ .directory-browser__crumbs button:hover { background:var(--q-background-hover); color:var(--q-ink-1); }
142
+ .directory-browser__body { flex:1; min-height:0; overflow:auto; padding:8px; }
143
+ .directory-browser__row { display:flex; width:100%; align-items:center; gap:9px; border:0; border-radius:7px; padding:8px 10px; background:transparent; color:var(--q-ink-1); text-align:left; }
144
+ .directory-browser__row:hover,
145
+ .directory-browser__row[data-selected="true"] { background:var(--q-background-hover); }
146
+ .directory-browser__row > span { min-width:0; flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
147
+ .directory-browser__open { display:grid; width:26px; height:26px; place-items:center; border:0; background:transparent; color:var(--q-ink-3); }
148
+ .directory-browser__status,
149
+ .directory-browser__error { margin:20px; color:var(--q-ink-3); font-size:15px; text-align:center; }
150
+ .directory-browser__error { color:var(--q-orange-default); }
151
+ .directory-browser__footer { display:flex; width:100%; flex-direction:column; gap:10px; }
152
+ .directory-browser__path { min-width:0; margin:0; overflow:hidden; color:var(--q-ink-3); font-size:13px; text-overflow:ellipsis; white-space:nowrap; text-align:left; }
153
+ .directory-browser__footer-row { display:flex; align-items:center; justify-content:space-between; gap:12px; }
154
+ .directory-browser__footer-left { display:flex; min-width:0; flex:1; align-items:center; gap:12px; }
155
+ .directory-browser__footer-right { display:flex; flex-shrink:0; align-items:center; gap:8px; }
156
+ .directory-browser__new-folder { display:inline-flex; align-items:center; gap:6px; border:1px solid var(--q-separator); border-radius:999px; padding:0 10px; height:32px; background:transparent; color:var(--q-ink-2); font:inherit; font-size:13px; white-space:nowrap; }
157
+ .directory-browser__new-folder svg { width:14px; height:14px; }
158
+ .directory-browser__hidden { display:inline-flex; align-items:center; gap:6px; color:var(--q-ink-3); font-size:13px; white-space:nowrap; cursor:pointer; }
159
+ .directory-browser__hidden input { width:15px; height:15px; margin:0; }
160
+ .directory-browser__cancel,
161
+ .directory-browser__choose { min-width:76px; height:32px; border-radius:999px; padding:0 12px; }
162
+ .directory-browser__cancel { border:1px solid var(--q-separator); background:transparent; color:var(--q-ink-2); }
163
+ .directory-browser__choose { border:0; background:var(--q-blue-default); color:var(--q-ink-on-brand); }
164
+ .directory-browser button,
165
+ .directory-browser__cancel,
166
+ .directory-browser__choose { cursor:pointer; font:inherit; }
167
+ .directory-browser button:disabled,
168
+ .directory-browser__cancel:disabled,
169
+ .directory-browser__choose:disabled { opacity:.5; cursor:default; }
170
+ </style>
@@ -0,0 +1,124 @@
1
+ <template>
2
+ <LaresPcDialog
3
+ :open="open"
4
+ :title="t('desktop.newFolder')"
5
+ :close-label="t('desktop.close')"
6
+ :busy="busy"
7
+ :width="420"
8
+ @close="$emit('close')"
9
+ >
10
+ <div class="directory-new-folder">
11
+ <input
12
+ ref="input"
13
+ v-model="value"
14
+ class="directory-new-folder__input"
15
+ :placeholder="t('desktop.untitledFolder')"
16
+ :disabled="busy"
17
+ @keydown.enter.prevent="submit"
18
+ />
19
+ </div>
20
+ <template #footer>
21
+ <button type="button" class="directory-new-folder__cancel" :disabled="busy" @click="$emit('close')">
22
+ {{ t("desktop.cancel") }}
23
+ </button>
24
+ <button
25
+ type="button"
26
+ class="directory-new-folder__create"
27
+ :disabled="busy || !value.trim()"
28
+ @click="submit"
29
+ >
30
+ {{ t("desktop.createFolder") }}
31
+ </button>
32
+ </template>
33
+ </LaresPcDialog>
34
+ </template>
35
+
36
+ <script>
37
+ import LaresPcDialog from "./ui/PcDialog.vue";
38
+
39
+ export default {
40
+ name: "LaresDirectoryNewFolder",
41
+ components: { LaresPcDialog },
42
+ props: {
43
+ open: { type: Boolean, default: false },
44
+ busy: { type: Boolean, default: false },
45
+ t: { type: Function, required: true },
46
+ },
47
+ emits: ["close", "create"],
48
+ data() {
49
+ return { value: "" };
50
+ },
51
+ watch: {
52
+ open(value) {
53
+ if (!value) {
54
+ this.value = "";
55
+ return;
56
+ }
57
+ this.value = this.t("desktop.untitledFolder");
58
+ this.$nextTick(() => {
59
+ this.$refs.input?.focus();
60
+ this.$refs.input?.select();
61
+ });
62
+ },
63
+ },
64
+ methods: {
65
+ submit() {
66
+ const name = this.value.trim();
67
+ if (!name || this.busy) return;
68
+ this.$emit("create", name);
69
+ },
70
+ },
71
+ };
72
+ </script>
73
+
74
+ <style scoped>
75
+ .directory-new-folder {
76
+ padding-inline: 12px;
77
+ }
78
+
79
+ .directory-new-folder__input {
80
+ box-sizing: border-box;
81
+ width: 100%;
82
+ height: 40px;
83
+ border: 1px solid var(--q-input-stroke, var(--q-separator));
84
+ border-radius: 999px;
85
+ padding: 0 14px;
86
+ background: transparent;
87
+ color: var(--q-ink-1);
88
+ font: inherit;
89
+ font-size: 15px;
90
+ }
91
+
92
+ .directory-new-folder__input:focus {
93
+ outline: none;
94
+ border-color: var(--q-blue-default);
95
+ }
96
+
97
+ .directory-new-folder__cancel,
98
+ .directory-new-folder__create {
99
+ min-width: 76px;
100
+ height: 32px;
101
+ border-radius: 999px;
102
+ padding: 0 14px;
103
+ font: inherit;
104
+ cursor: pointer;
105
+ }
106
+
107
+ .directory-new-folder__cancel {
108
+ border: 1px solid var(--q-separator);
109
+ background: transparent;
110
+ color: var(--q-ink-2);
111
+ }
112
+
113
+ .directory-new-folder__create {
114
+ border: 0;
115
+ background: var(--q-blue-default);
116
+ color: var(--q-ink-on-brand);
117
+ }
118
+
119
+ .directory-new-folder__cancel:disabled,
120
+ .directory-new-folder__create:disabled {
121
+ opacity: 0.5;
122
+ cursor: default;
123
+ }
124
+ </style>
@@ -0,0 +1,85 @@
1
+ <template>
2
+ <Teleport to="body">
3
+ <Transition name="lares-dialog">
4
+ <div v-if="open" class="lares-pc-dialog" role="dialog" aria-modal="true" :aria-labelledby="titleId">
5
+ <button class="lares-pc-dialog__backdrop" type="button" :aria-label="closeLabel" @click="close" />
6
+ <section class="lares-pc-dialog__surface" :style="{ width: surfaceWidth }">
7
+ <header class="lares-pc-dialog__header">
8
+ <div class="lares-pc-dialog__title">
9
+ <h2 :id="titleId">{{ title }}</h2>
10
+ <button type="button" class="lares-pc-dialog__close" :aria-label="closeLabel" @click="close">
11
+ <svg viewBox="0 0 24 24" aria-hidden="true"><path d="m7 7 10 10M17 7 7 17" /></svg>
12
+ </button>
13
+ </div>
14
+ <p v-if="description">{{ description }}</p>
15
+ </header>
16
+ <div class="lares-pc-dialog__body">
17
+ <slot />
18
+ </div>
19
+ <footer v-if="$slots.footer" class="lares-pc-dialog__footer">
20
+ <slot name="footer" />
21
+ </footer>
22
+ </section>
23
+ </div>
24
+ </Transition>
25
+ </Teleport>
26
+ </template>
27
+
28
+ <script>
29
+ let dialogId = 0;
30
+
31
+ export default {
32
+ name: "LaresPcDialog",
33
+ props: {
34
+ open: { type: Boolean, default: false },
35
+ title: { type: String, required: true },
36
+ description: { type: String, default: "" },
37
+ closeLabel: { type: String, default: "Close" },
38
+ width: { type: [Number, String], default: 440 },
39
+ busy: { type: Boolean, default: false },
40
+ },
41
+ emits: ["close"],
42
+ data() {
43
+ dialogId += 1;
44
+ return { titleId: `lares-pc-dialog-${dialogId}` };
45
+ },
46
+ computed: {
47
+ surfaceWidth() {
48
+ return typeof this.width === "number" ? `${this.width}px` : this.width;
49
+ },
50
+ },
51
+ watch: {
52
+ open(value) {
53
+ if (value) window.addEventListener("keydown", this.onKey);
54
+ else window.removeEventListener("keydown", this.onKey);
55
+ },
56
+ },
57
+ beforeUnmount() {
58
+ window.removeEventListener("keydown", this.onKey);
59
+ },
60
+ methods: {
61
+ close() {
62
+ if (!this.busy) this.$emit("close");
63
+ },
64
+ onKey(event) {
65
+ if (event.key === "Escape") this.close();
66
+ },
67
+ },
68
+ };
69
+ </script>
70
+
71
+ <style scoped>
72
+ .lares-pc-dialog { position:fixed; z-index:10000; inset:0; display:grid; place-items:center; color:var(--q-ink-1); }
73
+ .lares-pc-dialog__backdrop { position:absolute; inset:0; border:0; background:rgb(0 0 0 / 46%); }
74
+ .lares-pc-dialog__surface { position:relative; display:flex; max-width:calc(100vw - 48px); max-height:calc(100vh - 48px); flex-direction:column; overflow:hidden; border:1px solid var(--q-separator); border-radius:12px; background:var(--q-background-1); box-shadow:0 18px 60px rgb(0 0 0 / 32%); }
75
+ /* 12px + the 30px close button + 12px keeps a title-only header at 54px. */
76
+ .lares-pc-dialog__header { display:flex; flex-shrink:0; flex-direction:column; padding:12px 16px; border-bottom:1px solid var(--q-separator); }
77
+ .lares-pc-dialog__title { display:flex; min-height:30px; align-items:center; justify-content:space-between; gap:16px; }
78
+ .lares-pc-dialog__header h2 { margin:0; font-size:17px; font-weight:600; line-height:22px; }
79
+ .lares-pc-dialog__header p { margin:2px 0 0; color:var(--q-ink-3); font-size:13px; line-height:18px; }
80
+ .lares-pc-dialog__close { display:grid; width:30px; height:30px; flex-shrink:0; place-items:center; border:0; border-radius:7px; background:transparent; color:var(--q-ink-2); cursor:pointer; }
81
+ .lares-pc-dialog__close:hover { background:var(--q-background-hover); }
82
+ .lares-pc-dialog__close svg { width:18px; height:18px; fill:none; stroke:currentColor; stroke-width:1.7; stroke-linecap:round; }
83
+ .lares-pc-dialog__body { min-height:0; overflow:auto; padding:16px; }
84
+ .lares-pc-dialog__footer { display:flex; min-height:58px; flex-shrink:0; align-items:center; justify-content:flex-end; gap:8px; padding:0 16px; border-top:1px solid var(--q-separator); }
85
+ </style>
@@ -0,0 +1,110 @@
1
+ <template>
2
+ <span ref="anchor" class="lares-pc-popover">
3
+ <slot name="trigger" :open="modelValue" :toggle="toggle" :close="close" />
4
+ <Teleport to="body">
5
+ <Transition name="lares-popover">
6
+ <div v-if="modelValue" class="lares-pc-popover__layer" @mousedown.self="close">
7
+ <div
8
+ ref="panel"
9
+ class="lares-pc-popover__panel"
10
+ :style="panelStyle"
11
+ role="menu"
12
+ @mousedown.stop
13
+ >
14
+ <slot :close="close" />
15
+ </div>
16
+ </div>
17
+ </Transition>
18
+ </Teleport>
19
+ </span>
20
+ </template>
21
+
22
+ <script>
23
+ import { placePanel } from "./place.js";
24
+
25
+ export default {
26
+ name: "LaresPcPopover",
27
+ props: {
28
+ modelValue: { type: Boolean, default: false },
29
+ width: { type: [Number, String], default: 220 },
30
+ placement: { type: String, default: "bottom-end" },
31
+ minPanelHeight: { type: Number, default: 120 },
32
+ },
33
+ emits: ["update:modelValue"],
34
+ data() {
35
+ return {
36
+ panel: { top: "", bottom: "", left: "0px", width: "220px", maxHeight: "320px" },
37
+ };
38
+ },
39
+ computed: {
40
+ panelStyle() {
41
+ return this.panel;
42
+ },
43
+ },
44
+ watch: {
45
+ modelValue(open) {
46
+ if (open) {
47
+ window.addEventListener("resize", this.place);
48
+ window.addEventListener("scroll", this.place, true);
49
+ window.addEventListener("keydown", this.onKey);
50
+ this.$nextTick(this.place);
51
+ } else {
52
+ this.unlisten();
53
+ }
54
+ },
55
+ },
56
+ beforeUnmount() {
57
+ this.unlisten();
58
+ },
59
+ methods: {
60
+ toggle() {
61
+ this.$emit("update:modelValue", !this.modelValue);
62
+ },
63
+ close() {
64
+ this.$emit("update:modelValue", false);
65
+ },
66
+ unlisten() {
67
+ window.removeEventListener("resize", this.place);
68
+ window.removeEventListener("scroll", this.place, true);
69
+ window.removeEventListener("keydown", this.onKey);
70
+ },
71
+ onKey(event) {
72
+ if (event.key === "Escape") this.close();
73
+ },
74
+ place() {
75
+ const anchor = this.$refs.anchor;
76
+ if (!anchor) return;
77
+ const box = anchor.getBoundingClientRect();
78
+ const width = this.width === "anchor"
79
+ ? box.width
80
+ : typeof this.width === "number"
81
+ ? this.width
82
+ : Number.parseFloat(this.width) || box.width;
83
+ this.panel = placePanel({
84
+ box,
85
+ viewport: { width: window.innerWidth, height: window.innerHeight },
86
+ width,
87
+ placement: this.placement,
88
+ minHeight: this.minPanelHeight,
89
+ });
90
+ },
91
+ },
92
+ };
93
+ </script>
94
+
95
+ <style scoped>
96
+ .lares-pc-popover { display:inline-flex; min-width:0; }
97
+ .lares-pc-popover__layer { position:fixed; z-index:9000; inset:0; }
98
+ .lares-pc-popover__panel {
99
+ position:fixed;
100
+ box-sizing:border-box;
101
+ overflow-x:hidden;
102
+ overflow-y:auto;
103
+ border:1px solid var(--q-separator);
104
+ border-radius:8px;
105
+ padding:6px;
106
+ background:var(--q-background-1);
107
+ color:var(--q-ink-1);
108
+ box-shadow:0 8px 28px rgb(0 0 0 / 20%);
109
+ }
110
+ </style>
@@ -0,0 +1,95 @@
1
+ <template>
2
+ <span
3
+ ref="anchor"
4
+ class="lares-pc-tooltip"
5
+ @mouseenter="schedule"
6
+ @mouseleave="hide"
7
+ @focusin="schedule"
8
+ @focusout="hide"
9
+ @click="hide"
10
+ >
11
+ <slot />
12
+ <Teleport to="body">
13
+ <div v-if="shown" class="lares-pc-tooltip__chip" role="tooltip" :style="chip">{{ label }}</div>
14
+ </Teleport>
15
+ </span>
16
+ </template>
17
+
18
+ <script>
19
+ export default {
20
+ name: "LaresPcTooltip",
21
+ props: {
22
+ label: { type: String, default: "" },
23
+ placement: { type: String, default: "bottom" },
24
+ delay: { type: Number, default: 120 },
25
+ },
26
+ data() {
27
+ return {
28
+ open: false,
29
+ timer: 0,
30
+ chip: { top: "0px", left: "0px" },
31
+ };
32
+ },
33
+ computed: {
34
+ shown() {
35
+ return this.open && Boolean(this.label);
36
+ },
37
+ },
38
+ watch: {
39
+ label(value) {
40
+ if (this.open && value) this.$nextTick(this.place);
41
+ },
42
+ },
43
+ beforeUnmount() {
44
+ this.hide();
45
+ },
46
+ methods: {
47
+ schedule() {
48
+ if (!this.label) return;
49
+ window.clearTimeout(this.timer);
50
+ this.timer = window.setTimeout(() => {
51
+ this.open = true;
52
+ window.addEventListener("scroll", this.hide, true);
53
+ this.$nextTick(this.place);
54
+ }, this.delay);
55
+ },
56
+ hide() {
57
+ window.clearTimeout(this.timer);
58
+ this.timer = 0;
59
+ if (!this.open) return;
60
+ this.open = false;
61
+ window.removeEventListener("scroll", this.hide, true);
62
+ },
63
+ place() {
64
+ const anchor = this.$refs.anchor;
65
+ if (!anchor) return;
66
+ const box = anchor.getBoundingClientRect();
67
+ const gap = 4;
68
+ const above = this.placement === "top";
69
+ this.chip = {
70
+ top: above ? `${Math.max(6, box.top - gap)}px` : `${box.bottom + gap}px`,
71
+ left: `${Math.max(6, box.left)}px`,
72
+ transform: above ? "translateY(-100%)" : "none",
73
+ };
74
+ },
75
+ },
76
+ };
77
+ </script>
78
+
79
+ <style scoped>
80
+ .lares-pc-tooltip { display:inline-flex; min-width:0; }
81
+ .lares-pc-tooltip__chip {
82
+ position:fixed;
83
+ z-index:9500;
84
+ max-width:260px;
85
+ border-radius:6px;
86
+ padding:4px 8px;
87
+ background:var(--q-background-3);
88
+ color:var(--q-ink-1);
89
+ font-size:14px;
90
+ line-height:18px;
91
+ white-space:nowrap;
92
+ pointer-events:none;
93
+ box-shadow:0 4px 14px rgb(0 0 0 / 24%);
94
+ }
95
+ </style>
@@ -0,0 +1,24 @@
1
+ const GAP = 5;
2
+ const EDGE = 8;
3
+
4
+ /**
5
+ * Fixed-position style for a panel hanging off `box`. Composer menus sit at the
6
+ * bottom of the window and open upward; the side only flips when the requested
7
+ * one is too cramped to read.
8
+ */
9
+ export function placePanel({ box, viewport, width, placement = "bottom-end", minHeight = 120 }) {
10
+ const below = viewport.height - box.bottom - GAP;
11
+ const above = box.top - GAP;
12
+ const preferTop = placement.startsWith("top");
13
+ const wanted = preferTop ? above : below;
14
+ const other = preferTop ? below : above;
15
+ const useTop = preferTop !== (wanted < minHeight && other > wanted);
16
+ const rawLeft = placement.endsWith("start") ? box.left : box.right - width;
17
+ return {
18
+ top: useTop ? "" : `${box.bottom + GAP}px`,
19
+ bottom: useTop ? `${viewport.height - box.top + GAP}px` : "",
20
+ left: `${Math.max(EDGE, Math.min(rawLeft, viewport.width - width - EDGE))}px`,
21
+ width: `${width}px`,
22
+ maxHeight: `${Math.max(minHeight, useTop ? above : below)}px`,
23
+ };
24
+ }