glib-web 6.0.2 → 6.0.4

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.
package/action.js CHANGED
@@ -81,6 +81,7 @@ import ActionLogicsSet from "./actions/logics/set";
81
81
  import ActionLogicsRun from "./actions/logics/run";
82
82
 
83
83
  import ActionListsAppend from "./actions/lists/append";
84
+ import ActionListsRemove from "./actions/lists/remove";
84
85
 
85
86
  import ActionBottomBannersOpen from "./actions/bottom_banners/open";
86
87
  import ActionBottomBannersClose from "./actions/bottom_banners/close";
@@ -182,6 +183,7 @@ const actions = {
182
183
  "logics/run": ActionLogicsRun,
183
184
 
184
185
  "lists/append": ActionListsAppend,
186
+ "lists/remove": ActionListsRemove,
185
187
 
186
188
  // deprecated
187
189
  "bottomBanners/open": ActionBottomBannersOpen,
@@ -0,0 +1,23 @@
1
+ import { nextTick } from "vue";
2
+ import { realComponent } from "../../components/helper";
3
+
4
+ export default class {
5
+ execute(spec, component) {
6
+ let target = component;
7
+
8
+ if (spec.targetId) {
9
+ target = realComponent(GLib.component.findById(spec.targetId));
10
+ }
11
+
12
+ if (!target) {
13
+ console.warn("Component ID not found", spec.targetId);
14
+ return;
15
+ }
16
+
17
+ target.action_removeRow(spec.rowId);
18
+
19
+ nextTick(() => {
20
+ GLib.action.execute(spec.onRemove, component);
21
+ });
22
+ }
23
+ }
package/app.scss CHANGED
@@ -188,4 +188,17 @@ blockquote, pre, ol, ul, dd {
188
188
  text-align: center;
189
189
  }
190
190
  }
191
- }
191
+ }
192
+
193
+ // A checkbox whose label carries a tooltip gets a "hover me for more info"
194
+ // affordance (dotted underline + help cursor). Scoped to `.has-tooltip` so plain
195
+ // checkboxes — the vast majority — render with no decoration. The marker class is
196
+ // emitted by `$classes()` whenever the spec has a `tooltip`.
197
+ .fields-check.has-tooltip {
198
+ .v-label {
199
+ text-decoration: underline;
200
+ text-decoration-style: dotted;
201
+ text-underline-offset: 0.15em;
202
+ cursor: help;
203
+ }
204
+ }
@@ -13,33 +13,18 @@
13
13
  </template>
14
14
 
15
15
  <script>
16
- import { determineColor, determineDensity, determineVariant, determineSize } from '../utils/constant';
17
16
  import GlibBase from "./base/glibBase.js";
18
-
17
+ import ButtonProps from "./mixins/buttonProps.js";
19
18
 
20
19
  export default {
21
20
  extends: GlibBase,
21
+ mixins: [ButtonProps],
22
22
  props: {
23
23
  spec: { type: Object, required: true },
24
24
  type: { type: String, default: "button" },
25
25
  hideTextOnXs: { type: Boolean },
26
26
  eventHandlers: { type: Object, default: null }
27
27
  },
28
- computed: {
29
- color() {
30
- // return this.spec.color
31
- return this.spec.color || determineColor(this.spec.styleClasses);
32
- },
33
- density() {
34
- return determineDensity(this.spec.styleClasses);
35
- },
36
- size() {
37
- return determineSize(this.spec.styleClasses);
38
- },
39
- variant() {
40
- return determineVariant(this.spec.styleClasses, 'elevated');
41
- }
42
- },
43
28
  methods: {
44
29
  onClick(e) {
45
30
  if (this.type == 'submit') {
@@ -49,33 +34,6 @@ export default {
49
34
  e.stopPropagation(); // prevent event bubbling
50
35
 
51
36
  this.$onClick();
52
- },
53
- // $ready() {
54
- // this.$type.ifArray(this.spec.styleClasses, val => {
55
- // this.linkStyling = val.includes("link");
56
- // });
57
-
58
- // // const vm = this
59
- // // variants.forEach((value) => {
60
- // // if (vm.$classes().includes(value)) {
61
- // // vm.variant = value
62
- // // }
63
- // // })
64
- // },
65
- styles() {
66
- const styles = this.$styles();
67
- // if (this.linkStyling) {
68
- if (this.$classes().includes('link')) {
69
- styles["color"] = styles["color"] || "#1976d2";
70
- }
71
- return styles;
72
- },
73
- // classes() {
74
- // const classes = this.$classes();
75
- // return classes;
76
- // },
77
- $registryEnabled() {
78
- return false;
79
37
  }
80
38
  }
81
39
  };
@@ -13,7 +13,7 @@
13
13
  <script>
14
14
  import GlibBase from "./base/glibBase.js";
15
15
  import { vueApp } from "../store";
16
- import { determineColor } from '../utils/constant';
16
+ import { determineColor, resolveIconName } from '../utils/constant';
17
17
  import { computed } from "vue";
18
18
 
19
19
  export default {
@@ -44,15 +44,10 @@ export default {
44
44
  return this.isBusy ? "autorenew" : this.name;
45
45
  },
46
46
  icon() {
47
- if (this.spec.fa) {
48
- return { name: `fab fa-${this.spec.fa.name}`, size: this.spec.fa.size };
49
- } else if (this.spec.material) {
50
- return { name: this.spec.material.name, size: this.spec.material.size };
51
- } else {
52
- // Fallback needed when logics_set clears/modifies the spec in ways that remove icon properties.
53
- // Without this check, "Cannot read properties of undefined (reading 'name')" error occurs.
54
- return { name: '', size: null };
55
- }
47
+ return {
48
+ name: resolveIconName(this.spec) || '',
49
+ size: this.spec.fa?.size ?? this.spec.material?.size ?? null
50
+ };
56
51
  }
57
52
  }
58
53
  };
@@ -33,11 +33,41 @@ function useListAutoload() {
33
33
  return proxy.$onVisibilityChange(el, callback);
34
34
  };
35
35
 
36
+ // When the list has a fixed height it scrolls internally; those `scroll` events
37
+ // don't bubble to `#page_body`, so the viewport-change listeners alone never
38
+ // re-evaluate the anchor visibility. Attach the handler to the list's own
39
+ // scroll container (the nearest scrollable ancestor of the anchor) as well.
40
+ const scrollContainer = () => {
41
+ let el = bottomAnchorEl.value || topAnchorEl.value;
42
+ while (el && el !== document.body && el.nodeType === 1) {
43
+ const overflowY = window.getComputedStyle(el).overflowY;
44
+ if (overflowY === "auto" || overflowY === "scroll" || overflowY === "overlay") {
45
+ return el;
46
+ }
47
+ el = el.parentElement;
48
+ }
49
+ return getProxy(instance)?.$el || null;
50
+ };
51
+
52
+ const addScrollContainerListener = (handler) => {
53
+ const el = scrollContainer();
54
+ if (!isFunction(handler) || !el || !isFunction(el.addEventListener)) return;
55
+ el.addEventListener("scroll", handler);
56
+ };
57
+
58
+ const removeScrollContainerListener = (handler) => {
59
+ const el = scrollContainer();
60
+ if (!isFunction(handler) || !el || !isFunction(el.removeEventListener)) return;
61
+ el.removeEventListener("scroll", handler);
62
+ };
63
+
36
64
  const enableInfiniteScrollIfApplicable = async (spec) => {
37
65
  await nextTick();
38
66
 
39
67
  removeViewportChangeListeners(topScrollHandler.value);
40
68
  removeViewportChangeListeners(bottomScrollHandler.value);
69
+ removeScrollContainerListener(topScrollHandler.value);
70
+ removeScrollContainerListener(bottomScrollHandler.value);
41
71
 
42
72
  const prevPage = spec?.prevPage || {};
43
73
  const nextPage = spec?.nextPage || {};
@@ -79,6 +109,7 @@ function useListAutoload() {
79
109
  );
80
110
 
81
111
  addViewportChangeListeners(topScrollHandler.value);
112
+ addScrollContainerListener(topScrollHandler.value);
82
113
  };
83
114
 
84
115
  const updateScrollAfterPrepending = (previousFirstRowIndex) => {
@@ -164,6 +195,7 @@ function useListAutoload() {
164
195
  );
165
196
 
166
197
  addViewportChangeListeners(bottomScrollHandler.value);
198
+ addScrollContainerListener(bottomScrollHandler.value);
167
199
  };
168
200
 
169
201
  const handleBottomScroll = (onScrollToBottom) => {
@@ -31,6 +31,35 @@ function useTableAutoload() {
31
31
  return proxy.$onVisibilityChange(el, callback);
32
32
  };
33
33
 
34
+ // When the table has a fixed height it scrolls internally; those `scroll` events
35
+ // don't bubble to `#page_body`, so the viewport-change listeners alone never
36
+ // re-evaluate the anchor visibility. Attach the handler to the table's own
37
+ // scroll container (the nearest scrollable ancestor of the anchor) as well.
38
+ const scrollContainer = () => {
39
+ const proxy = getProxy(instance);
40
+ let el = proxy?.$refs?.bottomAnchor || proxy?.$refs?.topAnchor;
41
+ while (el && el !== document.body && el.nodeType === 1) {
42
+ const overflowY = window.getComputedStyle(el).overflowY;
43
+ if (overflowY === "auto" || overflowY === "scroll" || overflowY === "overlay") {
44
+ return el;
45
+ }
46
+ el = el.parentElement;
47
+ }
48
+ return proxy?.$el || null;
49
+ };
50
+
51
+ const addScrollContainerListener = (handler) => {
52
+ const el = scrollContainer();
53
+ if (!isFunction(handler) || !el || !isFunction(el.addEventListener)) return;
54
+ el.addEventListener("scroll", handler);
55
+ };
56
+
57
+ const removeScrollContainerListener = (handler) => {
58
+ const el = scrollContainer();
59
+ if (!isFunction(handler) || !el || !isFunction(el.removeEventListener)) return;
60
+ el.removeEventListener("scroll", handler);
61
+ };
62
+
34
63
  const bottomAnchorStyles = computed(() => {
35
64
  return {
36
65
  display: infiniteScroll.value && nextPageUrl.value ? "block" : "none"
@@ -42,6 +71,8 @@ function useTableAutoload() {
42
71
 
43
72
  removeViewportChangeListeners(topScrollHandler.value);
44
73
  removeViewportChangeListeners(bottomScrollHandler.value);
74
+ removeScrollContainerListener(topScrollHandler.value);
75
+ removeScrollContainerListener(bottomScrollHandler.value);
45
76
 
46
77
  const proxy = getProxy(instance);
47
78
  if (!proxy) return;
@@ -81,6 +112,7 @@ function useTableAutoload() {
81
112
  );
82
113
 
83
114
  addViewportChangeListeners(topScrollHandler.value);
115
+ addScrollContainerListener(topScrollHandler.value);
84
116
  };
85
117
 
86
118
  const handleTopScroll = (onScrollToTop) => {
@@ -103,6 +135,7 @@ function useTableAutoload() {
103
135
  );
104
136
 
105
137
  addViewportChangeListeners(bottomScrollHandler.value);
138
+ addScrollContainerListener(bottomScrollHandler.value);
106
139
  };
107
140
 
108
141
  const handleBottomScroll = (onScrollToBottom) => {
@@ -1,21 +1,31 @@
1
1
  <template>
2
- <div class="fab">
3
- <common-button :spec="spec"></common-button>
4
- </div>
2
+ <v-fab app :disabled="spec.disabled" :class="$classes()" :href="$href()" :variant="variant"
3
+ :rounded="$classes().includes('rounded') || null" :density="density" :size="size" :color="color"
4
+ :location="spec.location || 'bottom end'" :icon="iconValue" @click="$onClick">
5
+ </v-fab>
5
6
  </template>
6
7
 
7
8
  <script>
8
9
  import GlibBase from "./base/glibBase.js";
10
+ import ButtonProps from "./mixins/buttonProps.js";
11
+ import { resolveIconName } from "../utils/constant";
12
+
9
13
  export default {
10
14
  extends: GlibBase,
11
- props: ['spec']
15
+ mixins: [ButtonProps],
16
+ props: {
17
+ spec: { type: Object, required: true }
18
+ },
19
+ computed: {
20
+ iconValue() {
21
+ return resolveIconName(this.spec.icon);
22
+ }
23
+ }
12
24
  };
13
25
  </script>
14
26
 
15
- <style scoped>
16
- .fab {
17
- position: fixed;
18
- right: 48px;
19
- bottom: 24px;
27
+ <style lang="scss" scoped>
28
+ .v-fab.glib-clickable:not(.v-btn):not(.disabled):not(.no-hover):hover:before {
29
+ background: unset;
20
30
  }
21
31
  </style>
@@ -4,10 +4,12 @@
4
4
  <!-- See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/date for why we need to use `pattern` -->
5
5
 
6
6
  <template v-else>
7
- <v-text-field ref="field" :color="gcolor" :label="spec.label" :hint="spec.hint" :readonly="spec.readOnly"
8
- :disabled="inputDisabled" :style="$styles()" :density="$classes().includes('compact') ? 'compact' : 'default'"
9
- :clearable="spec.clearable" :variant="variant" validate-on="blur" persistent-placeholder
10
- :placeholder="spec.placeholder" :value="format" @change="onChange" @click="openPicker" />
7
+ <v-text-field ref="field" v-model="inputText" :color="gcolor" :label="spec.label" :hint="spec.hint"
8
+ :readonly="spec.readOnly" :disabled="inputDisabled" :style="$styles()"
9
+ :density="$classes().includes('compact') ? 'compact' : 'default'" :clearable="spec.clearable"
10
+ :variant="variant" validate-on="blur" persistent-placeholder :placeholder="spec.placeholder"
11
+ :error-messages="parseError" append-inner-icon="calendar_today" @click:append-inner="openPicker"
12
+ @click:clear="onClear" @blur="parseTypedDate" @keydown.enter="parseTypedDate" />
11
13
  <input ref="nativePicker" v-model="fieldModel" class="native-picker" :type="type" :name="fieldName"
12
14
  :min="sanitizeValue(spec.min)" :max="sanitizeValue(spec.max)" :value="sanitizeValue(fieldModel)"
13
15
  @change="handleNativeChange" />
@@ -18,6 +20,7 @@
18
20
 
19
21
  <script>
20
22
  import { useDate } from "vuetify";
23
+ import moment from "moment";
21
24
  import { sanitize } from "../composable/date";
22
25
  import { useGlibInput } from "../composable/form";
23
26
  import { useInputVariant } from "../composable/inputVariant";
@@ -25,6 +28,20 @@ import buttonDate from "./_buttonDate.vue";
25
28
  import { isPresent, isString, isFunction } from "../../utils/type";
26
29
  import GlibBase from "../base/glibBase.js";
27
30
 
31
+ // Accepted formats for flexible parsing of typed/pasted dates. moment is lenient
32
+ // (non-strict) so these cover the common cases (ISO, slash/dash variants, named
33
+ // months) including the field's own displayed `fullDate` format.
34
+ const PARSE_FORMATS = [
35
+ moment.ISO_8601,
36
+ "YYYY-MM-DD", "YYYY/MM/DD",
37
+ "MM/DD/YYYY", "M/D/YYYY", "MM-DD-YYYY",
38
+ "DD/MM/YYYY", "D/M/YYYY", "DD-MM-YYYY",
39
+ "MMM D YYYY", "MMM D, YYYY", "MMMM D YYYY", "MMMM D, YYYY",
40
+ "ddd, MMM DD, YYYY",
41
+ "YYYY-MM-DDTHH:mm", "YYYY-MM-DD HH:mm",
42
+ "MM/DD/YYYY HH:mm", "MM/DD/YYYY h:mm A"
43
+ ];
44
+
28
45
  export default {
29
46
  components: { buttonDate },
30
47
  extends: GlibBase,
@@ -40,23 +57,29 @@ export default {
40
57
 
41
58
  return { adapter, variant };
42
59
  },
60
+ data() {
61
+ return {
62
+ // Text shown in (and typed into) the editable field. Kept in sync with the
63
+ // formatted display value via the `format` watcher; parsed back into
64
+ // `fieldModel` on blur/enter.
65
+ inputText: undefined,
66
+ parseError: undefined
67
+ };
68
+ },
43
69
  computed: {
44
70
  format() {
45
- if (!isPresent(this.fieldModel)) return;
46
- if (!isPresent(this.spec.format)) {
47
- switch (this.type) {
48
- case 'date':
49
- return this.adapter.format(this.fieldModel, 'fullDate');
50
-
51
- case 'datetime-local':
52
- return this.adapter.format(this.fieldModel, 'keyboardDateTime24h');
53
-
54
- default:
55
- return this.adapter.format(this.fieldModel, 'keyboardDateTime24h');
56
- }
71
+ return this.formatValue(this.fieldModel);
72
+ }
73
+ },
74
+ watch: {
75
+ format: {
76
+ immediate: true,
77
+ handler(val) {
78
+ // Reflect external changes (native picker, initial value, clearing) into
79
+ // the visible text. Does not fire while typing, since `fieldModel` only
80
+ // changes once the typed text is parsed.
81
+ this.inputText = isPresent(val) ? val : undefined;
57
82
  }
58
-
59
- return this.adapter.format(this.fieldModel, this.spec.format);
60
83
  }
61
84
  },
62
85
  methods: {
@@ -68,6 +91,23 @@ export default {
68
91
  return sanitize(val, this.type, timeZone);
69
92
  }
70
93
  },
94
+ formatValue(val) {
95
+ if (!isPresent(val)) return;
96
+ if (!isPresent(this.spec.format)) {
97
+ switch (this.type) {
98
+ case 'date':
99
+ return this.adapter.format(val, 'fullDate');
100
+
101
+ case 'datetime-local':
102
+ return this.adapter.format(val, 'keyboardDateTime24h');
103
+
104
+ default:
105
+ return this.adapter.format(val, 'keyboardDateTime24h');
106
+ }
107
+ }
108
+
109
+ return this.adapter.format(val, this.spec.format);
110
+ },
71
111
  openPicker() {
72
112
  if (this.inputDisabled || this.spec.readOnly) return;
73
113
 
@@ -86,7 +126,48 @@ export default {
86
126
  picker.click();
87
127
  }
88
128
  },
89
- onChange() {
129
+ parseTypedDate() {
130
+ const text = isString(this.inputText) ? this.inputText.trim() : this.inputText;
131
+
132
+ // Empty input clears the value (also reached when typing then deleting).
133
+ if (!isPresent(text) || text === '') {
134
+ this.onClear();
135
+ return;
136
+ }
137
+
138
+ const parsed = moment(text, PARSE_FORMATS);
139
+ if (!parsed.isValid()) {
140
+ this.parseError = 'Invalid date';
141
+ return;
142
+ }
143
+
144
+ // Pass '' as the time zone so the typed wall-clock date is preserved rather
145
+ // than shifted, matching `handleNativeChange`. `sanitizeValue` slices it to
146
+ // the field's storage format (YYYY-MM-DD for date, etc.).
147
+ const value = this.sanitizeValue(parsed.toISOString(true), '');
148
+
149
+ // Enforce the same min/max the native picker honours. Sanitized values share
150
+ // a comparable per-type format, so lexicographic comparison reflects order.
151
+ const min = this.sanitizeValue(this.spec.min);
152
+ const max = this.sanitizeValue(this.spec.max);
153
+ if (isPresent(min) && value < min) {
154
+ this.parseError = `Date must be on or after ${this.formatValue(min)}`;
155
+ return;
156
+ }
157
+ if (isPresent(max) && value > max) {
158
+ this.parseError = `Date must be on or before ${this.formatValue(max)}`;
159
+ return;
160
+ }
161
+
162
+ this.parseError = undefined;
163
+ this.fieldModel = value;
164
+ this.inputText = this.format;
165
+ this.$executeOnChange();
166
+ },
167
+ onClear() {
168
+ this.parseError = undefined;
169
+ this.inputText = undefined;
170
+ this.fieldModel = undefined;
90
171
  this.$executeOnChange();
91
172
  },
92
173
  handleNativeChange(event) {
@@ -6,7 +6,7 @@
6
6
  :disabled="inputDisabled" :multiple="spec.multiple" :readonly="spec.readOnly" :clearable="spec.clearable"
7
7
  :placeholder="spec.placeholder" :rules="$validation()" persistent-hint :append-icon="append.icon"
8
8
  validate-on="input" item-title='text' :variant="variant" :density="density" persistent-placeholder
9
- :menu-props="{ maxWidth: 0 }" @update:modelValue="onChange" @focus="focused = true" @blur="focused = false">
9
+ :menu-props="{ maxWidth: 0 }" :custom-filter="filterBySubtitle" @update:modelValue="onChange" @focus="focused = true" @blur="focused = false">
10
10
 
11
11
  <template #item="{ props, item }">
12
12
  <v-list-subheader v-if="item.type == 'view'">
@@ -381,6 +381,13 @@ export default {
381
381
  triggerOnChange(containerEl);
382
382
  }
383
383
  },
384
+ filterBySubtitle(value, query, item) {
385
+ const q = (query || '').toString().toLowerCase();
386
+ if (!q) return true;
387
+ const text = (value || '').toString().toLowerCase();
388
+ const subtitle = (item?.raw?.subtitle || '').toString().toLowerCase();
389
+ return text.includes(q) || subtitle.includes(q);
390
+ },
384
391
  removeItem(item) {
385
392
  // fieldModel may not be an array when useChips is true but multiple is false.
386
393
  // This can happen when spec.useChips is explicitly set to true while spec.multiple is false.
@@ -7,7 +7,7 @@
7
7
  :suffix="spec.rightText" :min="spec.min" :max="spec.max" :autofocus="spec.autoFocus || false" validate-on="blur"
8
8
  :variant="variant" persistent-placeholder :clearable="spec.clearable" :rounded="spec.rounded"
9
9
  :autocomplete="spec.autocomplete" @click:appendInner="onRightIconClick" @input="onChange"
10
- @keyup="$onTyping({ duration: 2000 })" @wheel.prevent />
10
+ @keyup="$onTyping({ duration: 2000 })" @wheel="onWheel" />
11
11
  </div>
12
12
  </template>
13
13
 
@@ -131,6 +131,17 @@ export default {
131
131
  action_focus() {
132
132
  this.$refs.field.focus();
133
133
  },
134
+ onWheel() {
135
+ // Only number inputs change their value on wheel scroll, and only while
136
+ // focused. Blur the focused number input so the value stays put while the
137
+ // wheel event still bubbles up and scrolls the page. All other field types
138
+ // (and unfocused number fields) are left untouched so the page can scroll.
139
+ if (this.config.type !== "number") return;
140
+ const input = this.$refs.field?.$el?.querySelector("input");
141
+ if (input && document.activeElement === input) {
142
+ input.blur();
143
+ }
144
+ },
134
145
  onChange: eventFiltering.debounce(function () {
135
146
  this.$executeOnChange();
136
147
  }, 300),
@@ -0,0 +1,30 @@
1
+ import { determineColor, determineDensity, determineVariant, determineSize } from '../../utils/constant';
2
+
3
+ export default {
4
+ computed: {
5
+ color() {
6
+ return this.spec.color || determineColor(this.spec.styleClasses);
7
+ },
8
+ density() {
9
+ return determineDensity(this.spec.styleClasses);
10
+ },
11
+ size() {
12
+ return determineSize(this.spec.styleClasses);
13
+ },
14
+ variant() {
15
+ return determineVariant(this.spec.styleClasses, 'elevated');
16
+ }
17
+ },
18
+ methods: {
19
+ styles() {
20
+ const styles = this.$styles();
21
+ if (this.$classes().includes('link')) {
22
+ styles["color"] = styles["color"] || "#1976d2";
23
+ }
24
+ return styles;
25
+ },
26
+ $registryEnabled() {
27
+ return false;
28
+ }
29
+ }
30
+ };
@@ -1,13 +1,8 @@
1
1
  // From https://github.com/ittus/vue-long-click
2
2
  export default ({ delay = 400, interval = 50 }) => ({
3
- bind: function(el, binding, vNode) {
3
+ beforeMount: function(el, binding) {
4
4
  if (typeof binding.value !== "function") {
5
- const compName = vNode.context.name;
6
- let warn = `[longclick:] provided expression '${binding.expression}' is not a function, but has to be`;
7
- if (compName) {
8
- warn += `Found in component '${compName}' `;
9
- }
10
- console.warn(warn)
5
+ console.warn(`[longclick:] provided value is not a function`);
11
6
  return;
12
7
  }
13
8
 
@@ -48,9 +43,20 @@ export default ({ delay = 400, interval = 50 }) => ({
48
43
  binding.value(e);
49
44
  };
50
45
 
46
+ el._longClickStart = start;
47
+ el._longClickCancel = cancel;
48
+
51
49
  ["mousedown", "touchstart"].forEach(e => el.addEventListener(e, start));
52
50
  ["click", "mouseup", "touchend", "touchcancel"].forEach(e => {
53
51
  el.addEventListener(e, cancel);
54
52
  });
53
+ },
54
+ unmounted: function(el) {
55
+ if (el._longClickStart) {
56
+ ["mousedown", "touchstart"].forEach(e => el.removeEventListener(e, el._longClickStart));
57
+ }
58
+ if (el._longClickCancel) {
59
+ ["click", "mouseup", "touchend", "touchcancel"].forEach(e => el.removeEventListener(e, el._longClickCancel));
60
+ }
55
61
  }
56
62
  });
@@ -13,10 +13,10 @@ export default {
13
13
  rect.left >= 0 &&
14
14
  rect.bottom <=
15
15
  (window.innerHeight ||
16
- document.documentElement.clientHeight) /*or $(window).height() */ &&
16
+ document.documentElement.clientHeight) + 2 /*or $(window).height() */ &&
17
17
  rect.right <=
18
18
  (window.innerWidth ||
19
- document.documentElement.clientWidth) /*or $(window).width() */
19
+ document.documentElement.clientWidth) + 2 /*or $(window).width() */
20
20
  );
21
21
  },
22
22
  // From https://stackoverflow.com/questions/123999/how-to-tell-if-a-dom-element-is-visible-in-the-current-viewport/7557433#7557433
@@ -301,6 +301,14 @@ export default {
301
301
  classes.push("glib-clickable")
302
302
  );
303
303
 
304
+ // Marker for elements that carry a tooltip. The tooltip itself is wired up
305
+ // via JS hover/click listeners and leaves no other trace in the DOM, so this
306
+ // class is the only hook consumers have to style a "this has a tooltip"
307
+ // affordance (e.g. a dotted underline) scoped to elements that actually have one.
308
+ Utils.type.ifObject(properties.tooltip, () =>
309
+ classes.push("has-tooltip")
310
+ );
311
+
304
312
  // if (properties.readOnly) {
305
313
  // classes.push("glib-input-disabled");
306
314
  // }
@@ -44,15 +44,18 @@ export default {
44
44
  computed: {
45
45
  template() {
46
46
  const name = `template-${this.spec.template.replace("/", "-")}`;
47
- const strict = true;
48
- if (strict) {
47
+
48
+ // Built-in templates registered locally on this component.
49
+ if (this.$options.components[name]) {
49
50
  return name;
50
- } else {
51
- if (getCurrentInstance().appContext.components[name]) {
52
- return name;
53
- }
54
- return "template-unsupported";
55
51
  }
52
+
53
+ // Project-defined templates registered globally as `template-<name>`.
54
+ if (getCurrentInstance().appContext.components[name]) {
55
+ return name;
56
+ }
57
+
58
+ return "template-unsupported";
56
59
  },
57
60
  contentSpec() {
58
61
  const onClick = this.spec.onClick ? { onClick: this.spec.onClick } : {};