@sveltia/ui 0.65.0 → 0.65.2

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.
@@ -40,11 +40,17 @@
40
40
  } = $props();
41
41
 
42
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.
43
+ * Width of the dropdown, also enforced in the stylesheet below.
45
44
  */
46
45
  const LIST_WIDTH = 280;
47
- const LIST_MAX_HEIGHT = 280;
46
+ /**
47
+ * How many suggestions are visible at once. The rest are reached by scrolling.
48
+ */
49
+ const VISIBLE_ROWS = 5;
50
+ /**
51
+ * Height to assume until a row has been measured, so the first open is positioned sensibly.
52
+ */
53
+ const FALLBACK_MAX_HEIGHT = 180;
48
54
  /**
49
55
  * Gap between the dropdown and the caret, and the minimum margin to the viewport edges.
50
56
  */
@@ -93,6 +99,23 @@
93
99
 
94
100
  const open = $derived(!!trigger && !!candidates.length);
95
101
 
102
+ /**
103
+ * Height of one suggestion, measured rather than assumed so the dropdown still shows exactly
104
+ * {@link VISIBLE_ROWS} of them whatever the theme makes a row.
105
+ * @type {number}
106
+ */
107
+ let rowHeight = $state(0);
108
+ /**
109
+ * The dropdown’s own vertical padding and border, which sit outside the rows. `box-sizing` is
110
+ * `border-box`, so `max-height` has to cover them for the rows to get their full share.
111
+ * @type {number}
112
+ */
113
+ let listChrome = $state(0);
114
+
115
+ const listMaxHeight = $derived(
116
+ rowHeight ? rowHeight * VISIBLE_ROWS + listChrome : FALLBACK_MAX_HEIGHT,
117
+ );
118
+
96
119
  /**
97
120
  * Position of the dropdown, flipped above the caret and clamped to the viewport as needed.
98
121
  *
@@ -108,7 +131,7 @@
108
131
  const { innerWidth, innerHeight } = window;
109
132
  const spaceBelow = innerHeight - anchorRect.bottom;
110
133
  const spaceAbove = anchorRect.top;
111
- const flipped = spaceBelow < LIST_MAX_HEIGHT + VIEWPORT_MARGIN && spaceAbove > spaceBelow;
134
+ const flipped = spaceBelow < listMaxHeight + VIEWPORT_MARGIN && spaceAbove > spaceBelow;
112
135
  const rtl = document.dir === 'rtl';
113
136
  const anchorLeft = rtl ? anchorRect.right - LIST_WIDTH : anchorRect.left;
114
137
 
@@ -120,7 +143,7 @@
120
143
  )}px`,
121
144
  maxHeight: `${Math.round(
122
145
  Math.min(
123
- LIST_MAX_HEIGHT,
146
+ listMaxHeight,
124
147
  (flipped ? spaceAbove : spaceBelow) - LIST_OFFSET - VIEWPORT_MARGIN,
125
148
  ),
126
149
  )}px`,
@@ -247,11 +270,34 @@
247
270
  return true;
248
271
  };
249
272
 
250
- // Move the dropdown to the top layer, so it’s not clipped by anything around the field
273
+ // Move the dropdown to the top layer, so it’s not clipped by anything around the field, then
274
+ // measure a row. The measurement has to happen after the popover is shown, because until then the
275
+ // element isn’t rendered at all and everything measures zero. A row’s height comes from the
276
+ // theme’s control height plus its padding, so it’s read back rather than assumed.
251
277
  $effect(() => {
252
- if (listElement && !listElement.matches(':popover-open')) {
278
+ void candidates;
279
+
280
+ if (!listElement) {
281
+ return;
282
+ }
283
+
284
+ if (!listElement.matches(':popover-open')) {
253
285
  listElement.showPopover?.();
254
286
  }
287
+
288
+ const row = listElement.querySelector('.option');
289
+
290
+ if (row) {
291
+ const { paddingTop, paddingBottom, borderTopWidth, borderBottomWidth } =
292
+ getComputedStyle(listElement);
293
+
294
+ rowHeight = row.getBoundingClientRect().height;
295
+ listChrome =
296
+ Number.parseFloat(paddingTop) +
297
+ Number.parseFloat(paddingBottom) +
298
+ Number.parseFloat(borderTopWidth) +
299
+ Number.parseFloat(borderBottomWidth);
300
+ }
255
301
  });
256
302
 
257
303
  // Advertise that typing here can bring up predictions, for as long as the autocomplete is
@@ -301,11 +347,15 @@
301
347
  };
302
348
  });
303
349
 
304
- // Keep the highlighted suggestion visible while the user arrows through a long list
350
+ // Keep the highlighted suggestion visible while the user arrows through a long list. The scroll
351
+ // has to be instant: an inherited `scroll-behavior: smooth` otherwise animates it, and the
352
+ // animation never lands while the list is in the top layer, leaving the highlight off screen.
305
353
  $effect(() => {
306
354
  void selectedIndex;
307
355
 
308
- listElement?.querySelector('[aria-selected="true"]')?.scrollIntoView({ block: 'nearest' });
356
+ listElement
357
+ ?.querySelector('[aria-selected="true"]')
358
+ ?.scrollIntoView({ block: 'nearest', behavior: 'instant' });
309
359
  });
310
360
 
311
361
  onMount(() => {
@@ -323,12 +373,35 @@
323
373
  }
324
374
  };
325
375
 
376
+ /**
377
+ * Dismiss the list when the user presses somewhere else on the page.
378
+ *
379
+ * The popover is `manual` rather than `auto`, so the platform’s own light dismiss is off: an
380
+ * `auto` popover also closes itself on Escape, which would fight the Escape handling here, and
381
+ * it would close on a press inside the field the list belongs to, where the caret moving is
382
+ * what should decide. This covers the one behavior worth borrowing.
383
+ * @param {PointerEvent} event `pointerdown` event.
384
+ */
385
+ const onPointerDown = ({ target }) => {
386
+ const node = /** @type {Node} */ (target);
387
+
388
+ // A press on the list is a choice, not a dismissal, and this runs before the option’s own
389
+ // handler; a press in the field is left to the caret to sort out
390
+ if (!open || listElement?.contains(node) || ariaOwner?.contains(node)) {
391
+ return;
392
+ }
393
+
394
+ close();
395
+ };
396
+
326
397
  window.addEventListener('scroll', reposition, { capture: true, passive: true });
327
398
  window.addEventListener('resize', reposition, { passive: true });
399
+ document.addEventListener('pointerdown', onPointerDown, { capture: true });
328
400
 
329
401
  return () => {
330
402
  window.removeEventListener('scroll', reposition, { capture: true });
331
403
  window.removeEventListener('resize', reposition);
404
+ document.removeEventListener('pointerdown', onPointerDown, { capture: true });
332
405
  };
333
406
  });
334
407
  </script>
@@ -8,9 +8,12 @@
8
8
  */
9
9
  export const EMOJI_TRIGGER_REGEX: RegExp;
10
10
  /**
11
- * Maximum number of emoji suggestions shown at a time.
11
+ * Maximum number of emoji suggestions offered, matching what Discord shows. The dropdown displays
12
+ * five at a time and scrolls through the rest, so this only bounds how far a query can be explored
13
+ * — a single letter otherwise matches over a thousand emojis, all rendered on every keystroke.
12
14
  */
13
- export const MAX_EMOJI_SUGGESTIONS: 10;
15
+ export const MAX_EMOJI_SUGGESTIONS: 50;
16
+ export function normalizeEmojiName(name: string): string;
14
17
  export function parseEmojiData(data: EmojiData): EmojiEntry[];
15
18
  export function loadEmojiList(): Promise<EmojiEntry[]>;
16
19
  /**
@@ -13,9 +13,11 @@ import { getEmojiDataLoader } from './loader.js';
13
13
  export const EMOJI_TRIGGER_REGEX = /(?<=^|[\s([{"'«])(?::)(?<query>[a-zA-Z0-9_+-]{1,32})$/;
14
14
 
15
15
  /**
16
- * Maximum number of emoji suggestions shown at a time.
16
+ * Maximum number of emoji suggestions offered, matching what Discord shows. The dropdown displays
17
+ * five at a time and scrolls through the rest, so this only bounds how far a query can be explored
18
+ * — a single letter otherwise matches over a thousand emojis, all rendered on every keystroke.
17
19
  */
18
- export const MAX_EMOJI_SUGGESTIONS = 10;
20
+ export const MAX_EMOJI_SUGGESTIONS = 50;
19
21
 
20
22
  /**
21
23
  * Cached emoji list. This is `undefined` until {@link loadEmojiList} resolves for the first time.
@@ -28,6 +30,24 @@ let emojiList;
28
30
  */
29
31
  let loader;
30
32
 
33
+ /**
34
+ * Rewrite an emoji name as a shortcode the user can actually type.
35
+ *
36
+ * Most names are already lower case words joined with underscores, but a hundred or so of the newer
37
+ * ones are written with spaces or commas instead, like `heart hands`. A query can contain neither,
38
+ * so those names would be unreachable by their own shortcode and would be shown as something the
39
+ * user can’t type back.
40
+ * @internal
41
+ * @param {string} name Name as published.
42
+ * @returns {string} Name made up of the characters a query can contain.
43
+ */
44
+ export const normalizeEmojiName = (name) =>
45
+ name
46
+ .toLowerCase()
47
+ // Runs of anything a query can’t contain become a single separator
48
+ .replace(/[^a-z0-9_+-]+/g, '_')
49
+ .replace(/^_+|_+$/g, '');
50
+
31
51
  /**
32
52
  * Convert the raw emoji data into a searchable list.
33
53
  * @internal
@@ -38,7 +58,7 @@ let loader;
38
58
  export const parseEmojiData = (data) =>
39
59
  Object.entries(data).map(([emoji, [name, ...aliases]]) => ({
40
60
  emoji,
41
- name,
61
+ name: normalizeEmojiName(name),
42
62
  // Some of the keywords are capitalized, e.g. `NASA` and `XD`
43
63
  aliases: aliases.map((alias) => alias.toLowerCase()),
44
64
  }));
@@ -86,9 +106,11 @@ export const NO_EMOJI_MATCH = 9;
86
106
  /**
87
107
  * Get how well an emoji’s name matches the given query. A lower rank means a better match.
88
108
  *
89
- * The name is matched word by word rather than only as a whole, so a partly typed `:cana` reaches
90
- * `flag_canada`’s second word just as `:canada` does. The whole name is tested as well, so a query
91
- * spanning a word boundary, like `:flag_can`, still matches.
109
+ * A match has to start at a word boundary. The name is matched word by word rather than only as a
110
+ * whole, so a partly typed `:cana` reaches `flag_canada`’s second word just as `:canada` does, and
111
+ * the whole name is tested as well, so a query spanning a word boundary like `:flag_can` still
112
+ * matches. What this rules out is a match starting mid-word, which is nearly always coincidental:
113
+ * `:age` would otherwise turn up `mage`, `bagel`, `baggage`, `pager` and `package`.
92
114
  * @internal
93
115
  * @param {string} name Canonical emoji name.
94
116
  * @param {string} query Lower-cased search query without the leading colon.
@@ -101,16 +123,20 @@ export const getEmojiNameMatchRank = (name, query) => {
101
123
  return 0;
102
124
  }
103
125
 
104
- if (words.includes(query)) {
126
+ // What the name leads with is what the emoji mostly is, so `heart_hands` is a better `:heart`
127
+ // match than `sparkling_heart`, where the word merely turns up along the way. The whole leading
128
+ // word has to match: `japanese_castle` is not what `:japan` is after, nor `crystal_ball` `:cry`.
129
+ if (name.startsWith(`${query}_`)) {
105
130
  return 1;
106
131
  }
107
132
 
108
- if (name.startsWith(query) || words.some((word) => word.startsWith(query))) {
109
- return 3;
133
+ if (words.includes(query)) {
134
+ return 2;
110
135
  }
111
136
 
112
- if (name.includes(query)) {
113
- return 5;
137
+ // The whole name is tested too, so a query spanning a word boundary like `:heart_h` still matches
138
+ if (name.startsWith(query) || words.some((word) => word.startsWith(query))) {
139
+ return 4;
114
140
  }
115
141
 
116
142
  return NO_EMOJI_MATCH;
@@ -127,11 +153,11 @@ export const getEmojiNameMatchRank = (name, query) => {
127
153
  */
128
154
  export const getEmojiAliasMatchRank = (aliases, query) => {
129
155
  if (aliases.includes(query)) {
130
- return 2;
156
+ return 3;
131
157
  }
132
158
 
133
159
  if (aliases.some((alias) => alias.startsWith(query))) {
134
- return 4;
160
+ return 5;
135
161
  }
136
162
 
137
163
  return NO_EMOJI_MATCH;
@@ -158,6 +184,30 @@ export const getEmojiMatchRank = ({ name, aliases }, query) => {
158
184
  return { rank: Math.min(nameRank, aliasRank), nameRank };
159
185
  };
160
186
 
187
+ /**
188
+ * Get how central a match is to the emoji, to separate emojis that match equally well. A lower
189
+ * number means the query is more of what the emoji is about.
190
+ *
191
+ * For a name match, that’s how much of the name the query accounts for: `red_heart` is more of a
192
+ * `:heart` than `smiling_face_with_heart_eyes` is. For a keyword match, it’s how prominent the
193
+ * keyword is — `emojilib` lists them roughly in order of relevance, so a keyword listed first, in a
194
+ * short list, is what the emoji is really for. `love` is the first of 🫶’s three keywords, while
195
+ * 💏 `kiss` buries it third among nineteen.
196
+ * @internal
197
+ * @param {EmojiEntry} entry Emoji entry.
198
+ * @param {string} query Lower-cased search query without the leading colon.
199
+ * @returns {number} Centrality, comparable only between equally ranked emojis.
200
+ */
201
+ const getMatchCentrality = ({ name, aliases }, query) => {
202
+ if (getEmojiNameMatchRank(name, query) < NO_EMOJI_MATCH) {
203
+ return name.split('_').length;
204
+ }
205
+
206
+ const index = aliases.findIndex((alias) => alias === query || alias.startsWith(query));
207
+
208
+ return (index > -1 ? index : aliases.length) * 100 + aliases.length;
209
+ };
210
+
161
211
  /**
162
212
  * Search the loaded emoji list for the given query. This returns an empty list unless
163
213
  * {@link loadEmojiList} has been resolved beforehand.
@@ -174,11 +224,19 @@ export const searchEmojis = (query) => {
174
224
 
175
225
  return (
176
226
  emojiList
177
- .map((entry) => ({ entry, ...getEmojiMatchRank(entry, normalizedQuery) }))
227
+ .map((entry) => {
228
+ const { rank, nameRank } = getEmojiMatchRank(entry, normalizedQuery);
229
+
230
+ return { entry, rank, nameRank, centrality: getMatchCentrality(entry, normalizedQuery) };
231
+ })
178
232
  .filter(({ rank }) => rank < NO_EMOJI_MATCH)
179
- // Equally ranked emojis are settled by the name, then by the original order, which roughly
180
- // goes from the most to the least commonly used — `Array.prototype.sort()` is stable
181
- .sort((a, b) => a.rank - b.rank || a.nameRank - b.nameRank)
233
+ // Equally ranked emojis are settled by the name, then by how central the match is to the
234
+ // emoji, then by the published order — `Array.prototype.sort()` is stable.
235
+ //
236
+ // That last resort is weak: the published order follows the Unicode categories, not how often
237
+ // an emoji is used, and newer emojis are simply appended. 🫶 `heart_hands` sits at 1826, so
238
+ // without the two keys before it, it loses every tie to whatever happens to be older.
239
+ .sort((a, b) => a.rank - b.rank || a.nameRank - b.nameRank || a.centrality - b.centrality)
182
240
  .slice(0, MAX_EMOJI_SUGGESTIONS)
183
241
  .map(({ entry }) => entry)
184
242
  );
@@ -1,6 +1,17 @@
1
+ /**
2
+ * @import { EmojiData } from '../../typedefs';
3
+ */
4
+ /**
5
+ * Version of `emojilib` the data is fetched from, which also scopes the cache so a bump invalidates
6
+ * it.
7
+ *
8
+ * This is written out rather than read from `emojilib/package.json`, because this package ships
9
+ * unbundled: a consumer’s test runner may well load these modules through Node, where a JSON import
10
+ * needs an import attribute and exposes no named exports, and not every bundler understands the
11
+ * attribute either. A test keeps this in step with the installed version.
12
+ */
13
+ export const EMOJILIB_VERSION: "4.0.3";
1
14
  export function getEmojiDataURL(): string;
2
- export { EMOJILIB_VERSION };
3
15
  export function setEmojiDataLoader(newLoader: () => Promise<EmojiData>): void;
4
16
  export function getEmojiDataLoader(): () => Promise<EmojiData>;
5
- import { version as EMOJILIB_VERSION } from 'emojilib/package.json';
6
17
  import type { EmojiData } from '../../typedefs';
@@ -1,9 +1,18 @@
1
- import { version as EMOJILIB_VERSION } from 'emojilib/package.json';
2
-
3
1
  /**
4
2
  * @import { EmojiData } from '../../typedefs';
5
3
  */
6
4
 
5
+ /**
6
+ * Version of `emojilib` the data is fetched from, which also scopes the cache so a bump invalidates
7
+ * it.
8
+ *
9
+ * This is written out rather than read from `emojilib/package.json`, because this package ships
10
+ * unbundled: a consumer’s test runner may well load these modules through Node, where a JSON import
11
+ * needs an import attribute and exposes no named exports, and not every bundler understands the
12
+ * attribute either. A test keeps this in step with the installed version.
13
+ */
14
+ export const EMOJILIB_VERSION = '4.0.3';
15
+
7
16
  const CDN_BASE_URL = 'https://unpkg.com/emojilib';
8
17
  /**
9
18
  * How long to wait for the emoji data, in milliseconds. The suggestions are a convenience, so a
@@ -16,17 +25,12 @@ const FETCH_TIMEOUT = 5000;
16
25
  *
17
26
  * The data is fetched rather than bundled because it’s a few hundred kilobytes that most sessions
18
27
  * never need, and a single-file bundle — our main consumer, Sveltia CMS — would otherwise inline it
19
- * wholesale. The version comes from the installed `emojilib`, so the URL always matches the
20
- * package this was developed against.
28
+ * wholesale. The URL is pinned to {@link EMOJILIB_VERSION}, so the data always matches the package
29
+ * this was developed against.
21
30
  * @returns {string} URL.
22
31
  */
23
32
  export const getEmojiDataURL = () => `${CDN_BASE_URL}@${EMOJILIB_VERSION}/dist/emoji-en-US.json`;
24
33
 
25
- /**
26
- * Version of the emoji data, used to scope the cache so a bump invalidates it.
27
- */
28
- export { EMOJILIB_VERSION };
29
-
30
34
  /**
31
35
  * Fetch the emoji data from the CDN.
32
36
  * @returns {Promise<EmojiData>} Emoji data, keyed by emoji character.
@@ -7,7 +7,7 @@ export const SHIKI_VERSION: "4.4.3";
7
7
  /**
8
8
  * Version of this package, used to resolve the prebuilt Shiki engine chunk from a CDN.
9
9
  */
10
- export const UI_VERSION: "0.65.0";
10
+ export const UI_VERSION: "0.65.2";
11
11
  /**
12
12
  * Available syntax highlighting languages, sorted by display name.
13
13
  * @type {{ id: string, name: string, aliases?: string[] }[]}
@@ -10,7 +10,7 @@ export const SHIKI_VERSION = "4.4.3";
10
10
  /**
11
11
  * Version of this package, used to resolve the prebuilt Shiki engine chunk from a CDN.
12
12
  */
13
- export const UI_VERSION = "0.65.0";
13
+ export const UI_VERSION = "0.65.2";
14
14
 
15
15
  /**
16
16
  * Available syntax highlighting languages, sorted by display name.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sveltia/ui",
3
- "version": "0.65.0",
3
+ "version": "0.65.2",
4
4
  "description": "A collection of Svelte components and utilities for building user interfaces.",
5
5
  "repository": {
6
6
  "type": "git",