@streamscloud/kit 0.52.0 → 0.54.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.
@@ -1,9 +1,9 @@
1
1
  <script lang="ts">import { BreadcrumbLocalization } from './breadcrumb-localization';
2
- let { items, separator = '/' } = $props();
2
+ let { items, separator = '/', underline = 'hover' } = $props();
3
3
  const localization = new BreadcrumbLocalization();
4
4
  </script>
5
5
 
6
- <div class="breadcrumb" role="navigation" aria-label={localization.navLabel}>
6
+ <div class="breadcrumb breadcrumb--underline-{underline}" role="navigation" aria-label={localization.navLabel}>
7
7
  {#each items as item, i (item.label + i)}
8
8
  {#if i > 0}<span class="breadcrumb__separator" aria-hidden="true">{separator}</span>{/if}
9
9
  {#if i === items.length - 1}
@@ -26,7 +26,8 @@ A horizontal trail of crumbs ending at the current page. Each non-last item rend
26
26
  - `<button>` styled like an anchor when only `item.on.click` is set (callback-only mode),
27
27
  - plain text `<span>` when neither is set.
28
28
 
29
- The last item is always plain text with `aria-current="page"`.
29
+ The last item is always plain text with `aria-current="page"`. `underline` decides whether the crumb
30
+ links show their underline on hover only (default), always, or never.
30
31
 
31
32
  ### CSS Custom Properties
32
33
  | Property | Description | Default |
@@ -46,6 +47,8 @@ The last item is always plain text with `aria-current="page"`.
46
47
  --_breadcrumb--link-color: var(--sc-kit--breadcrumb--link--color, var(--sc-kit--color--text--secondary));
47
48
  --_breadcrumb--link-color-hover: var(--sc-kit--breadcrumb--link--color--hover, var(--sc-kit--color--accent));
48
49
  --_breadcrumb--current-color: var(--sc-kit--breadcrumb--current--color, var(--sc-kit--color--text--primary));
50
+ --_breadcrumb--link-text-decoration: none;
51
+ --_breadcrumb--link-text-decoration-hover: underline;
49
52
  display: inline-flex;
50
53
  align-items: center;
51
54
  flex-wrap: wrap;
@@ -54,14 +57,20 @@ The last item is always plain text with `aria-current="page"`.
54
57
  line-height: var(--sc-kit--leading--tight);
55
58
  color: var(--_breadcrumb--color);
56
59
  }
60
+ .breadcrumb--underline-always {
61
+ --_breadcrumb--link-text-decoration: underline;
62
+ }
63
+ .breadcrumb--underline-never {
64
+ --_breadcrumb--link-text-decoration-hover: none;
65
+ }
57
66
  .breadcrumb__link {
58
67
  color: var(--_breadcrumb--link-color);
59
- text-decoration: none;
68
+ text-decoration: var(--_breadcrumb--link-text-decoration);
60
69
  transition: color var(--sc-kit--duration--base) var(--sc-kit--ease--default);
61
70
  }
62
71
  .breadcrumb__link:hover {
63
72
  color: var(--_breadcrumb--link-color-hover);
64
- text-decoration: underline;
73
+ text-decoration: var(--_breadcrumb--link-text-decoration-hover);
65
74
  }
66
75
  .breadcrumb__link:focus-visible {
67
76
  outline: 2px solid var(--sc-kit--color--border--focus);
@@ -3,6 +3,8 @@ type Props = {
3
3
  items: BreadcrumbItem[];
4
4
  /** Separator character between crumbs. @default '/' */
5
5
  separator?: string;
6
+ /** When the crumb links show their underline: only on hover, permanently, or not at all. @default 'hover' */
7
+ underline?: 'hover' | 'always' | 'never';
6
8
  };
7
9
  /**
8
10
  * A horizontal trail of crumbs ending at the current page. Each non-last item renders as:
@@ -11,7 +13,8 @@ type Props = {
11
13
  * - `<button>` styled like an anchor when only `item.on.click` is set (callback-only mode),
12
14
  * - plain text `<span>` when neither is set.
13
15
  *
14
- * The last item is always plain text with `aria-current="page"`.
16
+ * The last item is always plain text with `aria-current="page"`. `underline` decides whether the crumb
17
+ * links show their underline on hover only (default), always, or never.
15
18
  *
16
19
  * ### CSS Custom Properties
17
20
  * | Property | Description | Default |
@@ -156,7 +156,7 @@ $effect(() => {
156
156
  {/if}
157
157
 
158
158
  {#if open}
159
- <!-- preventDefault cancels FormField's <label> activation — a dead-space click inside the panel would forward to the trigger and close the calendar. -->
159
+ <!-- preventDefault cancels an ancestor <label>'s activation — a dead-space click inside the panel would forward to the trigger and close the calendar. FormField guards its own; a consumer's plain <label> does not. -->
160
160
  <div bind:this={panelEl} class="date-picker__panel" role="presentation" onclick={(e) => e.preventDefault()} onkeydown={() => undefined}>
161
161
  <DatePickerCalendar
162
162
  selectedDate={selectedDate}
@@ -28,6 +28,8 @@ FormFieldValidatable — convenience wrapper that composes `<FormField>` and `<V
28
28
  </FormFieldValidatable>
29
29
  ```
30
30
 
31
+ The snippet takes a second `validating: boolean` argument — true from the value change until the validator has caught up, debounce window included. See `Validatable` for what it drives.
32
+
31
33
  Identical visual output to:
32
34
  ```svelte
33
35
  <FormField label="Email" required>
@@ -27,8 +27,8 @@ declare function $$render<T extends Record<string, unknown>, K extends keyof T &
27
27
  change?: (value: T[K]) => void;
28
28
  blur?: () => void;
29
29
  };
30
- /** Receives the wired `FieldBinding<T[K]>` — spread or wire onto the kit input. */
31
- children: Snippet<[FieldBinding<T[K]>]>;
30
+ /** Receives the wired `FieldBinding<T[K]>` — spread or wire onto the kit input — plus the `validating` flag. */
31
+ children: Snippet<[FieldBinding<T[K]>, boolean]>;
32
32
  };
33
33
  exports: {};
34
34
  bindings: "";
@@ -61,6 +61,8 @@ interface $$IsomorphicComponent {
61
61
  * </FormFieldValidatable>
62
62
  * ```
63
63
  *
64
+ * The snippet takes a second `validating: boolean` argument — true from the value change until the validator has caught up, debounce window included. See `Validatable` for what it drives.
65
+ *
64
66
  * Identical visual output to:
65
67
  * ```svelte
66
68
  * <FormField label="Email" required>
@@ -1,10 +1,14 @@
1
+ <script lang="ts" module>"use strict";
2
+ // HTML's interactive-content list, verbatim: exactly what makes the browser skip label activation. A wider selector (role, tabindex) skips the guard on clicks the browser still forwards.
3
+ const INTERACTIVE_CONTENT = 'a[href], audio[controls], button, details, embed, iframe, img[usemap], input:not([type="hidden"]), label, select, textarea, video[controls]';
4
+ </script>
5
+
1
6
  <script lang="ts">import { DomHelper } from '../../core/utils';
2
7
  import { Icon } from '../icon';
3
8
  import { Tooltip } from '../tooltip';
4
9
  import IconInfo from '@fluentui/svg-icons/icons/info_20_regular.svg?raw';
5
10
  let { label, hint, required = false, children } = $props();
6
11
  let rootEl = $state.raw(undefined);
7
- let labelRowEl = $state.raw(undefined);
8
12
  let swallowLabelActivation = false;
9
13
  // Unhandled, the click activates the labelled field (a checkbox would toggle); cancelling it on interactive content would kill a link's navigation.
10
14
  const swallowClick = (event) => {
@@ -16,15 +20,40 @@ const swallowClick = (event) => {
16
20
  }
17
21
  event.preventDefault();
18
22
  };
19
- const isLabelChrome = (target) => target instanceof Node && (target === rootEl || !!labelRowEl?.contains(target));
20
- // Read at mousedown: by click time the popup field has already dismissed itself and the forwarded activation would reopen it.
21
- const rememberLabelActivation = (event) => {
22
- swallowLabelActivation = isLabelChrome(event.target) && !!rootEl?.querySelector('[aria-haspopup][aria-expanded="true"]');
23
+ const forwardsLabelActivation = (target) => {
24
+ if (!(target instanceof Element)) {
25
+ return false;
26
+ }
27
+ const interactive = target.closest(INTERACTIVE_CONTENT);
28
+ return !interactive || interactive === rootEl;
23
29
  };
30
+ // Registered at mount, so it precedes the capture-phase dismissal a Popover registers on open: by click time the popup has closed and the forwarded activation would reopen it.
31
+ $effect(() => {
32
+ const armLabelActivation = (event) => {
33
+ swallowLabelActivation = false;
34
+ if (event.button !== 0 || !rootEl || !(event.target instanceof Node) || !rootEl.contains(event.target)) {
35
+ return;
36
+ }
37
+ swallowLabelActivation = !!rootEl.querySelector('[aria-haspopup][aria-expanded="true"]');
38
+ };
39
+ // A keyboard-activated click brings no pointerdown of its own and would otherwise inherit the flag from a gesture that produced no click. Only the two keys that synthesize one: any key would also disarm a pointer gesture that is still in flight.
40
+ const disarmLabelActivation = (event) => {
41
+ if (event.key !== 'Enter' && event.key !== ' ') {
42
+ return;
43
+ }
44
+ swallowLabelActivation = false;
45
+ };
46
+ window.addEventListener('pointerdown', armLabelActivation, true);
47
+ window.addEventListener('keydown', disarmLabelActivation, true);
48
+ return () => {
49
+ window.removeEventListener('pointerdown', armLabelActivation, true);
50
+ window.removeEventListener('keydown', disarmLabelActivation, true);
51
+ };
52
+ });
24
53
  const guardLabelActivation = (event) => {
25
54
  const swallow = swallowLabelActivation;
26
55
  swallowLabelActivation = false;
27
- if (swallow && isLabelChrome(event.target)) {
56
+ if (swallow && forwardsLabelActivation(event.target)) {
28
57
  event.preventDefault();
29
58
  }
30
59
  };
@@ -32,9 +61,9 @@ const guardLabelActivation = (event) => {
32
61
 
33
62
  <!-- svelte-ignore a11y_click_events_have_key_events -->
34
63
  <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
35
- <label bind:this={rootEl} class="form-field" onmousedown={rememberLabelActivation} onclick={guardLabelActivation}>
64
+ <label bind:this={rootEl} class="form-field" onclick={guardLabelActivation}>
36
65
  {#if label}
37
- <span bind:this={labelRowEl} class="form-field__label">
66
+ <span class="form-field__label">
38
67
  {#if typeof label === 'string'}
39
68
  {label}
40
69
  {:else}
@@ -64,7 +93,7 @@ const guardLabelActivation = (event) => {
64
93
  @component
65
94
  FormField — pure layout shell: optional label (string or snippet) above an input slot. The root is a `<label>` element, so the first labelable form control inside is automatically associated via HTML's implicit-association rule — no id wiring required. Does NOT render errors or helper text; pair with `Validatable` for that.
66
95
 
67
- Clicking the label opens a popup field inside it (select, date picker) but never re-opens one that is already expanded — that click dismisses it, exactly like a click on empty space.
96
+ Clicking the label opens a popup field inside it (select, date picker, color picker) but never re-opens one that is already expanded — that click dismisses it, exactly like a click on empty space. The same guard keeps a drag that starts inside an expanded panel from closing it, so a color picker's gradient and its hue / alpha bars stay usable when the pointer leaves the panel. A nested `<label>` — a `Checkbox` sitting next to the popup field — keeps its own activation either way. HTML forbids a `<label>` inside a `<label>`, so that pairing is tolerated rather than recommended: give the `Checkbox` its own row outside the `FormField` when you can.
68
97
 
69
98
  `hint` pins an affordance to the trailing edge of the label row — a string gives the standard info icon with a tooltip, a snippet takes over completely (button, link, badge). It rides on the label row, so it renders only when `label` is set. Clicks inside the hint are swallowed, so an interactive hint never activates the labelled field.
70
99
 
@@ -12,7 +12,7 @@ type Props = {
12
12
  /**
13
13
  * FormField — pure layout shell: optional label (string or snippet) above an input slot. The root is a `<label>` element, so the first labelable form control inside is automatically associated via HTML's implicit-association rule — no id wiring required. Does NOT render errors or helper text; pair with `Validatable` for that.
14
14
  *
15
- * Clicking the label opens a popup field inside it (select, date picker) but never re-opens one that is already expanded — that click dismisses it, exactly like a click on empty space.
15
+ * Clicking the label opens a popup field inside it (select, date picker, color picker) but never re-opens one that is already expanded — that click dismisses it, exactly like a click on empty space. The same guard keeps a drag that starts inside an expanded panel from closing it, so a color picker's gradient and its hue / alpha bars stay usable when the pointer leaves the panel. A nested `<label>` — a `Checkbox` sitting next to the popup field — keeps its own activation either way. HTML forbids a `<label>` inside a `<label>`, so that pairing is tolerated rather than recommended: give the `Checkbox` its own row outside the `FormField` when you can.
16
16
  *
17
17
  * `hint` pins an affordance to the trailing edge of the label row — a string gives the standard info icon with a tooltip, a snippet takes over completely (button, link, badge). It rides on the label row, so it renders only when `label` is set. Clicks inside the hint are swallowed, so an interactive hint never activates the labelled field.
18
18
  *
@@ -1,4 +1,4 @@
1
- <script lang="ts">let { href, variant = 'default', external = false, on, children } = $props();
1
+ <script lang="ts">let { href, variant = 'default', underline = 'hover', external = false, on, children } = $props();
2
2
  const isAnchor = $derived(href !== undefined);
3
3
  const target = $derived(isAnchor && external ? '_blank' : undefined);
4
4
  const rel = $derived(isAnchor && external ? 'noopener noreferrer' : undefined);
@@ -6,11 +6,11 @@ export {};
6
6
  </script>
7
7
 
8
8
  {#if isAnchor}
9
- <a class="link link--{variant}" href={href} target={target} rel={rel} onclick={(e) => on?.click?.(e)}>
9
+ <a class="link link--{variant} link--underline-{underline}" href={href} target={target} rel={rel} onclick={(e) => on?.click?.(e)}>
10
10
  {@render children()}
11
11
  </a>
12
12
  {:else}
13
- <button type="button" class="link link--{variant}" onclick={(e) => on?.click?.(e)}>
13
+ <button type="button" class="link link--{variant} link--underline-{underline}" onclick={(e) => on?.click?.(e)}>
14
14
  {@render children()}
15
15
  </button>
16
16
  {/if}
@@ -19,8 +19,8 @@ export {};
19
19
  @component
20
20
  A semantic link with three color variants. `default` reads as the standard accent link; `subtle`
21
21
  sits inline in body text with a secondary tint that picks up on hover; `danger` is for destructive
22
- navigation hints (e.g. "delete this account"). All variants animate color on hover and gain an
23
- underline.
22
+ navigation hints (e.g. "delete this account"). All variants animate color on hover; `underline`
23
+ decides whether the underline shows on hover only (default), always, or never.
24
24
 
25
25
  Renders as an `<a>` when `href` is set, otherwise as `<button type="button">` styled identically —
26
26
  useful for callback-only actions that read as inline links (e.g. "edit this", "show more"). The
@@ -40,30 +40,38 @@ useful for callback-only actions that read as inline links (e.g. "edit this", "s
40
40
  --_link--color-subtle-hover: var(--sc-kit--link--color--hover, var(--sc-kit--color--text--primary));
41
41
  --_link--color-danger: var(--sc-kit--link--color, var(--sc-kit--color--danger));
42
42
  --_link--color-danger-hover: var(--sc-kit--link--color--hover, var(--sc-kit--color--danger--hover));
43
- text-decoration: none;
43
+ --_link--text-decoration: none;
44
+ --_link--text-decoration-hover: underline;
45
+ text-decoration: var(--_link--text-decoration);
44
46
  cursor: pointer;
45
47
  transition: color var(--sc-kit--duration--base) var(--sc-kit--ease--default);
46
48
  }
49
+ .link:hover {
50
+ text-decoration: var(--_link--text-decoration-hover);
51
+ }
52
+ .link--underline-always {
53
+ --_link--text-decoration: underline;
54
+ }
55
+ .link--underline-never {
56
+ --_link--text-decoration-hover: none;
57
+ }
47
58
  .link--default {
48
59
  color: var(--_link--color-default);
49
60
  }
50
61
  .link--default:hover {
51
62
  color: var(--_link--color-default-hover);
52
- text-decoration: underline;
53
63
  }
54
64
  .link--subtle {
55
65
  color: var(--_link--color-subtle);
56
66
  }
57
67
  .link--subtle:hover {
58
68
  color: var(--_link--color-subtle-hover);
59
- text-decoration: underline;
60
69
  }
61
70
  .link--danger {
62
71
  color: var(--_link--color-danger);
63
72
  }
64
73
  .link--danger:hover {
65
74
  color: var(--_link--color-danger-hover);
66
- text-decoration: underline;
67
75
  }
68
76
  .link:focus-visible {
69
77
  outline: 2px solid var(--sc-kit--color--border--focus);
@@ -5,6 +5,8 @@ type Props = {
5
5
  href?: string;
6
6
  /** Visual variant. @default 'default' */
7
7
  variant?: LinkVariant;
8
+ /** When the underline shows: only on hover, permanently, or not at all. @default 'hover' */
9
+ underline?: 'hover' | 'always' | 'never';
8
10
  /** External link — opens in a new tab and adds `rel="noopener noreferrer"`. Ignored in button mode. */
9
11
  external?: boolean;
10
12
  on?: {
@@ -15,8 +17,8 @@ type Props = {
15
17
  /**
16
18
  * A semantic link with three color variants. `default` reads as the standard accent link; `subtle`
17
19
  * sits inline in body text with a secondary tint that picks up on hover; `danger` is for destructive
18
- * navigation hints (e.g. "delete this account"). All variants animate color on hover and gain an
19
- * underline.
20
+ * navigation hints (e.g. "delete this account"). All variants animate color on hover; `underline`
21
+ * decides whether the underline shows on hover only (default), always, or never.
20
22
  *
21
23
  * Renders as an `<a>` when `href` is set, otherwise as `<button type="button">` styled identically —
22
24
  * useful for callback-only actions that read as inline links (e.g. "edit this", "show more"). The
@@ -120,11 +120,14 @@ $effect(() => {
120
120
  id={listboxId}
121
121
  tabindex="-1"
122
122
  onclick={(e) => {
123
- // preventDefault cancels FormField's <label> activation — stopPropagation alone doesn't.
123
+ // preventDefault cancels an ancestor <label>'s activation — stopPropagation alone doesn't. FormField guards its own; a consumer's plain <label> does not.
124
124
  e.preventDefault();
125
125
  e.stopPropagation();
126
126
  }}
127
- onmousedown={(e) => e.stopPropagation()}
127
+ onmousedown={(e) => {
128
+ // Keeps the trigger root's mousedown preventDefault (select-core) off panel presses: without it a nested input in the panel never takes the caret and text selection dies.
129
+ e.stopPropagation();
130
+ }}
128
131
  onkeydown={() => undefined}>
129
132
  {#if headerSnippet}
130
133
  <div class="select-listbox__header">{@render headerSnippet()}</div>
@@ -2,7 +2,7 @@ export { default as Table } from './cmp.table.svelte';
2
2
  export { default as DraggableTable } from './cmp.table-draggable.svelte';
3
3
  export { default as TableColumnsManager } from './table-columns-manager/cmp.table-columns-manager.svelte';
4
4
  export { default as TableGroupActions } from './table-group-actions/cmp.table-group-actions.svelte';
5
- export type { ColumnsConfig, TableColumnSortDirection, TableColumnType, TableImageColumnFormat, TableItemAction, TableTextColumnFormat, TableOrderByState, PageQuery, IAnyTableColumn } from './types';
5
+ export type { ColumnsConfig, TableColumnSortDirection, TableColumnType, TableItemAction, TableOrderByState, PageQuery, IAnyTableColumn } from './types';
6
6
  export type { TableGroupAction } from './table-group-actions/types.svelte';
7
7
  export { TableModel } from './table-model.svelte';
8
8
  export { pickDirectionFromOrderByState, generatePagination } from './service';
@@ -14,6 +14,7 @@ const getDateValueForColumn = (column, item) => {
14
14
  };
15
15
  const dateTime = $derived(getDateValueForColumn(column, item));
16
16
  const by = $derived(column.byValueFactory ? column.byValueFactory(item) : null);
17
+ const underline = $derived(column.underline ?? 'hover');
17
18
  const getHref = (by) => {
18
19
  return column.hrefFactory ? column.hrefFactory(by) : null;
19
20
  };
@@ -26,7 +27,7 @@ const navigateToUser = (event, by) => {
26
27
  };
27
28
  </script>
28
29
 
29
- <span class="table-by-cell">
30
+ <span class="table-by-cell table-by-cell--underline-{underline}">
30
31
  {#if dateTime}
31
32
  {#if by}
32
33
  <span class="table-by-cell__by">
@@ -73,6 +74,8 @@ const navigateToUser = (event, by) => {
73
74
  <style>.table-by-cell {
74
75
  --_table-by-cell--font-size: var(--sc-kit--table--cell--font-size, 0.875em);
75
76
  --_table-by-cell--no-date--color: var(--sc-kit--color--text--muted);
77
+ --_table-by-cell--name-text-decoration: none;
78
+ --_table-by-cell--name-text-decoration-hover: underline;
76
79
  height: 3.125em;
77
80
  min-height: 3.125em;
78
81
  max-height: 3.125em;
@@ -113,12 +116,18 @@ const navigateToUser = (event, by) => {
113
116
  white-space: nowrap;
114
117
  overflow: hidden;
115
118
  }
119
+ .table-by-cell--underline-always {
120
+ --_table-by-cell--name-text-decoration: underline;
121
+ }
122
+ .table-by-cell--underline-never {
123
+ --_table-by-cell--name-text-decoration-hover: none;
124
+ }
116
125
  .table-by-cell__name--link {
117
126
  color: inherit;
118
- text-decoration: none;
127
+ text-decoration: var(--_table-by-cell--name-text-decoration);
119
128
  }
120
129
  .table-by-cell__name--link:hover {
121
- text-decoration: underline;
130
+ text-decoration: var(--_table-by-cell--name-text-decoration-hover);
122
131
  }
123
132
  .table-by-cell__by-image {
124
133
  display: flex;
@@ -65,6 +65,8 @@ export interface ITableByColumn<T> extends ITableColumn<T> {
65
65
  id: string;
66
66
  handle: string;
67
67
  }) => void) | null;
68
+ /** When the person-name link shows its underline: only on hover, permanently, or not at all. @default 'hover' */
69
+ underline?: 'hover' | 'always' | 'never';
68
70
  }
69
71
  export interface ITableButtonColumn<T> extends ITableColumn<T> {
70
72
  type: 'button';
@@ -83,7 +85,7 @@ export interface ITableIconColumn<T> extends ITableColumn<T> {
83
85
  export interface ITableImageColumn<T> extends ITableColumn<T> {
84
86
  type: 'image';
85
87
  valueFactory?: ((item: T) => string) | null;
86
- format?: TableImageColumnFormat;
88
+ format?: 'circle' | 'square-contain' | 'square-cover' | 'vertical-cover' | 'horizontal-cover';
87
89
  coverAspectRatio?: number;
88
90
  sizeCss?: string;
89
91
  }
@@ -98,13 +100,11 @@ export interface ITableSnippetColumn<T> extends ITableColumn<T> {
98
100
  }
99
101
  export interface ITableTextColumn<T> extends ITableColumn<T> {
100
102
  type: 'text';
101
- format?: TableTextColumnFormat | null;
103
+ format?: 'text' | 'html' | 'money' | 'date' | 'date-time' | null;
102
104
  /** @default 1 */
103
105
  maxLines?: number | 'single-line-no-trim' | null;
104
106
  valueFactory?: ((item: T) => string) | null;
105
107
  }
106
- export type TableTextColumnFormat = 'text' | 'html' | 'money' | 'date' | 'date-time';
107
- export type TableImageColumnFormat = 'circle' | 'square-contain' | 'square-cover' | 'vertical-cover' | 'horizontal-cover';
108
108
  export type TableItemAction<T> = {
109
109
  title: string;
110
110
  icon?: IconProp;
@@ -14,9 +14,14 @@ let pendingValidation = $state(false);
14
14
  let activeToken = 0;
15
15
  const runValidate = async () => {
16
16
  const myToken = ++activeToken;
17
- await handler.validateField(name);
18
- if (myToken === activeToken) {
19
- pendingValidation = false;
17
+ try {
18
+ await handler.validateField(name);
19
+ }
20
+ finally {
21
+ // A rejecting validator would otherwise strand pendingValidation, and the children snippet reads it as a never-ending `validating`.
22
+ if (myToken === activeToken) {
23
+ pendingValidation = false;
24
+ }
20
25
  }
21
26
  };
22
27
  const validate = $derived(debounceMs > 0 ? Utils.debounce(runValidate, debounceMs) : runValidate);
@@ -60,7 +65,7 @@ const field = $derived({
60
65
  </script>
61
66
 
62
67
  <FieldFrame error={showErrors ? handler.errors[name] : null} reserveErrorSpace={reserveErrorSpace}>
63
- {@render children(field)}
68
+ {@render children(field, pendingValidation)}
64
69
  </FieldFrame>
65
70
 
66
71
  <!--
@@ -79,10 +84,27 @@ layout; `reserveErrorSpace={false}` drops the reserve — the message overlays c
79
84
  </Validatable>
80
85
  ```
81
86
 
82
- The snippet argument has shape `{ name, value, error, on: { input, change, blur } }` — the
87
+ The first snippet argument has shape `{ name, value, error, on: { input, change, blur } }` — the
83
88
  minimal interface every kit input supports. Inputs whose primary value prop is not `value`
84
89
  (e.g. `Checkbox` uses `checked`) wire the field explicitly inside the snippet.
85
90
 
91
+ ### Validating flag
92
+ The second snippet argument is true from the value change until the validator has caught up — the
93
+ debounce window included, which `handler.isValidating` does not cover. Drives an inline async
94
+ indicator; everything else it might pair with is already public (`field.error` for the gated error
95
+ state, `handler.errors[name]` for the text, `handler.touched[name]`):
96
+
97
+ ```svelte
98
+ <Validatable {handler} name="handle" debounceMs={400}>
99
+ {#snippet children(field, validating)}
100
+ <HandleInput {...field} status={validating ? 'checking' : field.error ? 'taken' : handler.touched.handle ? 'available' : undefined} />
101
+ {/snippet}
102
+ </Validatable>
103
+ ```
104
+
105
+ With a `validateOn` that excludes `input` and `change`, the flag stays true for the whole typing
106
+ session — pair an async indicator with the default `validateOn` plus a `debounceMs`.
107
+
86
108
  ### Validation events
87
109
  `validateOn` accepts an array. Default `['input', 'change', 'blur']` validates on every event.
88
110
  Pass `['blur']` for blur-only validation, `['change', 'blur']` for the classic "validate on commit" pattern.
@@ -45,8 +45,11 @@ declare function $$render<T extends Record<string, unknown>, K extends keyof T &
45
45
  change?: (value: T[K]) => void;
46
46
  blur?: () => void;
47
47
  };
48
- /** Receives a wired `FieldBinding<T[K]>` to spread into a kit input. */
49
- children: Snippet<[FieldBinding<T[K]>]>;
48
+ /**
49
+ * Receives a wired `FieldBinding<T[K]>` to spread into a kit input, plus a `validating` flag —
50
+ * true from the value change until the validator has caught up, debounce window included.
51
+ */
52
+ children: Snippet<[FieldBinding<T[K]>, boolean]>;
50
53
  };
51
54
  exports: {};
52
55
  bindings: "";
@@ -82,10 +85,27 @@ interface $$IsomorphicComponent {
82
85
  * </Validatable>
83
86
  * ```
84
87
  *
85
- * The snippet argument has shape `{ name, value, error, on: { input, change, blur } }` — the
88
+ * The first snippet argument has shape `{ name, value, error, on: { input, change, blur } }` — the
86
89
  * minimal interface every kit input supports. Inputs whose primary value prop is not `value`
87
90
  * (e.g. `Checkbox` uses `checked`) wire the field explicitly inside the snippet.
88
91
  *
92
+ * ### Validating flag
93
+ * The second snippet argument is true from the value change until the validator has caught up — the
94
+ * debounce window included, which `handler.isValidating` does not cover. Drives an inline async
95
+ * indicator; everything else it might pair with is already public (`field.error` for the gated error
96
+ * state, `handler.errors[name]` for the text, `handler.touched[name]`):
97
+ *
98
+ * ```svelte
99
+ * <Validatable {handler} name="handle" debounceMs={400}>
100
+ * {#snippet children(field, validating)}
101
+ * <HandleInput {...field} status={validating ? 'checking' : field.error ? 'taken' : handler.touched.handle ? 'available' : undefined} />
102
+ * {/snippet}
103
+ * </Validatable>
104
+ * ```
105
+ *
106
+ * With a `validateOn` that excludes `input` and `change`, the flag stays true for the whole typing
107
+ * session — pair an async indicator with the default `validateOn` plus a `debounceMs`.
108
+ *
89
109
  * ### Validation events
90
110
  * `validateOn` accepts an array. Default `['input', 'change', 'blur']` validates on every event.
91
111
  * Pass `['blur']` for blur-only validation, `['change', 'blur']` for the classic "validate on commit" pattern.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@streamscloud/kit",
3
- "version": "0.52.0",
3
+ "version": "0.54.0",
4
4
  "author": "StreamsCloud",
5
5
  "repository": {
6
6
  "type": "git",