@streamscloud/kit 0.53.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.
@@ -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
  *
@@ -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>
@@ -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.53.0",
3
+ "version": "0.54.0",
4
4
  "author": "StreamsCloud",
5
5
  "repository": {
6
6
  "type": "git",