@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
@@ -82,6 +82,8 @@
82
82
  setContext('editorStore', editorStore);
83
83
 
84
84
  $effect(() => {
85
+ // The root initializes the editor before these effects first run, and stays initialized
86
+ /* v8 ignore next */
85
87
  if (!editorStore.initialized) {
86
88
  return;
87
89
  }
@@ -94,6 +96,8 @@
94
96
  });
95
97
 
96
98
  $effect(() => {
99
+ // The root initializes the editor before these effects first run, and stays initialized
100
+ /* v8 ignore next */
97
101
  if (!editorStore.initialized) {
98
102
  return;
99
103
  }
@@ -23,6 +23,6 @@
23
23
  } = $props();
24
24
  </script>
25
25
 
26
- <ToolbarWrapper disabled={disabled || readonly} aria-label={_('_sui.text_editor.code_editor')}>
26
+ <ToolbarWrapper disabled={disabled || readonly} ariaLabel={_('_sui.text_editor.code_editor')}>
27
27
  <CodeLanguageSwitcher disabled={disabled || readonly} />
28
28
  </ToolbarWrapper>
@@ -45,6 +45,9 @@
45
45
  : getNodeByKey(/** @type {string} */ (editorStore.selection.blockNodeKey));
46
46
 
47
47
  if (isCodeNode(node)) {
48
+ // The highlighter gives a block without a language the default one as it’s transformed,
49
+ // so the fallbacks here are only for the unexpected
50
+ /* v8 ignore next */
48
51
  selectedLanguage = node.getLanguage() ?? editorStore.config.defaultLanguage ?? 'plain';
49
52
  }
50
53
  });
@@ -53,7 +56,7 @@
53
56
 
54
57
  <Select
55
58
  {disabled}
56
- aria-label={_('_sui.text_editor.language')}
59
+ ariaLabel={_('_sui.text_editor.language')}
57
60
  value={selectedLanguage}
58
61
  onChange={async ({ detail: { value: lang } }) => {
59
62
  if (!editorStore.editor || selectedLanguage === lang) {
@@ -33,10 +33,12 @@
33
33
  <Button
34
34
  iconic
35
35
  aria-label={_(`_sui.text_editor.${AVAILABLE_BUTTONS[type].labelKey}`)}
36
- aria-controls="{editorStore.editorId}-lexical-root"
36
+ aria-controls={`${editorStore.editorId}-lexical-root`}
37
37
  disabled={!editorStore.useRichText}
38
38
  pressed={selectionTypeMatches}
39
39
  onclick={async () => {
40
+ // The button is only enabled while the editor is there
41
+ /* v8 ignore else */
40
42
  if (editorStore.editor) {
41
43
  await focusEditor(editorStore.editor);
42
44
  editorStore.editor.dispatchCommand(FORMAT_TEXT_COMMAND, type);
@@ -34,7 +34,7 @@
34
34
  label={icon ? undefined : label}
35
35
  title={label}
36
36
  aria-label={label}
37
- aria-controls="{editorStore.editorId}-lexical-root"
37
+ aria-controls={`${editorStore.editorId}-lexical-root`}
38
38
  disabled={!editorStore.useRichText}
39
39
  onclick={() => {
40
40
  editorStore.editor?.update(() => {
@@ -74,11 +74,16 @@
74
74
  editorStore.editor?.getEditorState().read(() => {
75
75
  const _selection = getSelection();
76
76
 
77
+ // This is only called while the selection is reported to be within a link, so the fallbacks
78
+ // below are for the unexpected
79
+ /* v8 ignore else */
77
80
  if (isRangeSelection(_selection)) {
78
81
  const anchor = _selection.anchor.getNode();
82
+ /* v8 ignore next */
79
83
  const parent = anchor instanceof LinkNode ? anchor : getNearestNodeOfType(anchor, LinkNode);
80
84
  const url = parent?.getURL();
81
85
 
86
+ /* v8 ignore else */
82
87
  if (url) {
83
88
  hasAnchor = true;
84
89
  anchorURL = url;
@@ -90,6 +95,7 @@
90
95
  }
91
96
 
92
97
  // Can’t update for some reason; remove it
98
+ /* v8 ignore next */
93
99
  removeLink();
94
100
  });
95
101
  };
@@ -123,6 +129,8 @@
123
129
  */
124
130
  const onDialogClose = async (event) => {
125
131
  if (event.detail.returnValue !== 'cancel' && dialogMode !== 'remove') {
132
+ // The dialog can only be opened from the button, which needs the editor
133
+ /* v8 ignore next */
126
134
  if (!editorStore.editor) {
127
135
  return;
128
136
  }
@@ -148,7 +156,7 @@
148
156
 
149
157
  await focusEditor(editorStore.editor);
150
158
  editorStore.editor.dispatchCommand(TOGGLE_LINK_COMMAND, anchorURL);
151
- } else if (editorStore.editor) {
159
+ } else /* v8 ignore else */ if (editorStore.editor) {
152
160
  // The dialog leaves the focus alone (see `restoreFocus` below), so bring it back here
153
161
  await focusEditor(editorStore.editor);
154
162
  }
@@ -185,7 +193,7 @@
185
193
  <Button
186
194
  iconic
187
195
  aria-label={_(`_sui.text_editor.${AVAILABLE_BUTTONS[type].labelKey}`)}
188
- aria-controls="{editorStore.editorId}-lexical-root"
196
+ aria-controls={`${editorStore.editorId}-lexical-root`}
189
197
  disabled={!editorStore.useRichText}
190
198
  pressed={selectionTypeMatches}
191
199
  onclick={() => {
@@ -202,7 +210,6 @@
202
210
  ? _('_sui.text_editor.insert_link')
203
211
  : _('_sui.text_editor.update_link')}
204
212
  bind:open={openDialog}
205
- bind:value={anchorURL}
206
213
  okDisabled={!anchorURL}
207
214
  okLabel={dialogMode === 'create' ? _('_sui.insert') : _('_sui.update')}
208
215
  restoreFocus={false}
@@ -77,21 +77,20 @@
77
77
  );
78
78
  </script>
79
79
 
80
- <ToolbarWrapper disabled={disabled || readonly} aria-label={_('_sui.text_editor.text_editor')}>
80
+ <ToolbarWrapper disabled={disabled || readonly} ariaLabel={_('_sui.text_editor.text_editor')}>
81
81
  {#if blockLevelButtons.length > 1}
82
82
  <MenuButton
83
83
  disabled={!editorStore.useRichText}
84
84
  aria-label={_('_sui.text_editor.show_text_style_options')}
85
- aria-controls="{editorStore.editorId}-lexical-root"
85
+ aria-controls={`${editorStore.editorId}-lexical-root`}
86
86
  >
87
87
  {#snippet startIcon()}
88
88
  <Icon
89
- name={AVAILABLE_BUTTONS[editorStore.selection.blockType ?? '']?.icon ??
90
- 'format_paragraph'}
89
+ name={AVAILABLE_BUTTONS[editorStore.selection.blockType]?.icon ?? 'format_paragraph'}
91
90
  />
92
91
  {/snippet}
93
92
  {#snippet popup()}
94
- <Menu aria-label={_('_sui.text_editor.text_style_options')}>
93
+ <Menu ariaLabel={_('_sui.text_editor.text_style_options')}>
95
94
  {#each blockLevelButtons as type (type)}
96
95
  <ToggleBlockMenuItem {type} />
97
96
  {/each}
@@ -44,6 +44,8 @@
44
44
  * Change the current selection’s type to {@link type}.
45
45
  */
46
46
  const changeBlockType = async () => {
47
+ // The item is only clickable while the editor is there
48
+ /* v8 ignore next */
47
49
  if (!editorStore.editor) {
48
50
  return;
49
51
  }
@@ -77,6 +77,8 @@
77
77
  const getAnchorRect = (trigger) => {
78
78
  const { start, end } = /** @type {FieldEmojiTrigger} */ (trigger);
79
79
 
80
+ // The trigger was found in the field, so the field is there
81
+ /* v8 ignore next */
80
82
  return element ? getFieldCaretRect(element, start, end) : undefined;
81
83
  };
82
84
 
@@ -0,0 +1,15 @@
1
+ export function getMaximumFractionDigits(step: number): number;
2
+ export function parseNumber(inputValue: string): number | undefined;
3
+ export function isAtMin(inputValue: string, min: number | undefined): boolean;
4
+ export function isAtMax(inputValue: string, max: number | undefined): boolean;
5
+ export function stepNumber(inputValue: string, { step, direction, min, max }: {
6
+ step: number;
7
+ direction: 1 | -1;
8
+ min?: number | undefined;
9
+ max?: number | undefined;
10
+ }): string | undefined;
11
+ export function isInvalidNumber(inputValue: string, { required, min, max }?: {
12
+ required?: boolean | undefined;
13
+ min?: number | undefined;
14
+ max?: number | undefined;
15
+ }): boolean;
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Logic behind `<NumberInput>`, kept apart from the component so the number handling can be tested
3
+ * without rendering anything.
4
+ */
5
+
6
+ /**
7
+ * Get the number of fraction digits a value should be formatted with, so stepping by `0.1` yields
8
+ * `0.3` rather than `0.30000000000000004`.
9
+ * @param {number} step Step value.
10
+ * @returns {number} Number of fraction digits, `0` for an integer step.
11
+ */
12
+ export const getMaximumFractionDigits = (step) => String(step).split('.')[1]?.length || 0;
13
+
14
+ /**
15
+ * Parse the text entered in the field.
16
+ * @param {string} inputValue Text in the field.
17
+ * @returns {number | undefined} Number, or `undefined` if the field is empty or holds no number.
18
+ */
19
+ export const parseNumber = (inputValue) => {
20
+ const value = inputValue.trim() ? Number(inputValue) : NaN;
21
+
22
+ return Number.isNaN(value) ? undefined : value;
23
+ };
24
+
25
+ /**
26
+ * Get the number the field currently represents for range checks: an empty field counts as zero,
27
+ * so the spin buttons and the arrow keys have something to step from.
28
+ * @param {string} inputValue Text in the field.
29
+ * @returns {number} Number, possibly `NaN`.
30
+ */
31
+ const toNumber = (inputValue) => Number(inputValue || 0);
32
+
33
+ /**
34
+ * Whether the field is at or below the minimum, if any.
35
+ * @param {string} inputValue Text in the field.
36
+ * @param {number | undefined} min Minimum allowed value.
37
+ * @returns {boolean} Result.
38
+ */
39
+ export const isAtMin = (inputValue, min) => typeof min === 'number' && toNumber(inputValue) <= min;
40
+
41
+ /**
42
+ * Whether the field is at or above the maximum, if any.
43
+ * @param {string} inputValue Text in the field.
44
+ * @param {number | undefined} max Maximum allowed value.
45
+ * @returns {boolean} Result.
46
+ */
47
+ export const isAtMax = (inputValue, max) => typeof max === 'number' && toNumber(inputValue) >= max;
48
+
49
+ /**
50
+ * Step the number in the field up or down.
51
+ * @param {string} inputValue Text in the field.
52
+ * @param {object} options Options.
53
+ * @param {number} options.step Amount to add or subtract.
54
+ * @param {1 | -1} options.direction `1` to increase, `-1` to decrease.
55
+ * @param {number} [options.min] Minimum allowed value.
56
+ * @param {number} [options.max] Maximum allowed value.
57
+ * @returns {string | undefined} New text for the field, or `undefined` if the field holds no
58
+ * number or is already at the limit in that direction, in which case it’s left as is.
59
+ */
60
+ export const stepNumber = (inputValue, { step, direction, min, max }) => {
61
+ if (Number.isNaN(Number(inputValue))) {
62
+ return undefined;
63
+ }
64
+
65
+ if (direction > 0 ? isAtMax(inputValue, max) : isAtMin(inputValue, min)) {
66
+ return undefined;
67
+ }
68
+
69
+ return Number(toNumber(inputValue) + step * direction).toFixed(getMaximumFractionDigits(step));
70
+ };
71
+
72
+ /**
73
+ * Check whether the field content is invalid.
74
+ * @param {string} inputValue Text in the field.
75
+ * @param {object} options Options.
76
+ * @param {boolean} [options.required] Whether a value is required.
77
+ * @param {number} [options.min] Minimum allowed value.
78
+ * @param {number} [options.max] Maximum allowed value.
79
+ * @returns {boolean} `true` if the field is empty while required, holds something other than a
80
+ * number, or holds a number outside the allowed range.
81
+ */
82
+ export const isInvalidNumber = (inputValue, { required = false, min, max } = {}) => {
83
+ if (required && (parseNumber(inputValue) === undefined || inputValue === '')) {
84
+ return true;
85
+ }
86
+
87
+ if (inputValue === undefined || inputValue === '') {
88
+ return false;
89
+ }
90
+
91
+ const value = Number(inputValue);
92
+
93
+ return (
94
+ Number.isNaN(value) ||
95
+ (typeof min === 'number' && toNumber(inputValue) < min) ||
96
+ (typeof max === 'number' && toNumber(inputValue) > max)
97
+ );
98
+ };
@@ -9,6 +9,14 @@
9
9
  import { untrack } from 'svelte';
10
10
  import Button from '../button/button.svelte';
11
11
  import Icon from '../icon/icon.svelte';
12
+ import {
13
+ getMaximumFractionDigits,
14
+ isAtMax,
15
+ isAtMin,
16
+ isInvalidNumber,
17
+ parseNumber,
18
+ stepNumber,
19
+ } from './number-input.js';
12
20
  import TextInput from './text-input.svelte';
13
21
 
14
22
  /**
@@ -56,9 +64,9 @@
56
64
  let edited = $state(false);
57
65
  let inputValue = $state('');
58
66
 
59
- const maximumFractionDigits = $derived(String(step).split('.')[1]?.length || 0);
60
- const isMin = $derived(typeof min === 'number' && Number(inputValue || 0) <= min);
61
- const isMax = $derived(typeof max === 'number' && Number(inputValue || 0) >= max);
67
+ const maximumFractionDigits = $derived(getMaximumFractionDigits(step));
68
+ const isMin = $derived(isAtMin(inputValue, min));
69
+ const isMax = $derived(isAtMax(inputValue, max));
62
70
 
63
71
  $effect(() => {
64
72
  const newInputValue = String(value ?? '');
@@ -71,43 +79,39 @@
71
79
  });
72
80
 
73
81
  $effect(() => {
74
- const newValue = inputValue.trim() ? Number(inputValue) : NaN;
75
-
76
- value = !Number.isNaN(newValue) ? newValue : undefined;
82
+ value = parseNumber(inputValue);
77
83
  });
78
84
 
79
85
  $effect(() => {
80
86
  if (edited) {
81
- invalid =
82
- (required && (value === undefined || inputValue === '')) ||
83
- (inputValue !== undefined &&
84
- inputValue !== '' &&
85
- (Number.isNaN(Number(inputValue)) ||
86
- (typeof min === 'number' && Number(inputValue || 0) < min) ||
87
- (typeof max === 'number' && Number(inputValue || 0) > max)));
87
+ invalid = isInvalidNumber(inputValue, { required, min, max });
88
88
  }
89
89
  });
90
90
 
91
91
  /**
92
- * Decrease the number.
92
+ * Step the number up or down, unless the field holds no number or is already at the limit.
93
+ * @param {1 | -1} direction `1` to increase, `-1` to decrease.
93
94
  */
94
- const decrease = () => {
95
- if (isMin || Number.isNaN(Number(inputValue))) {
96
- return;
95
+ const stepValue = (direction) => {
96
+ const newInputValue = stepNumber(inputValue, { step, direction, min, max });
97
+
98
+ if (newInputValue !== undefined) {
99
+ inputValue = newInputValue;
97
100
  }
101
+ };
98
102
 
99
- inputValue = Number(Number(inputValue || 0) - step).toFixed(maximumFractionDigits);
103
+ /**
104
+ * Decrease the number.
105
+ */
106
+ const decrease = () => {
107
+ stepValue(-1);
100
108
  };
101
109
 
102
110
  /**
103
111
  * Increase the number.
104
112
  */
105
113
  const increase = () => {
106
- if (isMax || Number.isNaN(Number(inputValue))) {
107
- return;
108
- }
109
-
110
- inputValue = Number(Number(inputValue || 0) + step).toFixed(maximumFractionDigits);
114
+ stepValue(1);
111
115
  };
112
116
  </script>
113
117
 
@@ -52,6 +52,12 @@
52
52
 
53
53
  const id = $props.id();
54
54
  const timeout = $derived(typeof debounce === 'number' ? debounce : 300);
55
+ /**
56
+ * Accessible label, whether given as the `ariaLabel` prop or as a plain `aria-label` attribute,
57
+ * which the wrapper components such as `<SearchBar>` and `<NumberInput>` pass through as is.
58
+ * @type {string | undefined}
59
+ */
60
+ const label = $derived(ariaLabel ?? restProps['aria-label']);
55
61
 
56
62
  let debounceTimer = 0;
57
63
 
@@ -113,7 +119,7 @@
113
119
  disabled={disabled || undefined}
114
120
  readonly={readonly || undefined}
115
121
  {inputmode}
116
- aria-label={ariaLabel}
122
+ aria-label={label}
117
123
  aria-hidden={hidden}
118
124
  aria-disabled={disabled}
119
125
  aria-readonly={readonly}
@@ -125,10 +131,10 @@
125
131
  {#if useEmojiAutocomplete && !disabled && !readonly}
126
132
  <EmojiAutocomplete {element} />
127
133
  {/if}
128
- {#if ariaLabel && showInlineLabel}
134
+ {#if label && showInlineLabel}
129
135
  <span id="{id}-label" class="label" class:hidden={!!value} aria-hidden="true">
130
136
  <TruncatedText>
131
- {ariaLabel}
137
+ {label}
132
138
  </TruncatedText>
133
139
  </span>
134
140
  {/if}
@@ -64,11 +64,14 @@
64
64
  } else {
65
65
  popover = popoverBase;
66
66
 
67
+ // The base is bound by the time the component is mounted
68
+ /* v8 ignore else */
67
69
  if (popover) {
68
70
  popover.classList.add('enabled');
69
71
  (document.querySelector('.sui.app-shell') ?? document.body).appendChild(popover);
70
72
 
71
- // Move the element to top layer
73
+ // Move the element to top layer, unless the browser doesn’t support the Popover API
74
+ /* v8 ignore else */
72
75
  if (popover.showPopover) {
73
76
  popover.popover = 'manual';
74
77
  popover.showPopover();
@@ -105,6 +108,8 @@
105
108
  });
106
109
 
107
110
  $effect(() => {
111
+ // Both are in place by the time the effect first runs; see `onMount()` above
112
+ /* v8 ignore else */
108
113
  if (popover && toast) {
109
114
  popover.appendChild(toast);
110
115
  }
@@ -133,7 +138,7 @@
133
138
 
134
139
  <div bind:this={popoverBase} role="none" class="sui toast-base"></div>
135
140
 
136
- <div {...restProps} bind:this={toast} class="sui toast {position}" aria-hidden={!show}>
141
+ <div {...restProps} bind:this={toast} class={['sui', 'toast', position]} aria-hidden={!show}>
137
142
  {@render children?.()}
138
143
  </div>
139
144
 
@@ -42,7 +42,7 @@
42
42
  <div
43
43
  {...restProps}
44
44
  role="toolbar"
45
- class="sui toolbar {orientation} {variant} {className}"
45
+ class={['sui', 'toolbar', orientation, variant, className]}
46
46
  {hidden}
47
47
  aria-hidden={hidden}
48
48
  aria-disabled={disabled}
@@ -21,7 +21,8 @@
21
21
  <span
22
22
  role="none"
23
23
  class="sui truncated-text"
24
- style="-webkit-line-clamp: {lines}; line-clamp: {lines};"
24
+ style:-webkit-line-clamp={lines}
25
+ style:line-clamp={lines}
25
26
  >
26
27
  {@render children?.()}
27
28
  </span>
@@ -0,0 +1 @@
1
+ export function shouldAllowContextMenu(target: EventTarget | null): boolean;
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Helpers behind `<AppShell>`, kept apart from the component so they can be tested on their own.
3
+ */
4
+
5
+ /**
6
+ * Whether the browser’s context menu should be allowed on the given element. The shell suppresses
7
+ * it to feel more like a native app, except where it’s actually useful: on text fields and rich
8
+ * text editors, where the menu carries the cut/copy/paste and spell-checking commands.
9
+ * @param {EventTarget | null} target The element that was right-clicked.
10
+ * @returns {boolean} `true` to leave the browser’s context menu alone.
11
+ */
12
+ export const shouldAllowContextMenu = (target) => {
13
+ // Allow context menu in developer mode
14
+ if (document.documentElement.matches('[data-env="dev"]')) {
15
+ return true;
16
+ }
17
+
18
+ if (!(target instanceof Element)) {
19
+ return false;
20
+ }
21
+
22
+ // Allow context menu on text inputs and contentEditable elements. Checking `maxLength` rules
23
+ // out the input types that hold no text, such as a checkbox or a button.
24
+ return (
25
+ (target.matches('input, textarea') && 'maxLength' in target) ||
26
+ /** @type {HTMLElement | null} */ (target.closest('[role="textbox"]'))?.contentEditable ===
27
+ 'true'
28
+ );
29
+ };
@@ -6,6 +6,7 @@
6
6
  -->
7
7
  <script>
8
8
  import { onMount } from 'svelte';
9
+ import { shouldAllowContextMenu } from './app-shell.js';
9
10
  import FontLinks from './font-links.svelte';
10
11
 
11
12
  /**
@@ -87,28 +88,13 @@
87
88
  <div
88
89
  {...restProps}
89
90
  role="none"
90
- class="sui app-shell {orientation}"
91
+ class={['sui', 'app-shell', orientation]}
91
92
  ondragover={(event) => event.preventDefault()}
92
93
  ondrop={(event) => event.preventDefault()}
93
94
  oncontextmenu={(event) => {
94
- // Allow context menu in developer mode
95
- if (document.documentElement.matches('[data-env="dev"]')) {
96
- return;
95
+ if (!shouldAllowContextMenu(event.target)) {
96
+ event.preventDefault();
97
97
  }
98
-
99
- // eslint-disable-next-line prefer-destructuring
100
- const target = /** @type {HTMLElement} */ (event.target);
101
-
102
- // Allow context menu on text inputs and contentEditable elements
103
- if (
104
- document.documentElement.matches('[data-env="dev"]') ||
105
- (target?.matches('input, textarea') && 'maxLength' in target) ||
106
- /** @type {HTMLElement} */ (target?.closest('[role="textbox"]'))?.contentEditable === 'true'
107
- ) {
108
- return;
109
- }
110
-
111
- event.preventDefault();
112
98
  }}
113
99
  >
114
100
  {@render children?.()}
@@ -0,0 +1,5 @@
1
+ export function getLongestTime(value: string): number;
2
+ export function getTransitionTimeout({ transitionDuration, transitionDelay }: {
3
+ transitionDuration: string;
4
+ transitionDelay: string;
5
+ }): number;
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Helpers behind `<Modal>`, kept apart from the component so they can be tested on their own.
3
+ */
4
+
5
+ /**
6
+ * Get the longest time from a computed CSS time list, such as `transition-duration`.
7
+ * @param {string} value Comma-separated CSS time values in seconds, e.g. `0.4s, 0.15s`.
8
+ * @returns {number} Time in milliseconds, `0` if the list is empty or holds no valid time.
9
+ */
10
+ export const getLongestTime = (value) =>
11
+ Math.max(0, ...value.split(',').map((time) => Number.parseFloat(time) || 0)) * 1000;
12
+
13
+ /**
14
+ * Get how long the closing or opening transition of a `<dialog>` can take, so a fallback timer can
15
+ * be set in case `transitionend` is never fired, for example when the transition is removed by the
16
+ * consumer’s CSS or the element is not rendered at all.
17
+ * @param {{ transitionDuration: string, transitionDelay: string }} style Computed style of the
18
+ * element.
19
+ * @returns {number} Time in milliseconds, with a small margin added.
20
+ */
21
+ export const getTransitionTimeout = ({ transitionDuration, transitionDelay }) =>
22
+ getLongestTime(transitionDuration) + getLongestTime(transitionDelay) + 100;
@@ -21,6 +21,7 @@
21
21
  unmount,
22
22
  untrack,
23
23
  } from 'svelte';
24
+ import { getTransitionTimeout } from './modal.js';
24
25
  import Placeholder from './placeholder.svelte';
25
26
 
26
27
  /**
@@ -193,14 +194,6 @@
193
194
  });
194
195
  };
195
196
 
196
- /**
197
- * Get the longest time from a computed CSS time list, such as `transition-duration`.
198
- * @param {string} value Comma-separated CSS time values in seconds, e.g. `0.4s, 0.15s`.
199
- * @returns {number} Time in milliseconds.
200
- */
201
- const getLongestTime = (value) =>
202
- Math.max(0, ...value.split(',').map((time) => Number.parseFloat(time) || 0)) * 1000;
203
-
204
197
  /**
205
198
  * Resolve once the transition is complete.
206
199
  * @returns {Promise<void>} Nothing.
@@ -213,11 +206,10 @@
213
206
  return;
214
207
  }
215
208
 
216
- const { transitionDuration, transitionDelay } = getComputedStyle(dialog);
217
209
  // Fall back to a timer, so the modal is never stuck half-open (and, more importantly, never
218
210
  // left mounted) in case `transitionend` is never fired, e.g. when the transition is removed by
219
211
  // the consumer’s CSS or the element is not rendered at all
220
- const timeout = getLongestTime(transitionDuration) + getLongestTime(transitionDelay) + 100;
212
+ const timeout = getTransitionTimeout(getComputedStyle(dialog));
221
213
  const controller = new AbortController();
222
214
  const { signal } = controller;
223
215
 
@@ -244,6 +236,8 @@
244
236
  * Show the modal.
245
237
  */
246
238
  const openDialog = async () => {
239
+ // Only called when the `open` prop changes, so it can’t be requested twice in a row
240
+ /* v8 ignore next */
247
241
  if (requestedOpen) {
248
242
  return;
249
243
  }
@@ -278,8 +272,11 @@
278
272
  // such as `<Dialog>`, may then move the focus to a specific control, like an input field.
279
273
  await tick();
280
274
 
275
+ // The focus is on `<body>` at this point, because the element was still `inert` when shown
276
+ /* v8 ignore next */
281
277
  if (gen !== generation || !dialog) return;
282
278
 
279
+ /* v8 ignore else */
283
280
  if (!dialog.contains(document.activeElement)) {
284
281
  focus();
285
282
  }
@@ -302,6 +299,8 @@
302
299
 
303
300
  const gen = generation;
304
301
  const wasOpen = setOpenClass;
302
+ // The element is mounted by the time a close can be requested; see `openDialog()` above
303
+ /* v8 ignore next */
305
304
  const returnValue = dialog?.returnValue ?? '';
306
305
 
307
306
  onClosing?.(new CustomEvent('Closing'));
@@ -81,12 +81,23 @@
81
81
  let popupInstance = $state();
82
82
  let hoveredTimeout = 0;
83
83
 
84
+ // Keep the `open` prop and the instance in sync both ways. The instance opens the popup when
85
+ // the anchor is activated, and closes it when the backdrop or an item is clicked, or Escape is
86
+ // pressed; the prop is what a consumer such as `<Combobox>` or `<MenuItem>` toggles from its own
87
+ // controls. The instance has to follow the prop as well, because it’s the instance that handles
88
+ // the dismissal, and it ignores a popup it doesn’t know to be open.
84
89
  $effect(() => {
85
90
  if (popupInstance) {
86
91
  open = popupInstance.open;
87
92
  }
88
93
  });
89
94
 
95
+ $effect(() => {
96
+ if (popupInstance && popupInstance.open !== open) {
97
+ popupInstance.open = open;
98
+ }
99
+ });
100
+
90
101
  $effect(() => {
91
102
  if (parentDialogElement && !dialogElement && content) {
92
103
  dialogElement = parentDialogElement;