@sveltia/ui 0.71.1 → 0.72.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.
Files changed (88) hide show
  1. package/dist/components/alert/infobar.svelte +1 -1
  2. package/dist/components/button/button.svelte +1 -1
  3. package/dist/components/button/select-button.svelte +2 -1
  4. package/dist/components/calendar/calendar.d.ts +20 -0
  5. package/dist/components/calendar/calendar.js +81 -0
  6. package/dist/components/calendar/calendar.svelte +23 -45
  7. package/dist/components/checkbox/checkbox-group.svelte +1 -1
  8. package/dist/components/checkbox/checkbox.svelte +6 -1
  9. package/dist/components/dialog/dialog.svelte +1 -1
  10. package/dist/components/drawer/drawer.svelte +1 -1
  11. package/dist/components/emoji/emoji-suggestions.svelte +14 -40
  12. package/dist/components/emoji/position.d.ts +44 -0
  13. package/dist/components/emoji/position.js +60 -0
  14. package/dist/components/progressbar/progressbar.svelte +1 -1
  15. package/dist/components/radio/radio-group.svelte +1 -1
  16. package/dist/components/resizable-pane/resizable-handle.svelte +15 -47
  17. package/dist/components/resizable-pane/resizable-pane-group.svelte +37 -100
  18. package/dist/components/resizable-pane/resizable-pane.svelte +1 -1
  19. package/dist/components/resizable-pane/sizing.d.ts +48 -0
  20. package/dist/components/resizable-pane/sizing.js +190 -0
  21. package/dist/components/scroll/infinite-scroll.svelte +2 -0
  22. package/dist/components/select/combobox.svelte +3 -1
  23. package/dist/components/select/select-tags.d.ts +17 -0
  24. package/dist/components/select/select-tags.js +95 -0
  25. package/dist/components/select/select-tags.svelte +26 -28
  26. package/dist/components/slider/slider.d.ts +17 -0
  27. package/dist/components/slider/slider.js +96 -0
  28. package/dist/components/slider/slider.svelte +30 -56
  29. package/dist/components/tabs/tab-box.svelte +1 -1
  30. package/dist/components/tabs/tab-list.d.ts +6 -0
  31. package/dist/components/tabs/tab-list.js +19 -0
  32. package/dist/components/tabs/tab-list.svelte +2 -14
  33. package/dist/components/text-editor/code-editor.d.ts +5 -0
  34. package/dist/components/text-editor/code-editor.js +28 -0
  35. package/dist/components/text-editor/code-editor.svelte +7 -8
  36. package/dist/components/text-editor/emoji-autocomplete.svelte +9 -1
  37. package/dist/components/text-editor/lexical-root.svelte +3 -1
  38. package/dist/components/text-editor/shiki/facade.js +1 -1
  39. package/dist/components/text-editor/shiki/highlighter.js +40 -15
  40. package/dist/components/text-editor/shiki/version.d.ts +1 -1
  41. package/dist/components/text-editor/shiki/version.js +1 -1
  42. package/dist/components/text-editor/store.svelte.js +17 -7
  43. package/dist/components/text-editor/text-editor.svelte +4 -0
  44. package/dist/components/text-editor/toolbar/code-editor-toolbar.svelte +1 -1
  45. package/dist/components/text-editor/toolbar/code-language-switcher.svelte +4 -1
  46. package/dist/components/text-editor/toolbar/format-text-button.svelte +3 -1
  47. package/dist/components/text-editor/toolbar/insert-item-button.svelte +1 -1
  48. package/dist/components/text-editor/toolbar/insert-link-button.svelte +10 -3
  49. package/dist/components/text-editor/toolbar/text-editor-toolbar.svelte +4 -5
  50. package/dist/components/text-editor/toolbar/toggle-block-menu-item.svelte +2 -0
  51. package/dist/components/text-field/emoji-autocomplete.svelte +2 -0
  52. package/dist/components/text-field/number-input.d.ts +15 -0
  53. package/dist/components/text-field/number-input.js +98 -0
  54. package/dist/components/text-field/number-input.svelte +27 -23
  55. package/dist/components/text-field/text-input.svelte +9 -3
  56. package/dist/components/toast/toast.svelte +7 -2
  57. package/dist/components/toolbar/toolbar.svelte +1 -1
  58. package/dist/components/typography/truncated-text.svelte +2 -1
  59. package/dist/components/util/app-shell.d.ts +1 -0
  60. package/dist/components/util/app-shell.js +29 -0
  61. package/dist/components/util/app-shell.svelte +4 -18
  62. package/dist/components/util/modal.d.ts +5 -0
  63. package/dist/components/util/modal.js +22 -0
  64. package/dist/components/util/modal.svelte +9 -10
  65. package/dist/components/util/popup.svelte +11 -0
  66. package/dist/locales/ar.yaml +2 -2
  67. package/dist/locales/ca.yaml +2 -2
  68. package/dist/locales/cs.yaml +2 -2
  69. package/dist/locales/da.yaml +2 -2
  70. package/dist/locales/de.yaml +1 -1
  71. package/dist/locales/el.yaml +2 -2
  72. package/dist/locales/en-CA.yaml +1 -1
  73. package/dist/locales/en-GB.yaml +1 -1
  74. package/dist/locales/en-US.yaml +1 -1
  75. package/dist/locales/es-CO.yaml +2 -2
  76. package/dist/locales/fa.yaml +2 -2
  77. package/dist/locales/fi.yaml +1 -1
  78. package/dist/locales/fr.yaml +2 -2
  79. package/dist/locales/hr.yaml +116 -0
  80. package/dist/locales/it.yaml +2 -2
  81. package/dist/locales/nl.yaml +1 -1
  82. package/dist/locales/pl.yaml +2 -2
  83. package/dist/locales/pt-BR.yaml +2 -2
  84. package/dist/locales/pt-PT.yaml +2 -2
  85. package/dist/locales/sv.yaml +2 -2
  86. package/dist/locales/zh-CN.yaml +1 -1
  87. package/dist/locales/zh-TW.yaml +1 -1
  88. package/package.json +8 -4
@@ -33,7 +33,7 @@
33
33
  </script>
34
34
 
35
35
  {#if show}
36
- <div role="none" class="infobar {status}">
36
+ <div role="none" class={['infobar', status]}>
37
37
  <div role="alert" class="message" aria-live={ariaLive}>
38
38
  {#if icon}
39
39
  {@render icon()}
@@ -58,7 +58,7 @@
58
58
  <button
59
59
  bind:this={element}
60
60
  {...restProps}
61
- class="sui button {variant} {size} {className}"
61
+ class={['sui', 'button', variant, size, className]}
62
62
  class:iconic
63
63
  class:pill
64
64
  class:flex
@@ -36,7 +36,8 @@
36
36
  class="sui select-button {className}"
37
37
  aria-checked={selected}
38
38
  onChange={(event) => {
39
- selected = event.detail.selected;
39
+ // The radio group reports the state as `checked`, after the `aria-checked` attribute it manages
40
+ selected = event.detail.checked;
40
41
  onChange?.(event);
41
42
  }}
42
43
  ></Button>
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Date helpers behind `<Calendar>`, kept apart from the component so the grid can be tested without
3
+ * rendering anything.
4
+ */
5
+ /**
6
+ * Number of cells in the day grid: six weeks, enough to show any month in full from the Sunday on
7
+ * or before its first day.
8
+ */
9
+ export const DAY_GRID_SIZE: 42;
10
+ /**
11
+ * List of month names for the month selector. We use a fixed date to avoid issues with daylight
12
+ * saving time.
13
+ * @type {string[]}
14
+ */
15
+ export const MONTH_NAMES: string[];
16
+ export function getFirstDayOfMonth(date: Date): Date;
17
+ export function addMonths(firstDay: Date, delta: number): Date;
18
+ export function getCalendarDays(firstDay: Date): Date[];
19
+ export function toDateString(date: Date): string;
20
+ export function isSameDay(a: Date, b: Date): boolean;
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Date helpers behind `<Calendar>`, kept apart from the component so the grid can be tested without
3
+ * rendering anything.
4
+ */
5
+
6
+ /**
7
+ * Number of cells in the day grid: six weeks, enough to show any month in full from the Sunday on
8
+ * or before its first day.
9
+ */
10
+ export const DAY_GRID_SIZE = 42;
11
+
12
+ /**
13
+ * List of month names for the month selector. We use a fixed date to avoid issues with daylight
14
+ * saving time.
15
+ * @type {string[]}
16
+ */
17
+ export const MONTH_NAMES = Array.from({ length: 12 }, (__, i) =>
18
+ new Date(2000, i, 10).toLocaleDateString('en', { month: 'short' }),
19
+ );
20
+
21
+ /**
22
+ * Get the first day of the month the given date is in.
23
+ * @param {Date} date Any date within the month.
24
+ * @returns {Date} Local midnight on the first day of the month, using the UTC year and month of
25
+ * the given date, so a date parsed from a `YYYY-MM-DD` string lands in the right month regardless
26
+ * of the time zone.
27
+ */
28
+ export const getFirstDayOfMonth = (date) => new Date(date.getUTCFullYear(), date.getUTCMonth(), 1);
29
+
30
+ /**
31
+ * Get the first day of another month, relative to the given one.
32
+ * @param {Date} firstDay First day of a month.
33
+ * @param {number} delta Number of months to move by; negative to go back.
34
+ * @returns {Date} A new date, `delta` months away.
35
+ */
36
+ export const addMonths = (firstDay, delta) => {
37
+ const date = new Date(firstDay);
38
+
39
+ date.setUTCMonth(date.getUTCMonth() + delta);
40
+
41
+ return date;
42
+ };
43
+
44
+ /**
45
+ * Get the days to be laid out in the calendar grid.
46
+ * @param {Date} firstDay First day of the month being displayed.
47
+ * @returns {Date[]} {@link DAY_GRID_SIZE} consecutive days, starting from the Sunday on or before
48
+ * `firstDay`, so the days from the previous and next months fill the grid.
49
+ */
50
+ export const getCalendarDays = (firstDay) => {
51
+ const cursor = new Date(firstDay);
52
+
53
+ // Start from Sunday
54
+ cursor.setDate(1 - cursor.getUTCDay());
55
+
56
+ return Array.from({ length: DAY_GRID_SIZE }, () => {
57
+ const day = new Date(cursor);
58
+
59
+ cursor.setUTCDate(cursor.getUTCDate() + 1);
60
+
61
+ return day;
62
+ });
63
+ };
64
+
65
+ /**
66
+ * Format a date as the `value` of the calendar.
67
+ * @param {Date} date Date.
68
+ * @returns {string} Date in the `YYYY-MM-DD` format, based on UTC.
69
+ */
70
+ export const toDateString = (date) => date.toJSON().split('T')[0];
71
+
72
+ /**
73
+ * Whether two dates fall on the same local calendar day.
74
+ * @param {Date} a A date.
75
+ * @param {Date} b Another date.
76
+ * @returns {boolean} Result.
77
+ */
78
+ export const isSameDay = (a, b) =>
79
+ a.getFullYear() === b.getFullYear() &&
80
+ a.getMonth() === b.getMonth() &&
81
+ a.getDate() === b.getDate();
@@ -4,25 +4,24 @@
4
4
  -->
5
5
  <script>
6
6
  import { _, isRTL } from '@sveltia/i18n';
7
- import { SvelteDate } from 'svelte/reactivity';
8
7
  import Button from '../button/button.svelte';
9
8
  import Divider from '../divider/divider.svelte';
10
9
  import Spacer from '../divider/spacer.svelte';
11
10
  import Icon from '../icon/icon.svelte';
11
+ import {
12
+ addMonths,
13
+ getCalendarDays,
14
+ getFirstDayOfMonth,
15
+ isSameDay,
16
+ MONTH_NAMES,
17
+ toDateString,
18
+ } from './calendar.js';
12
19
 
13
20
  /**
14
21
  * @typedef {object} Props
15
22
  * @property {string} [value] Date.
16
23
  */
17
24
 
18
- /**
19
- * List of month names for month selector. We use a fixed date to avoid issues with daylight
20
- * saving time.
21
- */
22
- const MONTH_NAMES = Array.from({ length: 12 }, (__, i) =>
23
- new Date(2000, i, 10).toLocaleDateString('en', { month: 'short' }),
24
- );
25
-
26
25
  /**
27
26
  * @type {Props & Record<string, any>}
28
27
  */
@@ -31,30 +30,13 @@
31
30
  const now = new Date();
32
31
 
33
32
  const date = $derived(value ? new Date(value) : now);
34
- const firstDayOfMonth = $derived(new Date(date.getUTCFullYear(), date.getUTCMonth(), 1));
35
- // eslint-disable-next-line svelte/prefer-writable-derived
36
- let firstDay = $state();
37
-
38
- $effect(() => {
39
- firstDay = new SvelteDate(firstDayOfMonth);
40
- });
41
-
42
- const dayList = $derived.by(() => {
43
- if (!firstDay) return [];
44
-
45
- const cursor = new SvelteDate(firstDay);
46
-
47
- // Start from Sunday
48
- cursor.setDate(1 - cursor.getUTCDay());
49
-
50
- return Array.from({ length: 42 }, () => {
51
- const day = new Date(cursor);
52
-
53
- cursor.setUTCDate(cursor.getUTCDate() + 1);
54
-
55
- return { day };
56
- });
57
- });
33
+ /**
34
+ * First day of the month being displayed. Follows the {@link value}, and is reassigned by the
35
+ * month navigation below.
36
+ * @type {Date}
37
+ */
38
+ let firstDay = $derived(getFirstDayOfMonth(date));
39
+ const dayList = $derived(getCalendarDays(firstDay).map((day) => ({ day })));
58
40
  </script>
59
41
 
60
42
  <div role="group">
@@ -62,7 +44,7 @@
62
44
  <div role="none" class="header">
63
45
  <Button
64
46
  variant="ghost"
65
- label={firstDay?.toLocaleDateString('en', { year: 'numeric', month: 'short' })}
47
+ label={firstDay.toLocaleDateString('en', { year: 'numeric', month: 'short' })}
66
48
  aria-haspopup="dialog"
67
49
  >
68
50
  {#snippet endIcon()}
@@ -98,7 +80,7 @@
98
80
  <div role="none" class="grid">
99
81
  {#each [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] as year (year)}
100
82
  <div role="none">
101
- <Button>202{year}</Button>
83
+ <Button>{`202${year}`}</Button>
102
84
  </div>
103
85
  {/each}
104
86
  </div>
@@ -119,8 +101,7 @@
119
101
  <Button
120
102
  aria-label={_('_sui.calendar.previous_month')}
121
103
  onclick={() => {
122
- firstDay.setUTCMonth(firstDay.getUTCMonth() - 1);
123
- firstDay = firstDay;
104
+ firstDay = addMonths(firstDay, -1);
124
105
  }}
125
106
  >
126
107
  <Icon name="chevron_left" />
@@ -128,8 +109,7 @@
128
109
  <Button
129
110
  aria-label={_('_sui.calendar.next_month')}
130
111
  onclick={() => {
131
- firstDay.setUTCMonth(firstDay.getUTCMonth() + 1);
132
- firstDay = firstDay;
112
+ firstDay = addMonths(firstDay, 1);
133
113
  }}
134
114
  >
135
115
  <Icon name={isRTL() ? 'chevron_left' : 'chevron_right'} />
@@ -144,16 +124,14 @@
144
124
  {#each dayList as { day } (day)}
145
125
  <div
146
126
  role="none"
147
- class:other-month={day.getUTCMonth() !== firstDay?.getUTCMonth()}
148
- class:today={day.getFullYear() === now.getFullYear() &&
149
- day.getMonth() === now.getMonth() &&
150
- day.getDate() === now.getDate()}
127
+ class:other-month={day.getUTCMonth() !== firstDay.getUTCMonth()}
128
+ class:today={isSameDay(day, now)}
151
129
  >
152
130
  <Button
153
131
  role="option"
154
132
  aria-selected={false}
155
133
  onclick={() => {
156
- [value] = day.toJSON().split('T');
134
+ value = toDateString(day);
157
135
  }}
158
136
  >
159
137
  {day.getUTCDate()}
@@ -172,7 +150,7 @@
172
150
  <Spacer flex={true} />
173
151
  <Button
174
152
  onclick={() => {
175
- [value] = now.toJSON().split('T');
153
+ value = toDateString(now);
176
154
  }}
177
155
  >
178
156
  {_('_sui.calendar.today')}
@@ -38,7 +38,7 @@
38
38
  <div
39
39
  {...restProps}
40
40
  role="group"
41
- class="sui checkbox-group {className} {orientation}"
41
+ class={['sui', 'checkbox-group', className, orientation]}
42
42
  {hidden}
43
43
  aria-hidden={hidden}
44
44
  aria-disabled={disabled}
@@ -91,6 +91,8 @@
91
91
  event.preventDefault();
92
92
  event.stopPropagation();
93
93
 
94
+ // The button handles its own clicks, and doesn’t let them reach here
95
+ /* v8 ignore else */
94
96
  if (!(/** @type {HTMLElement} */ (event.target).matches('button'))) {
95
97
  buttonElement?.click();
96
98
  }
@@ -122,12 +124,15 @@
122
124
 
123
125
  checked = indeterminate ? true : !checked;
124
126
 
127
+ // The effect above keeps `checked` in step with the group, so a newly checked box is never
128
+ // in the group already, and a newly unchecked one is never missing from it
125
129
  if (Array.isArray(group)) {
126
130
  if (checked) {
131
+ /* v8 ignore else */
127
132
  if (!group.includes(value)) {
128
133
  group = [...group, value];
129
134
  }
130
- } else if (group.includes(value)) {
135
+ } else /* v8 ignore else */ if (group.includes(value)) {
131
136
  group = group.filter((v) => v !== value);
132
137
  }
133
138
  }
@@ -115,7 +115,7 @@
115
115
  bind:open
116
116
  showBackdrop
117
117
  >
118
- <div bind:this={content} role="none" class="content {className} {size}">
118
+ <div bind:this={content} role="none" class={['content', className, size]}>
119
119
  {#if title || showClose || header || headerExtra}
120
120
  <div role="none" class="header">
121
121
  {#if header}
@@ -71,7 +71,7 @@
71
71
  </script>
72
72
 
73
73
  <Modal bind:this={modal} {...restProps} bind:open {id} class="drawer" showBackdrop>
74
- <div role="none" class="content {className} {size} {position} {orientation}">
74
+ <div role="none" class={['content', className, size, position, orientation]}>
75
75
  <div role="none" class="extra-control">
76
76
  {#if showClose === 'outside'}
77
77
  <Button
@@ -13,6 +13,7 @@
13
13
  import { _, isRTL } from '@sveltia/i18n';
14
14
  import { onMount, untrack } from 'svelte';
15
15
  import { searchEmojis } from './emoji.js';
16
+ import { getSuggestionListPosition } from './position.js';
16
17
 
17
18
  /**
18
19
  * @import { EmojiAnchorRect, EmojiEntry, EmojiTrigger } from '../../typedefs';
@@ -39,10 +40,6 @@
39
40
  /* eslint-enable prefer-const */
40
41
  } = $props();
41
42
 
42
- /**
43
- * Width of the dropdown, also enforced in the stylesheet below.
44
- */
45
- const LIST_WIDTH = 280;
46
43
  /**
47
44
  * How many suggestions are visible at once. The rest are reached by scrolling.
48
45
  */
@@ -51,11 +48,6 @@
51
48
  * Height to assume until a row has been measured, so the first open is positioned sensibly.
52
49
  */
53
50
  const FALLBACK_MAX_HEIGHT = 180;
54
- /**
55
- * Gap between the dropdown and the caret, and the minimum margin to the viewport edges.
56
- */
57
- const LIST_OFFSET = 4;
58
- const VIEWPORT_MARGIN = 8;
59
51
 
60
52
  const listId = $props.id();
61
53
 
@@ -112,37 +104,17 @@
112
104
 
113
105
  /**
114
106
  * Position of the dropdown, flipped above the caret and clamped to the viewport as needed.
115
- *
116
- * The dropdown grows away from the start of the line, so it follows the reading direction rather
117
- * than reaching back across the text: in a right-to-left layout it hangs from the shortcode’s
118
- * right edge and extends leftwards, mirroring what `activatePopup()` does for anchored popups.
119
107
  */
120
- const position = $derived.by(() => {
121
- if (!anchorRect) {
122
- return undefined;
123
- }
124
-
125
- const { innerWidth, innerHeight } = window;
126
- const spaceBelow = innerHeight - anchorRect.bottom;
127
- const spaceAbove = anchorRect.top;
128
- const flipped = spaceBelow < listMaxHeight + VIEWPORT_MARGIN && spaceAbove > spaceBelow;
129
- const rtl = isRTL();
130
- const anchorLeft = rtl ? anchorRect.right - LIST_WIDTH : anchorRect.left;
131
-
132
- return {
133
- top: flipped ? undefined : `${Math.round(anchorRect.bottom + LIST_OFFSET)}px`,
134
- bottom: flipped ? `${Math.round(innerHeight - anchorRect.top + LIST_OFFSET)}px` : undefined,
135
- left: `${Math.round(
136
- Math.max(VIEWPORT_MARGIN, Math.min(anchorLeft, innerWidth - LIST_WIDTH - VIEWPORT_MARGIN)),
137
- )}px`,
138
- maxHeight: `${Math.round(
139
- Math.min(
108
+ const position = $derived(
109
+ anchorRect
110
+ ? getSuggestionListPosition({
111
+ anchorRect,
140
112
  listMaxHeight,
141
- (flipped ? spaceAbove : spaceBelow) - LIST_OFFSET - VIEWPORT_MARGIN,
142
- ),
143
- )}px`,
144
- };
145
- });
113
+ viewport: { width: window.innerWidth, height: window.innerHeight },
114
+ rtl: isRTL(),
115
+ })
116
+ : undefined,
117
+ );
146
118
 
147
119
  /**
148
120
  * Whether any suggestions are currently shown.
@@ -277,6 +249,8 @@
277
249
 
278
250
  const row = listElement.querySelector('.option');
279
251
 
252
+ // The list is only mounted while there are candidates, so there is always a row
253
+ /* v8 ignore else */
280
254
  if (row) {
281
255
  const { paddingTop, paddingBottom, borderTopWidth, borderBottomWidth } =
282
256
  getComputedStyle(listElement);
@@ -406,7 +380,7 @@
406
380
  >
407
381
  {#each candidates as entry, index (entry.emoji)}
408
382
  <div
409
- id="{listId}-option-{index}"
383
+ id={`${listId}-option-${index}`}
410
384
  role="option"
411
385
  class="option"
412
386
  tabindex="-1"
@@ -422,7 +396,7 @@
422
396
  }}
423
397
  >
424
398
  <span role="none" class="emoji">{entry.emoji}</span>
425
- <span role="none" class="name">:{entry.name}:</span>
399
+ <span role="none" class="name">{`:${entry.name}:`}</span>
426
400
  </div>
427
401
  {/each}
428
402
  </div>
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Placement of the `<EmojiSuggestions>` dropdown, kept apart from the component so it can be
3
+ * tested against arbitrary viewports.
4
+ */
5
+ /**
6
+ * @import { EmojiAnchorRect } from '../../typedefs';
7
+ */
8
+ /**
9
+ * Width of the dropdown, also enforced in the component’s stylesheet.
10
+ */
11
+ export const LIST_WIDTH: 280;
12
+ /**
13
+ * Gap between the dropdown and the caret, and the minimum margin to the viewport edges.
14
+ */
15
+ export const LIST_OFFSET: 4;
16
+ export const VIEWPORT_MARGIN: 8;
17
+ export function getSuggestionListPosition({ anchorRect, listMaxHeight, viewport, rtl }: {
18
+ anchorRect: EmojiAnchorRect;
19
+ listMaxHeight: number;
20
+ viewport: {
21
+ width: number;
22
+ height: number;
23
+ };
24
+ rtl: boolean;
25
+ }): SuggestionListPosition;
26
+ export type SuggestionListPosition = {
27
+ /**
28
+ * CSS `top`, when the list hangs below the caret.
29
+ */
30
+ top: string | undefined;
31
+ /**
32
+ * CSS `bottom`, when the list is flipped above the caret.
33
+ */
34
+ bottom: string | undefined;
35
+ /**
36
+ * CSS `left`.
37
+ */
38
+ left: string;
39
+ /**
40
+ * CSS `max-height`.
41
+ */
42
+ maxHeight: string;
43
+ };
44
+ import type { EmojiAnchorRect } from '../../typedefs';
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Placement of the `<EmojiSuggestions>` dropdown, kept apart from the component so it can be
3
+ * tested against arbitrary viewports.
4
+ */
5
+
6
+ /**
7
+ * @import { EmojiAnchorRect } from '../../typedefs';
8
+ */
9
+
10
+ /**
11
+ * Width of the dropdown, also enforced in the component’s stylesheet.
12
+ */
13
+ export const LIST_WIDTH = 280;
14
+ /**
15
+ * Gap between the dropdown and the caret, and the minimum margin to the viewport edges.
16
+ */
17
+ export const LIST_OFFSET = 4;
18
+ export const VIEWPORT_MARGIN = 8;
19
+
20
+ /**
21
+ * @typedef {object} SuggestionListPosition
22
+ * @property {string | undefined} top CSS `top`, when the list hangs below the caret.
23
+ * @property {string | undefined} bottom CSS `bottom`, when the list is flipped above the caret.
24
+ * @property {string} left CSS `left`.
25
+ * @property {string} maxHeight CSS `max-height`.
26
+ */
27
+
28
+ /**
29
+ * Work out where to place the dropdown: below the caret by default, flipped above it when there
30
+ * is more room there and not enough below, and clamped to the viewport horizontally.
31
+ *
32
+ * The dropdown grows away from the start of the line, so it follows the reading direction rather
33
+ * than reaching back across the text: in a right-to-left layout it hangs from the shortcode’s
34
+ * right edge and extends leftwards, mirroring what `activatePopup()` does for anchored popups.
35
+ * @param {object} options Options.
36
+ * @param {EmojiAnchorRect} options.anchorRect Viewport-relative bounds of the shortcode being
37
+ * typed.
38
+ * @param {number} options.listMaxHeight The height the list would like to have, in pixels.
39
+ * @param {{ width: number, height: number }} options.viewport Size of the viewport in pixels.
40
+ * @param {boolean} options.rtl Whether the layout is right-to-left.
41
+ * @returns {SuggestionListPosition} Position.
42
+ */
43
+ export const getSuggestionListPosition = ({ anchorRect, listMaxHeight, viewport, rtl }) => {
44
+ const { width: innerWidth, height: innerHeight } = viewport;
45
+ const spaceBelow = innerHeight - anchorRect.bottom;
46
+ const spaceAbove = anchorRect.top;
47
+ const flipped = spaceBelow < listMaxHeight + VIEWPORT_MARGIN && spaceAbove > spaceBelow;
48
+ const anchorLeft = rtl ? anchorRect.right - LIST_WIDTH : anchorRect.left;
49
+
50
+ return {
51
+ top: flipped ? undefined : `${Math.round(anchorRect.bottom + LIST_OFFSET)}px`,
52
+ bottom: flipped ? `${Math.round(innerHeight - anchorRect.top + LIST_OFFSET)}px` : undefined,
53
+ left: `${Math.round(
54
+ Math.max(VIEWPORT_MARGIN, Math.min(anchorLeft, innerWidth - LIST_WIDTH - VIEWPORT_MARGIN)),
55
+ )}px`,
56
+ maxHeight: `${Math.round(
57
+ Math.min(listMaxHeight, (flipped ? spaceAbove : spaceBelow) - LIST_OFFSET - VIEWPORT_MARGIN),
58
+ )}px`,
59
+ };
60
+ };
@@ -40,7 +40,7 @@
40
40
  aria-valuetext={text}
41
41
  aria-label={ariaLabel}
42
42
  >
43
- <div role="none" style:width="{now}%"></div>
43
+ <div role="none" style:width={`${now}%`}></div>
44
44
  </div>
45
45
 
46
46
  <style>.progressbar {
@@ -53,7 +53,7 @@
53
53
  <div
54
54
  {...restProps}
55
55
  role="radiogroup"
56
- class="sui radio-group {className} {orientation}"
56
+ class={['sui', 'radio-group', className, orientation]}
57
57
  tabindex="-1"
58
58
  {hidden}
59
59
  aria-hidden={hidden}
@@ -7,6 +7,7 @@
7
7
  <script>
8
8
  import { isRTL } from '@sveltia/i18n';
9
9
  import { getContext } from 'svelte';
10
+ import { getHandleKeyAction } from './sizing.js';
10
11
 
11
12
  /**
12
13
  * @import { Snippet } from 'svelte';
@@ -106,6 +107,8 @@
106
107
  const measureContainerSize = () => {
107
108
  const container = element?.closest('.resizable-pane-group');
108
109
 
110
+ // The handle can’t be rendered outside a group; see the context check above
111
+ /* v8 ignore next */
109
112
  if (!container) return 0;
110
113
 
111
114
  return isHorizontal ? container.clientWidth : container.clientHeight;
@@ -126,6 +129,8 @@
126
129
  const screenPos = isHorizontal ? screenX : screenY;
127
130
  const pixelDelta = screenPos - startScreenPos;
128
131
 
132
+ // A group that has no size, because it isn’t laid out, can’t be resized
133
+ /* v8 ignore next */
129
134
  if (!containerSize) return;
130
135
 
131
136
  let percentDelta = (pixelDelta / containerSize) * 100;
@@ -194,63 +199,26 @@
194
199
  if (disabled) return;
195
200
 
196
201
  const { key, shiftKey } = event;
197
- const step = shiftKey ? 10 : 1;
198
- let delta = 0;
202
+ const action = getHandleKeyAction({ key, shiftKey, isHorizontal, rtl: isRTL() });
199
203
 
200
- if (key === 'Enter') {
201
- event.preventDefault();
202
- event.stopPropagation();
203
- ctx.toggleCollapse(handleIndex);
204
-
205
- return;
206
- }
207
-
208
- if (key === 'Home') {
209
- event.preventDefault();
210
- event.stopPropagation();
211
- // Collapse to minimum — clamped internally
212
- ctx.resize(handleIndex, -100);
213
-
214
- return;
215
- }
204
+ if (!action) return;
216
205
 
217
- if (key === 'End') {
218
- event.preventDefault();
219
- event.stopPropagation();
220
- // Expand to maximum — clamped internally
221
- ctx.resize(handleIndex, 100);
206
+ event.preventDefault();
207
+ event.stopPropagation();
222
208
 
223
- return;
224
- }
209
+ if (action.type === 'toggle') {
210
+ ctx.toggleCollapse(handleIndex);
225
211
 
226
- if (isHorizontal) {
227
- const _isRTL = isRTL();
228
-
229
- // In RTL, Left/Right directions are visually swapped
230
- if (key === 'ArrowLeft') {
231
- delta = _isRTL ? step : -step;
232
- } else if (key === 'ArrowRight') {
233
- delta = _isRTL ? -step : step;
234
- } else {
235
- return;
236
- }
237
- } else if (key === 'ArrowUp') {
238
- delta = -step;
239
- } else if (key === 'ArrowDown') {
240
- delta = step;
241
- } else {
242
212
  return;
243
213
  }
244
214
 
245
- event.preventDefault();
246
- event.stopPropagation();
247
-
248
- if (!keyResizing) {
215
+ // Home and End send an oversized delta, which gets clamped to the min/max internally
216
+ if (key !== 'Home' && key !== 'End' && !keyResizing) {
249
217
  keyResizing = true;
250
218
  onResizeStart?.();
251
219
  }
252
220
 
253
- ctx.resize(handleIndex, delta);
221
+ ctx.resize(handleIndex, action.delta);
254
222
  };
255
223
 
256
224
  /**
@@ -277,7 +245,7 @@
277
245
  aria-controls={ctx.paneDefs[handleIndex]?.id}
278
246
  aria-disabled={disabled || undefined}
279
247
  aria-label={ariaLabel}
280
- class="sui resizable-handle {className ?? ''}"
248
+ class="sui resizable-handle {className}"
281
249
  class:horizontal={isHorizontal}
282
250
  class:vertical={!isHorizontal}
283
251
  class:disabled