@sveltia/ui 0.64.0 → 0.65.1
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/dist/components/drawer/drawer.svelte.d.ts +2 -2
- package/dist/components/emoji/cache.d.ts +3 -0
- package/dist/components/emoji/cache.js +96 -0
- package/dist/components/emoji/caret.d.ts +2 -0
- package/dist/components/emoji/caret.js +106 -0
- package/dist/components/emoji/emoji-suggestions.svelte +424 -0
- package/dist/components/emoji/emoji-suggestions.svelte.d.ts +66 -0
- package/dist/components/emoji/emoji.d.ts +31 -0
- package/dist/components/emoji/emoji.js +208 -0
- package/dist/components/emoji/loader.d.ts +17 -0
- package/dist/components/emoji/loader.js +78 -0
- package/dist/components/text-editor/emoji-autocomplete.svelte +225 -0
- package/dist/components/text-editor/emoji-autocomplete.svelte.d.ts +12 -0
- package/dist/components/text-editor/shiki/generated.d.ts +1 -1
- package/dist/components/text-editor/shiki/generated.js +1 -1
- package/dist/components/text-editor/store.svelte.js +1 -0
- package/dist/components/text-editor/text-editor.svelte +9 -0
- package/dist/components/text-editor/text-editor.svelte.d.ts +10 -0
- package/dist/components/text-field/emoji-autocomplete.svelte +165 -0
- package/dist/components/text-field/emoji-autocomplete.svelte.d.ts +25 -0
- package/dist/components/text-field/text-area.svelte +11 -0
- package/dist/components/text-field/text-area.svelte.d.ts +19 -1
- package/dist/components/text-field/text-input.svelte +7 -0
- package/dist/components/text-field/text-input.svelte.d.ts +10 -0
- package/dist/locales/ar.yaml +3 -0
- package/dist/locales/bg.yaml +3 -0
- package/dist/locales/ca.yaml +3 -0
- package/dist/locales/cs.yaml +3 -0
- package/dist/locales/el.yaml +3 -0
- package/dist/locales/en-CA.yaml +3 -0
- package/dist/locales/en-GB.yaml +3 -0
- package/dist/locales/en-US.yaml +3 -0
- package/dist/locales/es-CO.yaml +3 -0
- package/dist/locales/fi.yaml +3 -0
- package/dist/locales/fr.yaml +3 -0
- package/dist/locales/ja.yaml +3 -0
- package/dist/locales/ko.yaml +3 -0
- package/dist/locales/nl.yaml +3 -0
- package/dist/locales/pl.yaml +3 -0
- package/dist/locales/pt-BR.yaml +3 -0
- package/dist/locales/pt-PT.yaml +3 -0
- package/dist/locales/ru.yaml +3 -0
- package/dist/locales/tr.yaml +3 -0
- package/dist/locales/uk.yaml +3 -0
- package/dist/locales/zh-CN.yaml +3 -0
- package/dist/typedefs.d.ts +61 -0
- package/dist/typedefs.js +34 -0
- package/package.json +2 -1
|
@@ -24,7 +24,7 @@ declare const Drawer: import("svelte").Component<ModalProps & {
|
|
|
24
24
|
/**
|
|
25
25
|
* Position of the drawer.
|
|
26
26
|
*/
|
|
27
|
-
position?: "top" | "
|
|
27
|
+
position?: "top" | "left" | "right" | "bottom" | undefined;
|
|
28
28
|
/**
|
|
29
29
|
* Width or height of the
|
|
30
30
|
* drawer.
|
|
@@ -75,7 +75,7 @@ type Props = {
|
|
|
75
75
|
/**
|
|
76
76
|
* Position of the drawer.
|
|
77
77
|
*/
|
|
78
|
-
position?: "top" | "
|
|
78
|
+
position?: "top" | "left" | "right" | "bottom" | undefined;
|
|
79
79
|
/**
|
|
80
80
|
* Width or height of the
|
|
81
81
|
* drawer.
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { IndexedDB } from '@sveltia/utils/storage';
|
|
2
|
+
import { EMOJILIB_VERSION } from './loader.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @import { EmojiData } from '../../typedefs';
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Client-side cache for the emoji data.
|
|
10
|
+
*
|
|
11
|
+
* The data is a few hundred kilobytes fetched from a CDN, and it never changes for a given version,
|
|
12
|
+
* so it’s worth keeping locally rather than going back to the network on every page load. IndexedDB
|
|
13
|
+
* is used rather than local storage because the payload is large enough to matter against the local
|
|
14
|
+
* storage quota, and because writing it there would block the main thread.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const DATABASE_NAME = 'sveltia-ui';
|
|
18
|
+
const STORE_NAME = 'emoji';
|
|
19
|
+
/** Key of the sole entry, scoped to the version the data came from. */
|
|
20
|
+
const KEY = `${EMOJILIB_VERSION}/data`;
|
|
21
|
+
/** @type {IndexedDB | undefined | null} `null` once the store is known to be unusable. */
|
|
22
|
+
let database;
|
|
23
|
+
/** @type {Promise<void> | undefined} */
|
|
24
|
+
let purgePromise;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Get the store, or `null` when IndexedDB is unavailable, as in a server-side render or with
|
|
28
|
+
* storage blocked.
|
|
29
|
+
* @returns {IndexedDB | null} Store.
|
|
30
|
+
*/
|
|
31
|
+
const getDatabase = () => {
|
|
32
|
+
if (database === undefined) {
|
|
33
|
+
database = typeof indexedDB === 'undefined' ? null : new IndexedDB(DATABASE_NAME, STORE_NAME);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return database;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Drop anything cached for a different version, once per session. A version bump changes the key,
|
|
41
|
+
* so the old payload would otherwise linger forever.
|
|
42
|
+
* @param {IndexedDB} db Store.
|
|
43
|
+
* @returns {Promise<void>} Nothing.
|
|
44
|
+
*/
|
|
45
|
+
const purgeStaleVersions = async (db) => {
|
|
46
|
+
purgePromise ??= (async () => {
|
|
47
|
+
const stale = (await db.keys()).filter((key) => key !== KEY);
|
|
48
|
+
|
|
49
|
+
if (stale.length) {
|
|
50
|
+
await db.deleteEntries(stale);
|
|
51
|
+
}
|
|
52
|
+
})();
|
|
53
|
+
|
|
54
|
+
return purgePromise;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Read the cached emoji data.
|
|
59
|
+
* @returns {Promise<EmojiData | undefined>} Cached data, or `undefined` when not cached or
|
|
60
|
+
* unreadable.
|
|
61
|
+
*/
|
|
62
|
+
export const getCachedEmojiData = async () => {
|
|
63
|
+
const db = getDatabase();
|
|
64
|
+
|
|
65
|
+
if (!db) {
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
await purgeStaleVersions(db);
|
|
71
|
+
|
|
72
|
+
return await db.get(KEY);
|
|
73
|
+
} catch {
|
|
74
|
+
// A failed cache read just means fetching again
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Store the emoji data.
|
|
81
|
+
* @param {EmojiData} data Emoji data.
|
|
82
|
+
* @returns {Promise<void>} Nothing.
|
|
83
|
+
*/
|
|
84
|
+
export const cacheEmojiData = async (data) => {
|
|
85
|
+
const db = getDatabase();
|
|
86
|
+
|
|
87
|
+
if (!db) {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
await db.set(KEY, data);
|
|
93
|
+
} catch {
|
|
94
|
+
// Out of quota or storage blocked: not worth failing over, the data is already in hand
|
|
95
|
+
}
|
|
96
|
+
};
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @import { EmojiAnchorRect } from '../../typedefs';
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* CSS properties copied from the field onto the mirror element, so the mirror wraps and spaces the
|
|
7
|
+
* text exactly as the field does. Anything that affects the position of a character has to be here.
|
|
8
|
+
*/
|
|
9
|
+
const MIRROR_STYLE_PROPERTIES = [
|
|
10
|
+
'boxSizing',
|
|
11
|
+
'width',
|
|
12
|
+
'borderTopWidth',
|
|
13
|
+
'borderRightWidth',
|
|
14
|
+
'borderBottomWidth',
|
|
15
|
+
'borderLeftWidth',
|
|
16
|
+
'paddingTop',
|
|
17
|
+
'paddingRight',
|
|
18
|
+
'paddingBottom',
|
|
19
|
+
'paddingLeft',
|
|
20
|
+
'fontFamily',
|
|
21
|
+
'fontSize',
|
|
22
|
+
'fontStretch',
|
|
23
|
+
'fontStyle',
|
|
24
|
+
'fontVariant',
|
|
25
|
+
'fontWeight',
|
|
26
|
+
'letterSpacing',
|
|
27
|
+
'lineHeight',
|
|
28
|
+
'tabSize',
|
|
29
|
+
'textAlign',
|
|
30
|
+
'textIndent',
|
|
31
|
+
'textTransform',
|
|
32
|
+
'wordBreak',
|
|
33
|
+
'wordSpacing',
|
|
34
|
+
'overflowWrap',
|
|
35
|
+
'direction',
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Get the viewport-relative bounds of a range of characters within a text field.
|
|
40
|
+
*
|
|
41
|
+
* An `<input>` or `<textarea>` gives no way to ask where a character sits, so the field is
|
|
42
|
+
* replicated with a hidden element carrying the same text and the same typography, and the range is
|
|
43
|
+
* measured there. Only the offset within the mirror is used, which is then applied to the field’s
|
|
44
|
+
* own position, so neither element has to be positioned relative to the other.
|
|
45
|
+
* @param {HTMLInputElement | HTMLTextAreaElement} element Text field.
|
|
46
|
+
* @param {number} start Index of the first character.
|
|
47
|
+
* @param {number} end Index just past the last character.
|
|
48
|
+
* @returns {EmojiAnchorRect | undefined} Bounds, or `undefined` if the field isn’t laid out.
|
|
49
|
+
* @see https://github.com/component/textarea-caret-position
|
|
50
|
+
*/
|
|
51
|
+
export const getFieldCaretRect = (element, start, end) => {
|
|
52
|
+
const fieldRect = element.getBoundingClientRect();
|
|
53
|
+
|
|
54
|
+
// The field is hidden or detached, so there is nothing to anchor to
|
|
55
|
+
if (!fieldRect.width && !fieldRect.height) {
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const style = globalThis.getComputedStyle(element);
|
|
60
|
+
const singleLine = element.tagName === 'INPUT';
|
|
61
|
+
const mirror = document.createElement('div');
|
|
62
|
+
const marker = document.createElement('span');
|
|
63
|
+
|
|
64
|
+
MIRROR_STYLE_PROPERTIES.forEach((property) => {
|
|
65
|
+
/** @type {Record<string, any>} */ (mirror.style)[property] =
|
|
66
|
+
/** @type {Record<string, any>} */ (style)[property];
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
Object.assign(mirror.style, {
|
|
70
|
+
position: 'absolute',
|
|
71
|
+
top: '0',
|
|
72
|
+
left: '0',
|
|
73
|
+
height: 'auto',
|
|
74
|
+
minHeight: '0',
|
|
75
|
+
maxHeight: 'none',
|
|
76
|
+
overflow: 'hidden',
|
|
77
|
+
visibility: 'hidden',
|
|
78
|
+
pointerEvents: 'none',
|
|
79
|
+
// A single-line field never wraps, however long the value is
|
|
80
|
+
whiteSpace: singleLine ? 'pre' : 'pre-wrap',
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
mirror.setAttribute('aria-hidden', 'true');
|
|
84
|
+
mirror.textContent = element.value.slice(0, start);
|
|
85
|
+
// A zero-width space keeps the marker measurable when the range is empty
|
|
86
|
+
marker.textContent = element.value.slice(start, end) || '\u200B';
|
|
87
|
+
mirror.append(marker);
|
|
88
|
+
document.body.append(mirror);
|
|
89
|
+
|
|
90
|
+
const mirrorRect = mirror.getBoundingClientRect();
|
|
91
|
+
const markerRects = marker.getClientRects();
|
|
92
|
+
// A wrapped range spans several lines; the last one is where the caret is
|
|
93
|
+
const markerRect = markerRects[markerRects.length - 1] ?? marker.getBoundingClientRect();
|
|
94
|
+
const offsetTop = markerRect.top - mirrorRect.top;
|
|
95
|
+
const offsetLeft = markerRect.left - mirrorRect.left;
|
|
96
|
+
const offsetRight = markerRect.right - mirrorRect.left;
|
|
97
|
+
const height = markerRect.height || Number.parseFloat(style.lineHeight) || 0;
|
|
98
|
+
|
|
99
|
+
mirror.remove();
|
|
100
|
+
|
|
101
|
+
const top = fieldRect.top + offsetTop - element.scrollTop;
|
|
102
|
+
const left = fieldRect.left + offsetLeft - element.scrollLeft;
|
|
103
|
+
const right = fieldRect.left + offsetRight - element.scrollLeft;
|
|
104
|
+
|
|
105
|
+
return { top, bottom: top + height, left, right };
|
|
106
|
+
};
|
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component
|
|
3
|
+
The dropdown shown while an emoji shortcode is being typed, along with the state behind it. This
|
|
4
|
+
knows nothing about where the text is being typed; a host component detects the shortcode, feeds
|
|
5
|
+
it in with {@link update} and applies the chosen emoji through the `onSelect` callback.
|
|
6
|
+
|
|
7
|
+
The list is rendered in the top layer with the Popover API, so it’s never clipped by whatever
|
|
8
|
+
contains the field, and it never takes the focus, so the user can keep typing to narrow down the
|
|
9
|
+
suggestions.
|
|
10
|
+
@see https://developer.mozilla.org/en-US/docs/Web/API/Popover_API
|
|
11
|
+
-->
|
|
12
|
+
<script>
|
|
13
|
+
import { _ } from '@sveltia/i18n';
|
|
14
|
+
import { onMount } from 'svelte';
|
|
15
|
+
import { loadEmojiList, searchEmojis } from './emoji.js';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @import { EmojiAnchorRect, EmojiEntry, EmojiTrigger } from '../../typedefs';
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @typedef {object} Props
|
|
23
|
+
* @property {(trigger: EmojiTrigger) => EmojiAnchorRect | undefined} getAnchorRect Get the
|
|
24
|
+
* viewport-relative bounds of the shortcode being typed, which the dropdown is anchored to.
|
|
25
|
+
* @property {(entry: EmojiEntry, trigger: EmojiTrigger) => void} onSelect Called with the emoji
|
|
26
|
+
* the user picked and the shortcode it should replace.
|
|
27
|
+
* @property {HTMLElement} [ariaOwner] The element holding the caret, which is labelled with the
|
|
28
|
+
* highlighted suggestion because the focus never moves to the dropdown.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @type {Props}
|
|
33
|
+
*/
|
|
34
|
+
let {
|
|
35
|
+
/* eslint-disable prefer-const */
|
|
36
|
+
getAnchorRect,
|
|
37
|
+
onSelect,
|
|
38
|
+
ariaOwner = undefined,
|
|
39
|
+
/* eslint-enable prefer-const */
|
|
40
|
+
} = $props();
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Estimated size of the dropdown, used to decide whether it fits below the caret. The width is
|
|
44
|
+
* also enforced in the stylesheet below.
|
|
45
|
+
*/
|
|
46
|
+
const LIST_WIDTH = 280;
|
|
47
|
+
const LIST_MAX_HEIGHT = 280;
|
|
48
|
+
/**
|
|
49
|
+
* Gap between the dropdown and the caret, and the minimum margin to the viewport edges.
|
|
50
|
+
*/
|
|
51
|
+
const LIST_OFFSET = 4;
|
|
52
|
+
const VIEWPORT_MARGIN = 8;
|
|
53
|
+
|
|
54
|
+
const listId = $props.id();
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The shortcode currently being typed, if any.
|
|
58
|
+
* @type {EmojiTrigger | undefined}
|
|
59
|
+
*/
|
|
60
|
+
let trigger = $state();
|
|
61
|
+
/**
|
|
62
|
+
* Emojis matching the {@link trigger}’s query.
|
|
63
|
+
* @type {EmojiEntry[]}
|
|
64
|
+
*/
|
|
65
|
+
let candidates = $state([]);
|
|
66
|
+
/**
|
|
67
|
+
* Index of the highlighted candidate.
|
|
68
|
+
* @type {number}
|
|
69
|
+
*/
|
|
70
|
+
let selectedIndex = $state(0);
|
|
71
|
+
/**
|
|
72
|
+
* Viewport-relative bounds of the shortcode being typed.
|
|
73
|
+
* @type {EmojiAnchorRect | undefined}
|
|
74
|
+
*/
|
|
75
|
+
let anchorRect = $state();
|
|
76
|
+
/**
|
|
77
|
+
* A reference to the dropdown element, which is only mounted while suggestions are shown.
|
|
78
|
+
* @type {HTMLElement | undefined}
|
|
79
|
+
*/
|
|
80
|
+
let listElement = $state();
|
|
81
|
+
/**
|
|
82
|
+
* Monotonically increasing counter used to discard the result of a search that has been
|
|
83
|
+
* superseded while the emoji list was being loaded.
|
|
84
|
+
* @type {number}
|
|
85
|
+
*/
|
|
86
|
+
let searchGeneration = 0;
|
|
87
|
+
/**
|
|
88
|
+
* Identifier of the shortcode the user has dismissed with the Escape key, so the suggestions
|
|
89
|
+
* don’t come back while they keep typing it.
|
|
90
|
+
* @type {string | undefined}
|
|
91
|
+
*/
|
|
92
|
+
let dismissedShortcodeId = $state();
|
|
93
|
+
|
|
94
|
+
const open = $derived(!!trigger && !!candidates.length);
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Position of the dropdown, flipped above the caret and clamped to the viewport as needed.
|
|
98
|
+
*
|
|
99
|
+
* The dropdown grows away from the start of the line, so it follows the reading direction rather
|
|
100
|
+
* than reaching back across the text: in a right-to-left layout it hangs from the shortcode’s
|
|
101
|
+
* right edge and extends leftwards, mirroring what `activatePopup()` does for anchored popups.
|
|
102
|
+
*/
|
|
103
|
+
const position = $derived.by(() => {
|
|
104
|
+
if (!anchorRect) {
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const { innerWidth, innerHeight } = window;
|
|
109
|
+
const spaceBelow = innerHeight - anchorRect.bottom;
|
|
110
|
+
const spaceAbove = anchorRect.top;
|
|
111
|
+
const flipped = spaceBelow < LIST_MAX_HEIGHT + VIEWPORT_MARGIN && spaceAbove > spaceBelow;
|
|
112
|
+
const rtl = document.dir === 'rtl';
|
|
113
|
+
const anchorLeft = rtl ? anchorRect.right - LIST_WIDTH : anchorRect.left;
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
top: flipped ? undefined : `${Math.round(anchorRect.bottom + LIST_OFFSET)}px`,
|
|
117
|
+
bottom: flipped ? `${Math.round(innerHeight - anchorRect.top + LIST_OFFSET)}px` : undefined,
|
|
118
|
+
left: `${Math.round(
|
|
119
|
+
Math.max(VIEWPORT_MARGIN, Math.min(anchorLeft, innerWidth - LIST_WIDTH - VIEWPORT_MARGIN)),
|
|
120
|
+
)}px`,
|
|
121
|
+
maxHeight: `${Math.round(
|
|
122
|
+
Math.min(
|
|
123
|
+
LIST_MAX_HEIGHT,
|
|
124
|
+
(flipped ? spaceAbove : spaceBelow) - LIST_OFFSET - VIEWPORT_MARGIN,
|
|
125
|
+
),
|
|
126
|
+
)}px`,
|
|
127
|
+
};
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Whether any suggestions are currently shown.
|
|
132
|
+
* @returns {boolean} Result.
|
|
133
|
+
*/
|
|
134
|
+
export const isOpen = () => open;
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Close the list and forget the current shortcode.
|
|
138
|
+
* @param {boolean} [dismissed] Whether the user has dismissed the list, in which case it stays
|
|
139
|
+
* closed until they move on to another shortcode.
|
|
140
|
+
*/
|
|
141
|
+
export const close = (dismissed = false) => {
|
|
142
|
+
dismissedShortcodeId = dismissed && trigger ? trigger.id : undefined;
|
|
143
|
+
trigger = undefined;
|
|
144
|
+
candidates = [];
|
|
145
|
+
selectedIndex = 0;
|
|
146
|
+
anchorRect = undefined;
|
|
147
|
+
searchGeneration += 1;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Feed the shortcode being typed into the list, or nothing to close it.
|
|
152
|
+
* @param {EmojiTrigger} [newTrigger] Shortcode state.
|
|
153
|
+
*/
|
|
154
|
+
export const update = async (newTrigger) => {
|
|
155
|
+
if (!newTrigger) {
|
|
156
|
+
if (trigger || dismissedShortcodeId) {
|
|
157
|
+
close();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// The user has dismissed this very shortcode with the Escape key
|
|
164
|
+
if (dismissedShortcodeId === newTrigger.id) {
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const { id, query } = newTrigger;
|
|
169
|
+
|
|
170
|
+
dismissedShortcodeId = undefined;
|
|
171
|
+
anchorRect = getAnchorRect(newTrigger);
|
|
172
|
+
|
|
173
|
+
// The caret may have moved without the query changing, e.g. with an undo; keep the existing
|
|
174
|
+
// suggestions in that case, so the highlighted item doesn’t jump back to the top
|
|
175
|
+
if (trigger?.id === id && trigger.query === query) {
|
|
176
|
+
trigger = newTrigger;
|
|
177
|
+
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
trigger = newTrigger;
|
|
182
|
+
searchGeneration += 1;
|
|
183
|
+
|
|
184
|
+
const generation = searchGeneration;
|
|
185
|
+
|
|
186
|
+
await loadEmojiList();
|
|
187
|
+
|
|
188
|
+
// Bail out if the user has kept typing in the meantime
|
|
189
|
+
if (generation !== searchGeneration) {
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
candidates = searchEmojis(query);
|
|
194
|
+
selectedIndex = 0;
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Move the highlight by the given amount, wrapping around at both ends.
|
|
199
|
+
* @param {number} delta Number of items to move.
|
|
200
|
+
*/
|
|
201
|
+
export const moveSelection = (delta) => {
|
|
202
|
+
const { length } = candidates;
|
|
203
|
+
|
|
204
|
+
if (length) {
|
|
205
|
+
selectedIndex = (selectedIndex + delta + length) % length;
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Apply the highlighted suggestion and close the list.
|
|
211
|
+
*/
|
|
212
|
+
export const selectHighlighted = () => {
|
|
213
|
+
const entry = candidates[selectedIndex];
|
|
214
|
+
|
|
215
|
+
if (entry && trigger) {
|
|
216
|
+
onSelect(entry, trigger);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
close();
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Handle a `keydown` event on the field the list is attached to.
|
|
224
|
+
* @param {KeyboardEvent} event `keydown` event.
|
|
225
|
+
* @returns {boolean} `true` if the list consumed the event, in which case the field should
|
|
226
|
+
* ignore it.
|
|
227
|
+
*/
|
|
228
|
+
export const handleKeyDown = (event) => {
|
|
229
|
+
const { key, altKey, ctrlKey, metaKey, shiftKey } = event;
|
|
230
|
+
|
|
231
|
+
if (!open || altKey || ctrlKey || metaKey) {
|
|
232
|
+
return false;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (key === 'ArrowDown' || key === 'ArrowUp') {
|
|
236
|
+
moveSelection(key === 'ArrowDown' ? 1 : -1);
|
|
237
|
+
} else if ((key === 'Enter' || key === 'Tab') && !shiftKey) {
|
|
238
|
+
selectHighlighted();
|
|
239
|
+
} else if (key === 'Escape') {
|
|
240
|
+
close(true);
|
|
241
|
+
} else {
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
event.preventDefault();
|
|
246
|
+
|
|
247
|
+
return true;
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
// Move the dropdown to the top layer, so it’s not clipped by anything around the field
|
|
251
|
+
$effect(() => {
|
|
252
|
+
if (listElement && !listElement.matches(':popover-open')) {
|
|
253
|
+
listElement.showPopover?.();
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
// Advertise that typing here can bring up predictions, for as long as the autocomplete is
|
|
258
|
+
// attached. `aria-autocomplete` applies to a textbox as much as to a combobox, so the field keeps
|
|
259
|
+
// whatever role it already had rather than being relabelled — a rich text editor is not a
|
|
260
|
+
// combobox, and neither is a comment field. `list` says the predictions appear in a popup rather
|
|
261
|
+
// than being completed inline.
|
|
262
|
+
// @see https://w3c.github.io/aria/#aria-autocomplete
|
|
263
|
+
$effect(() => {
|
|
264
|
+
if (!ariaOwner) {
|
|
265
|
+
return undefined;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const owner = ariaOwner;
|
|
269
|
+
|
|
270
|
+
owner.setAttribute('aria-autocomplete', 'list');
|
|
271
|
+
owner.setAttribute('aria-haspopup', 'listbox');
|
|
272
|
+
|
|
273
|
+
return () => {
|
|
274
|
+
owner.removeAttribute('aria-autocomplete');
|
|
275
|
+
owner.removeAttribute('aria-haspopup');
|
|
276
|
+
};
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
// Point at the highlighted suggestion, given the focus stays in the field. The list is only in
|
|
280
|
+
// the DOM while open, so it can only be referenced while it is. There is deliberately no
|
|
281
|
+
// `aria-expanded`, which a textbox doesn’t support; the active descendant appearing and
|
|
282
|
+
// disappearing is what conveys the list opening and closing.
|
|
283
|
+
$effect(() => {
|
|
284
|
+
if (!ariaOwner) {
|
|
285
|
+
return undefined;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const owner = ariaOwner;
|
|
289
|
+
|
|
290
|
+
if (open) {
|
|
291
|
+
owner.setAttribute('aria-controls', listId);
|
|
292
|
+
owner.setAttribute('aria-activedescendant', `${listId}-option-${selectedIndex}`);
|
|
293
|
+
} else {
|
|
294
|
+
owner.removeAttribute('aria-controls');
|
|
295
|
+
owner.removeAttribute('aria-activedescendant');
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return () => {
|
|
299
|
+
owner.removeAttribute('aria-controls');
|
|
300
|
+
owner.removeAttribute('aria-activedescendant');
|
|
301
|
+
};
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
// Keep the highlighted suggestion visible while the user arrows through a long list
|
|
305
|
+
$effect(() => {
|
|
306
|
+
void selectedIndex;
|
|
307
|
+
|
|
308
|
+
listElement?.querySelector('[aria-selected="true"]')?.scrollIntoView({ block: 'nearest' });
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
onMount(() => {
|
|
312
|
+
// Warm the data up front, so the first shortcode the user types shows suggestions straight away
|
|
313
|
+
// rather than waiting on the network. The loader memoizes, so this costs one request per page
|
|
314
|
+
// however many fields are on it, and nothing at all once it’s cached.
|
|
315
|
+
loadEmojiList();
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Follow the caret when the page or an ancestor is scrolled or the window is resized.
|
|
319
|
+
*/
|
|
320
|
+
const reposition = () => {
|
|
321
|
+
if (trigger) {
|
|
322
|
+
anchorRect = getAnchorRect(trigger);
|
|
323
|
+
}
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
window.addEventListener('scroll', reposition, { capture: true, passive: true });
|
|
327
|
+
window.addEventListener('resize', reposition, { passive: true });
|
|
328
|
+
|
|
329
|
+
return () => {
|
|
330
|
+
window.removeEventListener('scroll', reposition, { capture: true });
|
|
331
|
+
window.removeEventListener('resize', reposition);
|
|
332
|
+
};
|
|
333
|
+
});
|
|
334
|
+
</script>
|
|
335
|
+
|
|
336
|
+
{#if open && position}
|
|
337
|
+
<div
|
|
338
|
+
bind:this={listElement}
|
|
339
|
+
id={listId}
|
|
340
|
+
role="listbox"
|
|
341
|
+
class="sui emoji-suggestions"
|
|
342
|
+
aria-label={_('_sui.emoji_suggestions')}
|
|
343
|
+
popover="manual"
|
|
344
|
+
style:top={position.top}
|
|
345
|
+
style:bottom={position.bottom}
|
|
346
|
+
style:left={position.left}
|
|
347
|
+
style:max-height={position.maxHeight}
|
|
348
|
+
>
|
|
349
|
+
{#each candidates as entry, index (entry.emoji)}
|
|
350
|
+
<div
|
|
351
|
+
id="{listId}-option-{index}"
|
|
352
|
+
role="option"
|
|
353
|
+
class="option"
|
|
354
|
+
tabindex="-1"
|
|
355
|
+
aria-selected={index === selectedIndex}
|
|
356
|
+
onmouseenter={() => {
|
|
357
|
+
selectedIndex = index;
|
|
358
|
+
}}
|
|
359
|
+
onmousedown={(event) => {
|
|
360
|
+
// Keep the focus and the caret where they are, so the shortcode can be replaced
|
|
361
|
+
event.preventDefault();
|
|
362
|
+
selectedIndex = index;
|
|
363
|
+
selectHighlighted();
|
|
364
|
+
}}
|
|
365
|
+
>
|
|
366
|
+
<span role="none" class="emoji">{entry.emoji}</span>
|
|
367
|
+
<span role="none" class="name">:{entry.name}:</span>
|
|
368
|
+
</div>
|
|
369
|
+
{/each}
|
|
370
|
+
</div>
|
|
371
|
+
{/if}
|
|
372
|
+
|
|
373
|
+
<style>.emoji-suggestions {
|
|
374
|
+
position: fixed;
|
|
375
|
+
inset: auto;
|
|
376
|
+
z-index: 1000;
|
|
377
|
+
display: flex;
|
|
378
|
+
flex-direction: column;
|
|
379
|
+
overflow-y: auto;
|
|
380
|
+
margin: 0;
|
|
381
|
+
border-width: var(--sui-listbox-border-width, 1px);
|
|
382
|
+
border-style: var(--sui-listbox-border-style, solid);
|
|
383
|
+
border-color: var(--sui-listbox-border-width, var(--sui-secondary-border-color));
|
|
384
|
+
border-radius: var(--sui-listbox-border-radius, 4px);
|
|
385
|
+
padding: var(--sui-listbox-padding, 4px);
|
|
386
|
+
width: 280px;
|
|
387
|
+
color: var(--sui-primary-foreground-color);
|
|
388
|
+
background-color: var(--sui-secondary-background-color-translucent);
|
|
389
|
+
box-shadow: 0 8px 16px var(--sui-popup-shadow-color);
|
|
390
|
+
-webkit-backdrop-filter: blur(16px);
|
|
391
|
+
backdrop-filter: blur(16px);
|
|
392
|
+
font-family: var(--sui-control-font-family);
|
|
393
|
+
font-size: var(--sui-control-font-size);
|
|
394
|
+
line-height: var(--sui-control-line-height);
|
|
395
|
+
-webkit-user-select: none;
|
|
396
|
+
user-select: none;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
.option {
|
|
400
|
+
flex: none;
|
|
401
|
+
display: flex;
|
|
402
|
+
align-items: center;
|
|
403
|
+
gap: 8px;
|
|
404
|
+
border-radius: var(--sui-option-border-radius);
|
|
405
|
+
padding: var(--sui-option-padding);
|
|
406
|
+
min-height: var(--sui-option-height);
|
|
407
|
+
cursor: default;
|
|
408
|
+
}
|
|
409
|
+
.option[aria-selected=true] {
|
|
410
|
+
color: var(--sui-highlight-foreground-color);
|
|
411
|
+
background-color: var(--sui-hover-background-color);
|
|
412
|
+
}
|
|
413
|
+
.option .emoji {
|
|
414
|
+
flex: none;
|
|
415
|
+
width: 1.5em;
|
|
416
|
+
font-size: var(--sui-font-size-large);
|
|
417
|
+
text-align: center;
|
|
418
|
+
}
|
|
419
|
+
.option .name {
|
|
420
|
+
flex: auto;
|
|
421
|
+
overflow: hidden;
|
|
422
|
+
white-space: nowrap;
|
|
423
|
+
text-overflow: ellipsis;
|
|
424
|
+
}</style>
|