@streamscloud/kit 0.78.1 → 0.80.0

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.
@@ -51,15 +51,15 @@ A structured dialog panel with header (title + close button), scrollable body, a
51
51
  | `--sc-kit--dialog--height` | Dialog height | `auto` |
52
52
  | `--sc-kit--dialog--max-height` | Maximum height | `none` |
53
53
  | `--sc-kit--dialog--min-height` | Minimum height | `auto` |
54
- | `--sc-kit--dialog--padding-block` | Vertical padding for header/body/footer | `--sc-kit--space--5` |
55
- | `--sc-kit--dialog--padding-inline` | Horizontal padding for header/body/footer | `--sc-kit--space--6` |
54
+ | `--sc-kit--dialog--padding-block` | Vertical padding for header/body/footer | `--sc-kit--space--5`; `14px` when the dialog is under 768px wide inside `DialogHost` |
55
+ | `--sc-kit--dialog--padding-inline` | Horizontal padding for header/body/footer | `--sc-kit--space--6`; `--sc-kit--space--4` when the dialog is under 768px wide inside `DialogHost` |
56
56
  | `--sc-kit--dialog--body--overflow-y` | Body vertical overflow | `visible` |
57
- | `--sc-kit--dialog--body--padding-block` | Body-specific vertical padding | inherits `--padding-block` |
58
- | `--sc-kit--dialog--body--padding-inline` | Body-specific horizontal padding | inherits `--padding-inline` |
59
- | `--sc-kit--dialog--header--padding-block` | Header-specific vertical padding | inherits `--padding-block` |
60
- | `--sc-kit--dialog--header--padding-inline` | Header-specific horizontal padding | inherits `--padding-inline` |
61
- | `--sc-kit--dialog--footer--padding-block` | Footer-specific vertical padding | inherits `--padding-block` |
62
- | `--sc-kit--dialog--footer--padding-inline` | Footer-specific horizontal padding | inherits `--padding-inline` |
57
+ | `--sc-kit--dialog--body--padding-block` | Body-specific vertical padding | `--sc-kit--dialog--padding-block` |
58
+ | `--sc-kit--dialog--body--padding-inline` | Body-specific horizontal padding | `--sc-kit--dialog--padding-inline` |
59
+ | `--sc-kit--dialog--header--padding-block` | Header-specific vertical padding | `--sc-kit--dialog--padding-block` |
60
+ | `--sc-kit--dialog--header--padding-inline` | Header-specific horizontal padding | `--sc-kit--dialog--padding-inline` |
61
+ | `--sc-kit--dialog--footer--padding-block` | Footer-specific vertical padding | `--sc-kit--dialog--padding-block` |
62
+ | `--sc-kit--dialog--footer--padding-inline` | Footer-specific horizontal padding | `--sc-kit--dialog--padding-inline` |
63
63
  | `--sc-kit--dialog--title--font-size` | Title font size | `1.125rem` (18px) |
64
64
  | `--sc-kit--dialog--title--line-height` | Title line height | `1.4` |
65
65
  -->
@@ -73,13 +73,15 @@ A structured dialog panel with header (title + close button), scrollable body, a
73
73
  --_dialog--height: var(--sc-kit--dialog--height, auto);
74
74
  --_dialog--max-height: var(--sc-kit--dialog--max-height, none);
75
75
  --_dialog--min-height: var(--sc-kit--dialog--min-height, auto);
76
- --_dialog--padding-block: var(--sc-kit--dialog--padding-block, var(--sc-kit--space--5));
77
- --_dialog--padding-inline: var(--sc-kit--dialog--padding-inline, var(--sc-kit--space--6));
76
+ --_dialog--width-padding-block: var(--sc-kit--space--5);
77
+ --_dialog--width-padding-inline: var(--sc-kit--space--6);
78
+ --_dialog--padding-block: var(--sc-kit--dialog--padding-block, var(--_dialog--width-padding-block));
79
+ --_dialog--padding-inline: var(--sc-kit--dialog--padding-inline, var(--_dialog--width-padding-inline));
78
80
  --_dialog--body--overflow-y: var(--sc-kit--dialog--body--overflow-y, visible);
79
- --_dialog--body--padding-block: var(--sc-kit--dialog--body--padding-block, var(--_dialog--padding-block));
80
- --_dialog--body--padding-inline: var(--sc-kit--dialog--body--padding-inline, var(--_dialog--padding-inline));
81
- --_dialog--header--padding-block: var(--sc-kit--dialog--header--padding-block, var(--_dialog--padding-block));
82
- --_dialog--header--padding-inline: var(--sc-kit--dialog--header--padding-inline, var(--_dialog--padding-inline));
81
+ --_--dialog--body--padding-block: var(--sc-kit--dialog--body--padding-block, var(--_dialog--padding-block));
82
+ --_--dialog--body--padding-inline: var(--sc-kit--dialog--body--padding-inline, var(--_dialog--padding-inline));
83
+ --_--dialog--header--padding-block: var(--sc-kit--dialog--header--padding-block, var(--_dialog--padding-block));
84
+ --_--dialog--header--padding-inline: var(--sc-kit--dialog--header--padding-inline, var(--_dialog--padding-inline));
83
85
  --_dialog--footer--padding-block: var(--sc-kit--dialog--footer--padding-block, var(--_dialog--padding-block));
84
86
  --_dialog--footer--padding-inline: var(--sc-kit--dialog--footer--padding-inline, var(--_dialog--padding-inline));
85
87
  --_dialog--title-font-size: var(--sc-kit--dialog--title--font-size, 1.125rem);
@@ -98,7 +100,8 @@ A structured dialog panel with header (title + close button), scrollable body, a
98
100
  }
99
101
  @container (width < 768px) {
100
102
  .dialog {
101
- --_dialog--padding-inline: var(--sc-kit--dialog--padding-inline, var(--sc-kit--space--4));
103
+ --_dialog--width-padding-block: 0.875rem;
104
+ --_dialog--width-padding-inline: var(--sc-kit--space--4);
102
105
  }
103
106
  }
104
107
  .dialog__header {
@@ -107,7 +110,7 @@ A structured dialog panel with header (title + close button), scrollable body, a
107
110
  align-items: center;
108
111
  justify-content: space-between;
109
112
  gap: var(--sc-kit--space--4);
110
- padding: var(--_dialog--header--padding-block) var(--_dialog--header--padding-inline);
113
+ padding: var(--_--dialog--header--padding-block) var(--_--dialog--header--padding-inline);
111
114
  border-bottom: 1px solid var(--_dialog--border-color);
112
115
  -webkit-user-drag: none;
113
116
  user-select: none;
@@ -130,7 +133,7 @@ A structured dialog panel with header (title + close button), scrollable body, a
130
133
  flex: 1;
131
134
  min-height: 0;
132
135
  overflow-y: var(--_dialog--body--overflow-y);
133
- padding: var(--_dialog--body--padding-block) var(--_dialog--body--padding-inline);
136
+ padding: var(--_--dialog--body--padding-block) var(--_--dialog--body--padding-inline);
134
137
  --_cross-browser-scrollbar--thumb-color: var(--scrollbar--thumb-color, light-dark(#d1d5db, #4b5563));
135
138
  --_cross-browser-scrollbar--track-color: var(--scrollbar--track-color, transparent);
136
139
  }
@@ -52,15 +52,15 @@ interface $$IsomorphicComponent {
52
52
  * | `--sc-kit--dialog--height` | Dialog height | `auto` |
53
53
  * | `--sc-kit--dialog--max-height` | Maximum height | `none` |
54
54
  * | `--sc-kit--dialog--min-height` | Minimum height | `auto` |
55
- * | `--sc-kit--dialog--padding-block` | Vertical padding for header/body/footer | `--sc-kit--space--5` |
56
- * | `--sc-kit--dialog--padding-inline` | Horizontal padding for header/body/footer | `--sc-kit--space--6` |
55
+ * | `--sc-kit--dialog--padding-block` | Vertical padding for header/body/footer | `--sc-kit--space--5`; `14px` when the dialog is under 768px wide inside `DialogHost` |
56
+ * | `--sc-kit--dialog--padding-inline` | Horizontal padding for header/body/footer | `--sc-kit--space--6`; `--sc-kit--space--4` when the dialog is under 768px wide inside `DialogHost` |
57
57
  * | `--sc-kit--dialog--body--overflow-y` | Body vertical overflow | `visible` |
58
- * | `--sc-kit--dialog--body--padding-block` | Body-specific vertical padding | inherits `--padding-block` |
59
- * | `--sc-kit--dialog--body--padding-inline` | Body-specific horizontal padding | inherits `--padding-inline` |
60
- * | `--sc-kit--dialog--header--padding-block` | Header-specific vertical padding | inherits `--padding-block` |
61
- * | `--sc-kit--dialog--header--padding-inline` | Header-specific horizontal padding | inherits `--padding-inline` |
62
- * | `--sc-kit--dialog--footer--padding-block` | Footer-specific vertical padding | inherits `--padding-block` |
63
- * | `--sc-kit--dialog--footer--padding-inline` | Footer-specific horizontal padding | inherits `--padding-inline` |
58
+ * | `--sc-kit--dialog--body--padding-block` | Body-specific vertical padding | `--sc-kit--dialog--padding-block` |
59
+ * | `--sc-kit--dialog--body--padding-inline` | Body-specific horizontal padding | `--sc-kit--dialog--padding-inline` |
60
+ * | `--sc-kit--dialog--header--padding-block` | Header-specific vertical padding | `--sc-kit--dialog--padding-block` |
61
+ * | `--sc-kit--dialog--header--padding-inline` | Header-specific horizontal padding | `--sc-kit--dialog--padding-inline` |
62
+ * | `--sc-kit--dialog--footer--padding-block` | Footer-specific vertical padding | `--sc-kit--dialog--padding-block` |
63
+ * | `--sc-kit--dialog--footer--padding-inline` | Footer-specific horizontal padding | `--sc-kit--dialog--padding-inline` |
64
64
  * | `--sc-kit--dialog--title--font-size` | Title font size | `1.125rem` (18px) |
65
65
  * | `--sc-kit--dialog--title--line-height` | Title line height | `1.4` |
66
66
  */
@@ -103,6 +103,7 @@ subtitle / close), vertical Stepper sidebar, scrollable body, and a Cancel / Bac
103
103
  footer. Render it inside your own dialog view opened via `Dialogs.open()` — the layout owns step
104
104
  navigation and the submitting state; your `on.finish` owns closing (`controller.ok(...)`).
105
105
  Completed sidebar steps are clickable to go back; `canAdvance(step)` gates Next / Finish.
106
+ Header, body and sidebar block padding follow `Dialog`'s header / body padding vars.
106
107
 
107
108
  ### CSS Custom Properties
108
109
  | Property | Description | Default |
@@ -116,14 +117,13 @@ Completed sidebar steps are clickable to go back; `canAdvance(step)` gates Next
116
117
  --_sd--sidebar-width: var(--sc-kit--stepper-dialog--sidebar--width, 20rem);
117
118
  --_sd--sidebar-background: var(--sc-kit--stepper-dialog--sidebar--background, var(--sc-kit--color--bg--field-alt));
118
119
  --sc-kit--dialog--height: var(--sc-kit--stepper-dialog--height, auto);
119
- --sc-kit--dialog--body--overflow-y: auto;
120
120
  display: contents;
121
121
  }
122
122
  .stepper-dialog__header {
123
123
  display: flex;
124
124
  align-items: flex-start;
125
125
  gap: var(--sc-kit--space--3);
126
- padding: var(--sc-kit--space--5) var(--sc-kit--space--6);
126
+ padding: var(--_--dialog--header--padding-block) var(--_--dialog--header--padding-inline);
127
127
  border-block-end: 1px solid var(--sc-kit--color--border);
128
128
  }
129
129
  .stepper-dialog__icon {
@@ -169,7 +169,7 @@ Completed sidebar steps are clickable to go back; `canAdvance(step)` gates Next
169
169
  }
170
170
  .stepper-dialog__sidebar {
171
171
  flex: 0 0 var(--_sd--sidebar-width);
172
- padding: var(--sc-kit--space--5) var(--sc-kit--space--4);
172
+ padding: var(--_--dialog--body--padding-block) var(--sc-kit--space--4);
173
173
  background: var(--_sd--sidebar-background);
174
174
  border-inline-end: 1px solid var(--sc-kit--color--border);
175
175
  overflow-y: auto;
@@ -193,5 +193,5 @@ Completed sidebar steps are clickable to go back; `canAdvance(step)` gates Next
193
193
  flex: 1;
194
194
  min-block-size: 0;
195
195
  overflow-y: auto;
196
- padding: var(--sc-kit--space--5) var(--sc-kit--space--6);
196
+ padding: var(--_--dialog--body--padding-block) var(--_--dialog--body--padding-inline);
197
197
  }</style>
@@ -53,6 +53,7 @@ interface $$IsomorphicComponent {
53
53
  * footer. Render it inside your own dialog view opened via `Dialogs.open()` — the layout owns step
54
54
  * navigation and the submitting state; your `on.finish` owns closing (`controller.ok(...)`).
55
55
  * Completed sidebar steps are clickable to go back; `canAdvance(step)` gates Next / Finish.
56
+ * Header, body and sidebar block padding follow `Dialog`'s header / body padding vars.
56
57
  *
57
58
  * ### CSS Custom Properties
58
59
  * | Property | Description | Default |
@@ -1,120 +1,113 @@
1
1
  <script lang="ts">import { InputClearButton } from '../_internal/input-clear-button';
2
- import { Icon } from '../icon';
2
+ import { IconSlot } from '../icon';
3
3
  import { formatNumeral, NumeralThousandGroupStyles, registerCursorTracker } from 'cleave-zen';
4
- const { size = 'md', disabled = false, readonly = false, inert = false, error = false, borderless = false, placeholder = '', title = '', name = '', id = null, autofocus = false, min = null, max = null, decimals = 2, suffix = '', 'aria-describedby': ariaDescribedby, 'aria-label': ariaLabel, 'aria-required': ariaRequired, icon, iconPosition = 'leading', secondaryIcon, ...mode } = $props();
4
+ import { untrack } from 'svelte';
5
+ const { size = 'md', disabled = false, readonly = false, inert = false, error = false, borderless = false, placeholder = '', title = '', name = '', id = null, autofocus = false, min = null, max = null, decimals = 2, suffix, hideResolvedValue = false, 'aria-describedby': ariaDescribedby, 'aria-label': ariaLabel, 'aria-required': ariaRequired, icon, iconPosition = 'leading', secondaryIcon, ...mode } = $props();
5
6
  let inputEl = $state.raw(undefined);
7
+ let focused = $state(false);
8
+ let typedValue = $state(undefined);
6
9
  const formatOptions = $derived({
7
10
  delimiter: ' ',
8
11
  numeralDecimalScale: decimals,
9
12
  numeralThousandsGroupStyle: NumeralThousandGroupStyles.THOUSAND,
10
13
  numeralIntegerScale: 14
11
14
  });
12
- // Display string — formatted value + suffix, or empty when null.
13
- const displayValue = $derived(mode.value === null || mode.value === undefined ? '' : formatNumeral(String(mode.value), formatOptions) + (suffix && mode.value !== null ? suffix : ''));
15
+ const toRoundedPlainString = (value) => Number(value.toFixed(decimals))
16
+ .toFixed(decimals)
17
+ .replace(/(\.\d*?)0+$/, '$1')
18
+ .replace(/\.$/, '');
19
+ const formatValue = (value) => value === null || value === undefined ? '' : formatNumeral(toRoundedPlainString(value), formatOptions);
20
+ const displayValue = $derived(formatValue(mode.value));
14
21
  const clearButtonVisible = $derived(mode.clearable === true && !disabled && !readonly && !inert && mode.value !== null && mode.value !== undefined);
15
22
  /** Imperative API exposed via `bind:this`. */
16
23
  export const focus = () => inputEl?.focus();
17
24
  export const select = () => inputEl?.select();
18
- /** Parses the raw input string back to a number (or null when empty). Clamps to min/max. */
19
- const parseValue = (raw) => {
20
- if (!raw.trim()) {
21
- return null;
22
- }
23
- // Strip suffix + non-numeric chars except `.` `,` `-`, then normalize to dot-decimal.
24
- let cleaned = suffix ? raw.replace(suffix, '') : raw;
25
- cleaned = cleaned
26
- .replace(/\s/g, '')
27
- .replace(',', '.')
28
- .replace(/[^0-9.-]/g, '');
29
- const n = parseFloat(cleaned);
30
- if (Number.isNaN(n)) {
31
- return null;
32
- }
33
- let clamped = n;
34
- if (min !== null && clamped < min) {
35
- clamped = min;
36
- }
37
- if (max !== null && clamped > max) {
38
- clamped = max;
39
- }
40
- return Number(clamped.toFixed(decimals));
25
+ const parseValue = (formatted) => {
26
+ const n = parseFloat(formatted.replace(/\s/g, ''));
27
+ return Number.isNaN(n) ? null : n;
41
28
  };
42
- const initInput = (node) => {
43
- inputEl = node;
44
- if (autofocus) {
45
- node.focus();
29
+ const resolveValue = (typed) => {
30
+ if (typed !== null) {
31
+ return Math.min(Math.max(typed, min ?? -Infinity), max ?? Infinity);
46
32
  }
47
- registerCursorTracker({ input: node, delimiter: formatOptions.delimiter, delimiters: [] });
48
- mode.on?.mounted?.({ input: node });
33
+ return mode.clearable ? null : (min ?? mode.value);
49
34
  };
50
- const emit = (parsed, kind) => {
35
+ const resolvedTypedValue = $derived(typedValue === undefined ? undefined : resolveValue(typedValue));
36
+ const valueHintVisible = $derived(!hideResolvedValue && resolvedTypedValue !== typedValue);
37
+ const initialText = untrack(() => displayValue);
38
+ // Writing the prop into the field while focused would overwrite a typed "1" with min "8" before "12" can be typed.
39
+ $effect(() => {
40
+ if (inputEl && !focused) {
41
+ inputEl.value = displayValue;
42
+ }
43
+ });
44
+ const emit = (kind, value) => {
51
45
  if (mode.clearable) {
52
- const cb = mode.on?.[kind];
53
- cb?.(parsed);
46
+ mode.on?.[kind]?.(value);
54
47
  }
55
- else {
56
- // RequiredProps callbacks take `number` (never null). Coerce null → 0 (the legacy contract).
57
- if (parsed !== null) {
58
- mode.on?.[kind]?.(parsed);
59
- }
48
+ else if (value !== null) {
49
+ mode.on?.[kind]?.(value);
60
50
  }
61
51
  };
52
+ // Safari fires no native change once script has rewritten the value during input, so Enter and blur commit too.
53
+ const commit = () => {
54
+ if (!inputEl || typedValue === undefined) {
55
+ return;
56
+ }
57
+ const value = resolveValue(typedValue);
58
+ typedValue = undefined;
59
+ inputEl.value = formatValue(value);
60
+ emit('change', value);
61
+ };
62
62
  const handleInput = () => {
63
63
  if (!inputEl) {
64
64
  return;
65
65
  }
66
- // Reformat through cleave-zen so invalid characters are stripped and the decimal scale
67
- // is capped to `decimals` while the user is typing. Strip suffix first so it doesn't
68
- // get mangled, re-add nothing here (suffix re-appears on blur via displayValue resync).
69
- const raw = suffix ? inputEl.value.replace(suffix, '') : inputEl.value;
70
- let formatted = formatNumeral(raw, formatOptions);
71
- // Clamp on the fly when typing pushes the value past min / max — re-format the clamped
72
- // result so the visible input matches the emitted value.
73
- const parsedRaw = parseFloat(formatted.replace(/\s/g, '').replace(',', '.'));
74
- if (!Number.isNaN(parsedRaw)) {
75
- let clamped = parsedRaw;
76
- if (min !== null && clamped < min) {
77
- clamped = min;
78
- }
79
- if (max !== null && clamped > max) {
80
- clamped = max;
81
- }
82
- if (clamped !== parsedRaw) {
83
- formatted = formatNumeral(String(Number(clamped.toFixed(decimals))), formatOptions);
84
- }
85
- }
66
+ const raw = inputEl.value.includes('.') ? inputEl.value : inputEl.value.replace(',', '.');
67
+ const formatted = formatNumeral(raw, formatOptions);
86
68
  if (inputEl.value !== formatted) {
87
69
  inputEl.value = formatted;
88
70
  }
89
- const parsed = parseValue(inputEl.value);
90
- emit(parsed, 'input');
71
+ typedValue = parseValue(formatted);
72
+ emit('input', resolveValue(typedValue));
91
73
  };
92
- const handleChange = () => {
93
- if (!inputEl) {
94
- return;
74
+ const handleKeydown = (event) => {
75
+ if (event.key === 'Enter' && !event.isComposing) {
76
+ commit();
95
77
  }
96
- const parsed = parseValue(inputEl.value);
97
- emit(parsed, 'change');
98
78
  };
99
79
  const handleClear = () => {
100
80
  if (!inputEl) {
101
81
  return;
102
82
  }
103
83
  inputEl.value = '';
104
- emit(null, 'input');
105
- emit(null, 'change');
84
+ typedValue = undefined;
85
+ emit('input', null);
86
+ emit('change', null);
106
87
  inputEl.focus();
107
88
  };
89
+ const handleFocus = () => {
90
+ focused = true;
91
+ mode.on?.focus?.();
92
+ };
108
93
  const handleBlur = () => {
109
- if (!inputEl) {
110
- return;
94
+ commit();
95
+ focused = false;
96
+ mode.on?.blur?.();
97
+ };
98
+ const initInput = (node) => {
99
+ inputEl = node;
100
+ // A field focused before hydration never gets a focus event, so the sync effect would overwrite what is typed next.
101
+ if (node.matches(':focus')) {
102
+ handleFocus();
103
+ }
104
+ if (autofocus) {
105
+ node.focus();
111
106
  }
112
- const parsed = parseValue(inputEl.value);
113
- // Re-sync to the formatted display value so the input reflects clamped / canonical form.
114
- inputEl.value = displayValue;
115
- emit(parsed, 'blur');
107
+ const unregisterCursorTracker = registerCursorTracker({ input: node, delimiter: formatOptions.delimiter, delimiters: [] });
108
+ mode.on?.mounted?.({ input: node });
109
+ return { destroy: unregisterCursorTracker };
116
110
  };
117
- const focusInput = () => inputEl?.focus();
118
111
  </script>
119
112
 
120
113
  <div
@@ -124,18 +117,14 @@ const focusInput = () => inputEl?.focus();
124
117
  class:numeral-input--inert={inert}
125
118
  class:numeral-input--error={error}
126
119
  class:numeral-input--borderless={borderless}
127
- onclick={focusInput}
120
+ onclick={focus}
128
121
  onkeydown={() => undefined}
129
122
  role="none">
130
123
  {#if icon && iconPosition === 'leading'}
131
- <span class="numeral-input__icon numeral-input__icon--leading" aria-hidden="true">
132
- {#if typeof icon === 'string'}<Icon src={icon} />{:else}{@render icon()}{/if}
133
- </span>
124
+ <span class="numeral-input__icon numeral-input__icon--leading" aria-hidden="true"><IconSlot icon={icon} /></span>
134
125
  {/if}
135
126
  {#if secondaryIcon && iconPosition === 'trailing'}
136
- <span class="numeral-input__icon numeral-input__icon--leading" aria-hidden="true">
137
- {#if typeof secondaryIcon === 'string'}<Icon src={secondaryIcon} />{:else}{@render secondaryIcon()}{/if}
138
- </span>
127
+ <span class="numeral-input__icon numeral-input__icon--leading" aria-hidden="true"><IconSlot icon={secondaryIcon} /></span>
139
128
  {/if}
140
129
 
141
130
  <!-- svelte-ignore a11y_autofocus -->
@@ -152,38 +141,49 @@ const focusInput = () => inputEl?.focus();
152
141
  inert={inert}
153
142
  autofocus={autofocus}
154
143
  title={title}
155
- value={displayValue}
144
+ value={initialText}
156
145
  aria-invalid={error ? 'true' : undefined}
157
146
  aria-describedby={ariaDescribedby}
158
147
  aria-label={ariaLabel}
159
148
  aria-required={ariaRequired ? 'true' : undefined}
160
149
  oninput={handleInput}
161
- onchange={handleChange}
150
+ onchange={commit}
162
151
  onblur={handleBlur}
163
- onfocus={() => mode.on?.focus?.()} />
152
+ onfocus={handleFocus}
153
+ onkeydown={handleKeydown} />
154
+
155
+ {#if valueHintVisible}
156
+ <span class="numeral-input__value-hint"><span aria-hidden="true">→</span> {formatValue(resolvedTypedValue)}</span>
157
+ {/if}
158
+
159
+ {#if suffix}
160
+ <span class="numeral-input__suffix">{suffix}</span>
161
+ {/if}
164
162
 
165
163
  {#if clearButtonVisible}
166
164
  <InputClearButton size={size} on={{ clear: handleClear }} />
167
165
  {/if}
168
166
 
169
167
  {#if icon && iconPosition === 'trailing'}
170
- <span class="numeral-input__icon numeral-input__icon--trailing" aria-hidden="true">
171
- {#if typeof icon === 'string'}<Icon src={icon} />{:else}{@render icon()}{/if}
172
- </span>
168
+ <span class="numeral-input__icon numeral-input__icon--trailing" aria-hidden="true"><IconSlot icon={icon} /></span>
173
169
  {/if}
174
170
  {#if secondaryIcon && iconPosition === 'leading'}
175
- <span class="numeral-input__icon numeral-input__icon--trailing" aria-hidden="true">
176
- {#if typeof secondaryIcon === 'string'}<Icon src={secondaryIcon} />{:else}{@render secondaryIcon()}{/if}
177
- </span>
171
+ <span class="numeral-input__icon numeral-input__icon--trailing" aria-hidden="true"><IconSlot icon={secondaryIcon} /></span>
178
172
  {/if}
179
173
  </div>
180
174
 
181
175
  <!--
182
176
  @component
183
- Formatted number input with locale-aware thousand separators, decimal scale, min/max clamping,
184
- optional suffix (` %`, ` kg`), and smooth cursor tracking while typing (via cleave-zen).
185
- Discriminated by `clearable`: `clearable: true` allows `null` (with × button); `clearable: false`
186
- requires a non-null value. CSS API mirrors `Input` for visual-language compatibility.
177
+ Formatted number input with space-grouped thousands, a dot or comma decimal mark, decimal scale, min/max clamping,
178
+ a muted trailing `suffix` unit, and smooth cursor tracking while typing
179
+ (via cleave-zen). Discriminated by `clearable`: `clearable: true` allows `null` (with × button);
180
+ `clearable: false` requires a non-null value. CSS API mirrors `Input` for visual-language compatibility.
181
+
182
+ While focused the text belongs to the user: a value change from outside lands on blur. Every
183
+ emitted value is clamped to `min` / `max`, but the typed text is left alone until commit (blur,
184
+ Enter, `change`) — so a prefix below `min` stays typeable. With `clearable: false` an empty
185
+ field emits `min`, or keeps the held value when there is no `min`. Whenever the typed text resolves
186
+ to a different value, that value shows next to the field as `→ 12` (off with `hideResolvedValue`).
187
187
 
188
188
  ### CSS Custom Properties
189
189
  | Property | Description | Default |
@@ -204,6 +204,7 @@ requires a non-null value. CSS API mirrors `Input` for visual-language compatibi
204
204
  | `--sc-kit--numeral-input--placeholder--color` | Placeholder text color | `var(--sc-kit--color--text--placeholder)` |
205
205
  | `--sc-kit--numeral-input--icon--color` | Leading / trailing icon color (does NOT affect inner affordances) | `var(--sc-kit--color--text--muted)` |
206
206
  | `--sc-kit--numeral-input--icon--size` | Leading / trailing icon size (does NOT affect inner affordances) | per size |
207
+ | `--sc-kit--numeral-input--suffix--color` | Suffix and value hint color | `var(--sc-kit--color--text--muted)` |
207
208
  -->
208
209
 
209
210
  <style>.numeral-input {
@@ -224,6 +225,7 @@ requires a non-null value. CSS API mirrors `Input` for visual-language compatibi
224
225
  --_ni--placeholder-color: var(--sc-kit--numeral-input--placeholder--color, var(--sc-kit--color--text--placeholder));
225
226
  --_ni--icon-color: var(--sc-kit--numeral-input--icon--color, var(--sc-kit--color--text--muted));
226
227
  --_ni--icon-size: var(--sc-kit--numeral-input--icon--size, var(--_ni--size-icon-size, var(--sc-kit--field--icon--size--md)));
228
+ --_ni--suffix-color: var(--sc-kit--numeral-input--suffix--color, var(--sc-kit--color--text--muted));
227
229
  --_--input-affordance--opacity: 0;
228
230
  --_--input-affordance--scale: 0;
229
231
  }
@@ -321,6 +323,19 @@ requires a non-null value. CSS API mirrors `Input` for visual-language compatibi
321
323
  .numeral-input__el:disabled {
322
324
  cursor: default;
323
325
  }
326
+ .numeral-input__value-hint {
327
+ flex-shrink: 0;
328
+ margin-inline-start: var(--_ni--gap);
329
+ color: var(--_ni--suffix-color);
330
+ font-variant-numeric: tabular-nums;
331
+ white-space: nowrap;
332
+ }
333
+ .numeral-input__suffix {
334
+ flex-shrink: 0;
335
+ margin-inline-start: var(--_ni--gap);
336
+ color: var(--_ni--suffix-color);
337
+ white-space: nowrap;
338
+ }
324
339
  .numeral-input__icon {
325
340
  --sc-kit--icon--color: var(--_ni--icon-color);
326
341
  --sc-kit--icon--size: var(--_ni--icon-size);
@@ -1,4 +1,4 @@
1
- import type { Snippet } from 'svelte';
1
+ import { type IconProp } from '../icon';
2
2
  type BaseProps = {
3
3
  /** @default 'md' */
4
4
  size?: 'sm' | 'md' | 'lg';
@@ -16,23 +16,25 @@ type BaseProps = {
16
16
  name?: string;
17
17
  id?: string | null;
18
18
  autofocus?: boolean;
19
- /** Minimum allowed value (clamped on input). */
19
+ /** Minimum value emitted to the consumer, also emitted for an empty `clearable: false` field; the typed text is left alone until commit. */
20
20
  min?: number | null;
21
- /** Maximum allowed value (clamped on input). */
21
+ /** Maximum value emitted to the consumer; the typed text is left alone until commit. */
22
22
  max?: number | null;
23
23
  /** Number of decimal places shown. @default 2 */
24
24
  decimals?: number;
25
- /** Suffix appended to the formatted value at rest (e.g. ` %`, ` kg`). */
25
+ /** Muted unit rendered after the value, outside the editable text (`px`, `%`, `°`). */
26
26
  suffix?: string;
27
+ /** Hides the `→ 12` hint shown while the typed text resolves to a different value — for narrow fields. */
28
+ hideResolvedValue?: boolean;
27
29
  'aria-describedby'?: string;
28
30
  'aria-label'?: string;
29
31
  'aria-required'?: boolean;
30
- /** Primary icon — SVG source string or snippet. */
31
- icon?: string | Snippet;
32
+ /** Primary icon — string SVG source, `{ src, color?, size? }` object, or custom snippet. */
33
+ icon?: IconProp;
32
34
  /** Side of the input where the primary icon sits. @default 'leading' */
33
35
  iconPosition?: 'leading' | 'trailing';
34
36
  /** Optional secondary icon — always on the opposite side from `icon`. */
35
- secondaryIcon?: string | Snippet;
37
+ secondaryIcon?: IconProp;
36
38
  };
37
39
  type ClearableProps = BaseProps & {
38
40
  /** Value can be cleared to `null`. Trigger shows an × button when set. */
@@ -44,7 +46,7 @@ type ClearableProps = BaseProps & {
44
46
  mounted?: (data: {
45
47
  input: HTMLInputElement;
46
48
  }) => void;
47
- blur?: (value: number | null) => void;
49
+ blur?: () => void;
48
50
  focus?: () => void;
49
51
  };
50
52
  };
@@ -58,16 +60,22 @@ type RequiredProps = BaseProps & {
58
60
  mounted?: (data: {
59
61
  input: HTMLInputElement;
60
62
  }) => void;
61
- blur?: (value: number) => void;
63
+ blur?: () => void;
62
64
  focus?: () => void;
63
65
  };
64
66
  };
65
67
  type Props = ClearableProps | RequiredProps;
66
68
  /**
67
- * Formatted number input with locale-aware thousand separators, decimal scale, min/max clamping,
68
- * optional suffix (` %`, ` kg`), and smooth cursor tracking while typing (via cleave-zen).
69
- * Discriminated by `clearable`: `clearable: true` allows `null` (with × button); `clearable: false`
70
- * requires a non-null value. CSS API mirrors `Input` for visual-language compatibility.
69
+ * Formatted number input with space-grouped thousands, a dot or comma decimal mark, decimal scale, min/max clamping,
70
+ * a muted trailing `suffix` unit, and smooth cursor tracking while typing
71
+ * (via cleave-zen). Discriminated by `clearable`: `clearable: true` allows `null` (with × button);
72
+ * `clearable: false` requires a non-null value. CSS API mirrors `Input` for visual-language compatibility.
73
+ *
74
+ * While focused the text belongs to the user: a value change from outside lands on blur. Every
75
+ * emitted value is clamped to `min` / `max`, but the typed text is left alone until commit (blur,
76
+ * Enter, `change`) — so a prefix below `min` stays typeable. With `clearable: false` an empty
77
+ * field emits `min`, or keeps the held value when there is no `min`. Whenever the typed text resolves
78
+ * to a different value, that value shows next to the field as `→ 12` (off with `hideResolvedValue`).
71
79
  *
72
80
  * ### CSS Custom Properties
73
81
  * | Property | Description | Default |
@@ -88,6 +96,7 @@ type Props = ClearableProps | RequiredProps;
88
96
  * | `--sc-kit--numeral-input--placeholder--color` | Placeholder text color | `var(--sc-kit--color--text--placeholder)` |
89
97
  * | `--sc-kit--numeral-input--icon--color` | Leading / trailing icon color (does NOT affect inner affordances) | `var(--sc-kit--color--text--muted)` |
90
98
  * | `--sc-kit--numeral-input--icon--size` | Leading / trailing icon size (does NOT affect inner affordances) | per size |
99
+ * | `--sc-kit--numeral-input--suffix--color` | Suffix and value hint color | `var(--sc-kit--color--text--muted)` |
91
100
  */
92
101
  declare const Cmp: import("svelte").Component<Props, {
93
102
  /** Imperative API exposed via `bind:this`. */ focus: () => void;
@@ -1,16 +1,59 @@
1
1
  <script lang="ts">import { Checkbox } from '../checkbox';
2
- import { ToolbarIconButton } from '../page-toolbar';
2
+ import { Icon, IconSlot } from '../icon';
3
+ import { IconText } from '../icon-text';
3
4
  import { PlayIndicator } from '../play-indicator';
4
- import { Popover } from '../popover';
5
+ import { Popover, PopoverItem } from '../popover';
5
6
  import { TileCardLocalization } from './tile-card-localization';
6
7
  import IconMore from '@fluentui/svg-icons/icons/more_vertical_20_regular.svg?raw';
7
- const { title, meta, preview, menu, menuLabel, selected = false, checkboxVisibility = 'hover', on } = $props();
8
+ const { title, meta, preview, actions, selected = false, checkboxVisibility = 'hover', on } = $props();
8
9
  const localization = new TileCardLocalization();
10
+ const actionItems = $derived(actions?.items ?? []);
11
+ const actionsPosition = $derived(preview ? (actions?.position ?? 'footer') : 'footer');
12
+ let menuOpened = $state(false);
13
+ let keyboardFocused = $state(false);
9
14
  // Safari paints no frame for preload="metadata" until a seek target is given.
10
15
  const withFirstFrame = (src) => (src.includes('#') ? src : `${src}#t=0.001`);
11
16
  </script>
12
17
 
13
- <div class="tile-card" class:tile-card--selected={selected} class:tile-card--checkbox-always={checkboxVisibility === 'always'}>
18
+ {#snippet actionsBlock()}
19
+ {#if actionItems.length}
20
+ <div class="tile-card__actions tile-card__actions--{actionsPosition}">
21
+ {#if actionItems.length === 1 && actionsPosition === 'top' && actions?.singleAsButton}
22
+ <button
23
+ type="button"
24
+ class="tile-card__action"
25
+ class:tile-card__action--overlay={actionsPosition === 'top'}
26
+ aria-label={actionItems[0].label}
27
+ title={actionItems[0].label}
28
+ onclick={() => actionItems[0].onClick()}>
29
+ <IconSlot icon={actionItems[0].icon} fallbacks={{ size: 'control-md' }} />
30
+ </button>
31
+ {:else}
32
+ <Popover
33
+ position="bottom-end"
34
+ fixedPosition
35
+ aria-label={actions?.label ?? localization.actions}
36
+ on={{ opened: () => (menuOpened = true), closed: () => (menuOpened = false) }}>
37
+ {#snippet trigger()}
38
+ <span class="tile-card__action" class:tile-card__action--overlay={actionsPosition === 'top'}><Icon src={IconMore} size="control-md" /></span>
39
+ {/snippet}
40
+ {#each actionItems as action, index (index)}
41
+ <PopoverItem on={{ click: action.onClick }}><IconText icon={action.icon} size="sm">{action.label}</IconText></PopoverItem>
42
+ {/each}
43
+ </Popover>
44
+ {/if}
45
+ </div>
46
+ {/if}
47
+ {/snippet}
48
+
49
+ <div
50
+ class="tile-card"
51
+ class:tile-card--selected={selected}
52
+ class:tile-card--checkbox-always={checkboxVisibility === 'always'}
53
+ class:tile-card--menu-opened={menuOpened}
54
+ class:tile-card--keyboard-focused={keyboardFocused}
55
+ onfocusin={(e) => (keyboardFocused = (e.target as HTMLElement).matches(':focus-visible'))}
56
+ onfocusout={() => (keyboardFocused = false)}>
14
57
  {#if on?.activate}
15
58
  <button type="button" class="tile-card__hit" aria-label={title} title={title} onclick={() => on?.activate?.()}></button>
16
59
  {/if}
@@ -33,17 +76,13 @@ const withFirstFrame = (src) => (src.includes('#') ? src : `${src}#t=0.001`);
33
76
  <span class="tile-card__meta">{meta}</span>
34
77
  {/if}
35
78
  </div>
36
- {#if menu}
37
- <div class="tile-card__menu">
38
- <Popover position="bottom-end" fixedPosition aria-label={menuLabel ?? localization.actions}>
39
- {#snippet trigger()}
40
- <ToolbarIconButton icon={IconMore} presentational />
41
- {/snippet}
42
- {@render menu()}
43
- </Popover>
44
- </div>
79
+ {#if actionsPosition === 'footer'}
80
+ {@render actionsBlock()}
45
81
  {/if}
46
82
  </div>
83
+ {#if actionsPosition === 'top'}
84
+ {@render actionsBlock()}
85
+ {/if}
47
86
  {#if on?.select}
48
87
  <div class="tile-card__checkbox">
49
88
  <Checkbox checked={selected} variant="on-accent" title={localization.select} on={{ change: (value) => on?.select?.(value) }} />
@@ -53,8 +92,9 @@ const withFirstFrame = (src) => (src.includes('#') ? src : `${src}#t=0.001`);
53
92
 
54
93
  <!--
55
94
  @component
56
- File-browser style tile: a full-bleed preview on top and a footer with title, optional meta line and a kebab menu. Selection
57
- checkbox via `on.select`, whole-card activation via `on.activate`. For a drop-target highlight wrap it in `DropZone`.
95
+ File-browser style tile: a full-bleed preview on top and a footer with title and optional meta line. `actions` sit at the end
96
+ of the footer or over the preview's top-right corner. Selection checkbox via `on.select`, whole-card activation via
97
+ `on.activate`. For a drop-target highlight wrap it in `DropZone`.
58
98
 
59
99
  ### CSS Custom Properties
60
100
  | Property | Description | Default |
@@ -79,6 +119,7 @@ checkbox via `on.select`, whole-card activation via `on.activate`. For a drop-ta
79
119
  --_tile-card--preview-aspect-ratio: var(--sc-kit--tile-card--preview--aspect-ratio, 7 / 5);
80
120
  --_tile-card--footer-min-height: var(--sc-kit--tile-card--footer--min-height, 3.25rem);
81
121
  --_tile-card--checkbox-opacity: 0;
122
+ --_tile-card--top-actions-opacity: 0;
82
123
  position: relative;
83
124
  display: flex;
84
125
  flex-direction: column;
@@ -93,8 +134,12 @@ checkbox via `on.select`, whole-card activation via `on.activate`. For a drop-ta
93
134
  .tile-card:hover {
94
135
  --_tile-card--border-color: var(--_tile-card--border-color-hover);
95
136
  }
96
- .tile-card:hover, .tile-card:focus-within {
137
+ .tile-card:hover, .tile-card--keyboard-focused {
97
138
  --_tile-card--checkbox-opacity: 1;
139
+ --_tile-card--top-actions-opacity: 1;
140
+ }
141
+ .tile-card--menu-opened {
142
+ --_tile-card--top-actions-opacity: 1;
98
143
  }
99
144
  .tile-card:has(> .tile-card__hit:focus-visible) {
100
145
  outline: 2px solid var(--sc-kit--color--border--focus);
@@ -112,6 +157,7 @@ checkbox via `on.select`, whole-card activation via `on.activate`. For a drop-ta
112
157
  @media (hover: none) {
113
158
  .tile-card {
114
159
  --_tile-card--checkbox-opacity: 1;
160
+ --_tile-card--top-actions-opacity: 1;
115
161
  }
116
162
  }
117
163
  .tile-card__hit {
@@ -182,10 +228,46 @@ checkbox via `on.select`, whole-card activation via `on.activate`. For a drop-ta
182
228
  white-space: nowrap;
183
229
  overflow: hidden;
184
230
  }
185
- .tile-card__menu {
231
+ .tile-card__actions {
186
232
  position: relative;
187
233
  display: flex;
188
234
  }
235
+ .tile-card__actions--top {
236
+ position: absolute;
237
+ inset-block-start: var(--sc-kit--space--3);
238
+ inset-inline-end: var(--sc-kit--space--3);
239
+ opacity: var(--_tile-card--top-actions-opacity);
240
+ transition: opacity var(--sc-kit--duration--base) var(--sc-kit--ease--default);
241
+ }
242
+ .tile-card__action {
243
+ --sc-kit--icon--color: var(--sc-kit--color--text--muted);
244
+ display: inline-flex;
245
+ align-items: center;
246
+ justify-content: center;
247
+ inline-size: 1.75rem;
248
+ block-size: 1.75rem;
249
+ padding: 0;
250
+ background: transparent;
251
+ border: 0;
252
+ border-radius: var(--sc-kit--field--border-radius--sm);
253
+ cursor: pointer;
254
+ transition: background-color var(--sc-kit--duration--base) var(--sc-kit--ease--default);
255
+ }
256
+ .tile-card__action:hover {
257
+ background: var(--sc-kit--color--bg--hover);
258
+ }
259
+ .tile-card__action:focus-visible {
260
+ outline: 2px solid var(--sc-kit--color--border--focus);
261
+ outline-offset: 2px;
262
+ }
263
+ .tile-card__action--overlay {
264
+ --sc-kit--icon--color: var(--sc-kit--color--text--primary);
265
+ background: var(--sc-kit--color--bg--panel);
266
+ box-shadow: var(--sc-kit--shadow--md);
267
+ }
268
+ .tile-card__action--overlay:hover {
269
+ background: var(--sc-kit--color--bg--hover);
270
+ }
189
271
  .tile-card__checkbox {
190
272
  position: absolute;
191
273
  inset-block-start: var(--sc-kit--space--3);
@@ -1,28 +1,25 @@
1
- import type { TileCardCheckboxVisibility, TileCardPreview } from './types';
2
- import type { Snippet } from 'svelte';
1
+ import type { TileCardActions, TileCardCheckboxVisibility, TileCardPreview } from './types';
3
2
  type Props = {
4
3
  title: string;
5
4
  /** Secondary line under the title. */
6
5
  meta?: string;
7
- /** Top area: `image` / `video` render a drag-safe picture or frame (contain, never upscaled; a video gets a play glyph); a snippet renders custom content. The area is `pointer-events: none` — interactive content belongs in `menu` or `on.activate`. */
6
+ /** Top area: `image` / `video` render a drag-safe picture or frame (contain, never upscaled; a video gets a play glyph); a snippet renders custom content. The area is `pointer-events: none` — interactive content belongs in `actions` or `on.activate`. */
8
7
  preview?: TileCardPreview;
9
- /** `PopoverItem`s of the kebab menu at the end of the footer. */
10
- menu?: Snippet;
11
- /** Accessible name of the kebab menu. @default localized "Actions" */
12
- menuLabel?: string;
8
+ actions?: TileCardActions;
13
9
  selected?: boolean;
14
- /** When the checkbox shows: `'hover'` — on hover, focus-within, when selected and on touch screens; `'always'` — permanently. @default 'hover' */
10
+ /** When the checkbox shows: `'hover'` — on hover, keyboard focus, when selected and on touch screens; `'always'` — permanently. @default 'hover' */
15
11
  checkboxVisibility?: TileCardCheckboxVisibility;
16
12
  on?: {
17
13
  /** Renders the checkbox at the top-left of the preview. */
18
14
  select?: (value: boolean) => void;
19
- /** Fires on a click on the card surface or Enter / Space; the checkbox and the menu don't trigger it. */
15
+ /** Fires on a click on the card surface or Enter / Space; the checkbox and the actions don't trigger it. */
20
16
  activate?: () => void;
21
17
  };
22
18
  };
23
19
  /**
24
- * File-browser style tile: a full-bleed preview on top and a footer with title, optional meta line and a kebab menu. Selection
25
- * checkbox via `on.select`, whole-card activation via `on.activate`. For a drop-target highlight wrap it in `DropZone`.
20
+ * File-browser style tile: a full-bleed preview on top and a footer with title and optional meta line. `actions` sit at the end
21
+ * of the footer or over the preview's top-right corner. Selection checkbox via `on.select`, whole-card activation via
22
+ * `on.activate`. For a drop-target highlight wrap it in `DropZone`.
26
23
  *
27
24
  * ### CSS Custom Properties
28
25
  * | Property | Description | Default |
@@ -1,2 +1,2 @@
1
- export type { TileCardCheckboxVisibility, TileCardPreview } from './types';
1
+ export type { TileCardAction, TileCardActions, TileCardCheckboxVisibility, TileCardPreview } from './types';
2
2
  export { default as TileCard } from './cmp.tile-card.svelte';
@@ -1,3 +1,4 @@
1
+ import type { IconProp } from '../icon';
1
2
  import type { Snippet } from 'svelte';
2
3
  export type TileCardPreview = {
3
4
  image: string;
@@ -6,3 +7,22 @@ export type TileCardPreview = {
6
7
  poster?: string;
7
8
  } | Snippet;
8
9
  export type TileCardCheckboxVisibility = 'hover' | 'always';
10
+ /** One card action. `label` is the menu item text, or the accessible name and tooltip when it renders as a lone icon button. */
11
+ export type TileCardAction = {
12
+ label: string;
13
+ icon: IconProp;
14
+ onClick: () => void;
15
+ };
16
+ export type TileCardActions = {
17
+ items: TileCardAction[];
18
+ /**
19
+ * `'footer'` — at the end of the footer; `'top'` — over the top-right corner of the preview, revealed on hover, keyboard focus, while its menu is
20
+ * open and on touch screens. A card without a `preview` always uses `'footer'`.
21
+ * @default 'footer'
22
+ */
23
+ position?: 'footer' | 'top';
24
+ /** With `position: 'top'`, a single item renders as its own icon button instead of a one-item kebab menu. @default false */
25
+ singleAsButton?: boolean;
26
+ /** Accessible name of the kebab menu. @default localized "Actions" */
27
+ label?: string;
28
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@streamscloud/kit",
3
- "version": "0.78.1",
3
+ "version": "0.80.0",
4
4
  "author": "StreamsCloud",
5
5
  "repository": {
6
6
  "type": "git",