@streamscloud/kit 0.50.0 → 0.51.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.
@@ -3,19 +3,38 @@ import { Icon } from '../icon';
3
3
  import { Tooltip } from '../tooltip';
4
4
  import IconInfo from '@fluentui/svg-icons/icons/info_20_regular.svg?raw';
5
5
  let { label, hint, required = false, children } = $props();
6
+ let rootEl = $state.raw(undefined);
7
+ let labelRowEl = $state.raw(undefined);
8
+ let swallowLabelActivation = false;
6
9
  // Unhandled, the click activates the labelled field (a checkbox would toggle); cancelling it on interactive content would kill a link's navigation.
7
10
  const swallowClick = (event) => {
8
11
  event.stopPropagation();
12
+ // This click never reaches guardLabelActivation, so the flag would outlive the gesture and swallow a later keyboard-activated click.
13
+ swallowLabelActivation = false;
9
14
  if (DomHelper.isInsideInteractive(event.target)) {
10
15
  return;
11
16
  }
12
17
  event.preventDefault();
13
18
  };
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
+ };
24
+ const guardLabelActivation = (event) => {
25
+ const swallow = swallowLabelActivation;
26
+ swallowLabelActivation = false;
27
+ if (swallow && isLabelChrome(event.target)) {
28
+ event.preventDefault();
29
+ }
30
+ };
14
31
  </script>
15
32
 
16
- <label class="form-field">
33
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
34
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
35
+ <label bind:this={rootEl} class="form-field" onmousedown={rememberLabelActivation} onclick={guardLabelActivation}>
17
36
  {#if label}
18
- <span class="form-field__label">
37
+ <span bind:this={labelRowEl} class="form-field__label">
19
38
  {#if typeof label === 'string'}
20
39
  {label}
21
40
  {:else}
@@ -45,6 +64,8 @@ const swallowClick = (event) => {
45
64
  @component
46
65
  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.
47
66
 
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.
68
+
48
69
  `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.
49
70
 
50
71
  ### CSS Custom Properties
@@ -12,6 +12,8 @@ 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.
16
+ *
15
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.
16
18
  *
17
19
  * ### CSS Custom Properties
@@ -7,9 +7,9 @@ import { columnWidthStyle, createTableView } from './table-view.svelte';
7
7
  import IconReorderDotsHorizontal from '@fluentui/svg-icons/icons/re_order_dots_horizontal_20_regular.svg?raw';
8
8
  import { flip } from 'svelte/animate';
9
9
  import { dndzone } from 'svelte-dnd-action';
10
- let { model, showPlaceholder = false, showAlternativeView = false, on, alternativeView, placeholderRow, customNoItemsPlaceholder } = $props();
10
+ let { model, showAlternativeView = false, noItemsPlaceholder, on, alternativeView, customNoItemsPlaceholder } = $props();
11
11
  const localization = new TableLocalization();
12
- const view = createTableView(() => model, () => on);
12
+ const view = createTableView(() => model, () => on, () => noItemsPlaceholder);
13
13
  const flipDurationMs = 200;
14
14
  let tableRef = $state.raw(null);
15
15
  let dndItems = $state.raw(null);
@@ -51,18 +51,20 @@ const transformDraggedElement = (draggedRow) => {
51
51
  };
52
52
  </script>
53
53
 
54
+ {#snippet noItemsContent()}
55
+ {#if customNoItemsPlaceholder}
56
+ {@render customNoItemsPlaceholder()}
57
+ {:else}
58
+ <EmptyState graphic="zero-content" title={localization.noItemsFoundTitle} description={localization.noItemsFoundBody} />
59
+ {/if}
60
+ {/snippet}
61
+
54
62
  <div
55
63
  class="table-container table-container--{model.styles?.size ?? 'md'} table-container--{model.styles?.variant ?? 'plain'}"
56
64
  class:table-container--zebra={model.styles?.zebra === true}>
57
65
  {#if alternativeView && showAlternativeView}
58
- {#if showPlaceholder || view.showNoItemsPlaceholder}
59
- <div class="alternative-view__placeholder">
60
- {#if showPlaceholder}
61
- {@render placeholderRow?.()}
62
- {:else if customNoItemsPlaceholder}
63
- {@render customNoItemsPlaceholder()}
64
- {/if}
65
- </div>
66
+ {#if view.showNoItemsPlaceholder}
67
+ <div class="alternative-view__placeholder">{@render noItemsContent()}</div>
66
68
  {:else}
67
69
  {@render alternativeView()}
68
70
  {/if}
@@ -91,18 +93,10 @@ const transformDraggedElement = (draggedRow) => {
91
93
  }}
92
94
  onconsider={handleConsider}
93
95
  onfinalize={handleFinalize}>
94
- {#if showPlaceholder || view.showNoItemsPlaceholder}
96
+ {#if view.showNoItemsPlaceholder}
95
97
  <tr class="table__tr table__tr--placeholder">
96
98
  <td colspan={visibleColspan}>
97
- <div class="table__placeholder">
98
- {#if showPlaceholder}
99
- {@render placeholderRow?.()}
100
- {:else if customNoItemsPlaceholder}
101
- {@render customNoItemsPlaceholder()}
102
- {:else}
103
- <EmptyState graphic="zero-content" title={localization.noItemsFoundTitle} description={localization.noItemsFoundBody} />
104
- {/if}
105
- </div>
99
+ <div class="table__placeholder">{@render noItemsContent()}</div>
106
100
  </td>
107
101
  </tr>
108
102
  {:else}
@@ -5,8 +5,14 @@ declare function $$render<T extends {
5
5
  }>(): {
6
6
  props: {
7
7
  model: TableModel<T>;
8
- showPlaceholder?: boolean;
9
8
  showAlternativeView?: boolean;
9
+ /**
10
+ * When an empty list turns into the no-items placeholder. A number delays it by that many ms (anti-flicker while the first page is in flight); a boolean
11
+ * decides outright — `true` shows it as soon as the list is empty, `false` never shows it (pair it with your own loading / empty UI). Rows always win:
12
+ * the placeholder is never rendered over a non-empty list.
13
+ * @default 700
14
+ */
15
+ noItemsPlaceholder?: number | boolean;
10
16
  on?: {
11
17
  selectionChanged?: (e: T[]) => void;
12
18
  itemsSelected?: (e: T[]) => void;
@@ -14,7 +20,6 @@ declare function $$render<T extends {
14
20
  itemsReordered?: (e: T[]) => void;
15
21
  };
16
22
  alternativeView?: Snippet;
17
- placeholderRow?: Snippet;
18
23
  customNoItemsPlaceholder?: Snippet;
19
24
  };
20
25
  exports: {};
@@ -3,9 +3,9 @@ import { EmptyState } from '../empty-state';
3
3
  import { TableCell, TableHideableCell } from './table-cells';
4
4
  import { TableLocalization } from './table-localization';
5
5
  import { columnWidthStyle, createTableView } from './table-view.svelte';
6
- let { model, showPlaceholder = false, showAlternativeView = false, grouping, on, alternativeView, placeholderRow, customNoItemsPlaceholder } = $props();
6
+ let { model, showAlternativeView = false, noItemsPlaceholder, grouping, on, alternativeView, customNoItemsPlaceholder } = $props();
7
7
  const localization = new TableLocalization();
8
- const view = createTableView(() => model, () => on);
8
+ const view = createTableView(() => model, () => on, () => noItemsPlaceholder);
9
9
  const displayRows = $derived.by(() => {
10
10
  const items = model.items;
11
11
  const grp = grouping;
@@ -37,18 +37,20 @@ const displayRows = $derived.by(() => {
37
37
  const visibleColspan = $derived(model.columns.filter((column) => !column.hidden).length);
38
38
  </script>
39
39
 
40
+ {#snippet noItemsContent()}
41
+ {#if customNoItemsPlaceholder}
42
+ {@render customNoItemsPlaceholder()}
43
+ {:else}
44
+ <EmptyState graphic="zero-content" title={localization.noItemsFoundTitle} description={localization.noItemsFoundBody} />
45
+ {/if}
46
+ {/snippet}
47
+
40
48
  <div
41
49
  class="table-container table-container--{model.styles?.size ?? 'md'} table-container--{model.styles?.variant ?? 'plain'}"
42
50
  class:table-container--zebra={model.styles?.zebra === true}>
43
51
  {#if alternativeView && showAlternativeView}
44
- {#if showPlaceholder || view.showNoItemsPlaceholder}
45
- <div class="alternative-view__placeholder">
46
- {#if showPlaceholder}
47
- {@render placeholderRow?.()}
48
- {:else if customNoItemsPlaceholder}
49
- {@render customNoItemsPlaceholder()}
50
- {/if}
51
- </div>
52
+ {#if view.showNoItemsPlaceholder}
53
+ <div class="alternative-view__placeholder">{@render noItemsContent()}</div>
52
54
  {:else}
53
55
  {@render alternativeView()}
54
56
  {/if}
@@ -66,18 +68,10 @@ const visibleColspan = $derived(model.columns.filter((column) => !column.hidden)
66
68
  </tr>
67
69
  </thead>
68
70
  <tbody>
69
- {#if showPlaceholder || view.showNoItemsPlaceholder}
71
+ {#if view.showNoItemsPlaceholder}
70
72
  <tr class="table__tr table__tr--placeholder">
71
73
  <td colspan={visibleColspan}>
72
- <div class="table__placeholder">
73
- {#if showPlaceholder}
74
- {@render placeholderRow?.()}
75
- {:else if customNoItemsPlaceholder}
76
- {@render customNoItemsPlaceholder()}
77
- {:else}
78
- <EmptyState graphic="zero-content" title={localization.noItemsFoundTitle} description={localization.noItemsFoundBody} />
79
- {/if}
80
- </div>
74
+ <div class="table__placeholder">{@render noItemsContent()}</div>
81
75
  </td>
82
76
  </tr>
83
77
  {:else}
@@ -5,8 +5,14 @@ declare function $$render<T extends {
5
5
  }>(): {
6
6
  props: {
7
7
  model: TableModel<T>;
8
- showPlaceholder?: boolean;
9
8
  showAlternativeView?: boolean;
9
+ /**
10
+ * When an empty list turns into the no-items placeholder. A number delays it by that many ms (anti-flicker while the first page is in flight); a boolean
11
+ * decides outright — `true` shows it as soon as the list is empty, `false` never shows it (pair it with your own loading / empty UI). Rows always win:
12
+ * the placeholder is never rendered over a non-empty list.
13
+ * @default 700
14
+ */
15
+ noItemsPlaceholder?: number | boolean;
10
16
  /** Group consecutive (already-sorted) rows under section headers. `key` returns a group id per item (`null` = ungrouped); a header row renders wherever the key changes between neighbours. */
11
17
  grouping?: {
12
18
  key: (item: T) => string | null;
@@ -26,7 +32,6 @@ declare function $$render<T extends {
26
32
  itemsReordered?: (e: T[]) => void;
27
33
  };
28
34
  alternativeView?: Snippet;
29
- placeholderRow?: Snippet;
30
35
  customNoItemsPlaceholder?: Snippet;
31
36
  };
32
37
  exports: {};
@@ -8,8 +8,8 @@ type SelectionCallbacks<T> = {
8
8
  };
9
9
  /** Inline width declarations for a column's `th` / `td`: the preferred width plus, when set, the floor the browser must honour. */
10
10
  export declare const columnWidthStyle: <T extends WithId>(column: ResolvedColumn<T>) => string;
11
- /** Shared presentation wiring for the table views: selection-event subscriptions + the debounced no-items placeholder. */
12
- export declare const createTableView: <T extends WithId>(model: () => TableModel<T>, on: () => SelectionCallbacks<T> | undefined) => {
11
+ /** Shared presentation wiring for the table views: selection-event subscriptions + the no-items placeholder decision (`number` = delay in ms, boolean = explicit). */
12
+ export declare const createTableView: <T extends WithId>(model: () => TableModel<T>, on: () => SelectionCallbacks<T> | undefined, noItemsPlaceholder?: () => number | boolean | undefined) => {
13
13
  readonly showNoItemsPlaceholder: boolean;
14
14
  };
15
15
  export {};
@@ -4,8 +4,9 @@ export const columnWidthStyle = (column) => {
4
4
  const width = column.cssWidth ? `width: ${column.cssWidth};` : 'width: auto;';
5
5
  return column.minCssWidth ? `${width} min-width: ${column.minCssWidth};` : width;
6
6
  };
7
- /** Shared presentation wiring for the table views: selection-event subscriptions + the debounced no-items placeholder. */
8
- export const createTableView = (model, on) => {
7
+ const NO_ITEMS_PLACEHOLDER_DELAY_MS = 700;
8
+ /** Shared presentation wiring for the table views: selection-event subscriptions + the no-items placeholder decision (`number` = delay in ms, boolean = explicit). */
9
+ export const createTableView = (model, on, noItemsPlaceholder = () => undefined) => {
9
10
  let showNoItemsPlaceholder = $state(false);
10
11
  let timeout = 0;
11
12
  $effect(() => untrack(() => {
@@ -18,14 +19,20 @@ export const createTableView = (model, on) => {
18
19
  return () => subscriptions.forEach((unsubscribe) => unsubscribe());
19
20
  }));
20
21
  $effect(() => {
22
+ const decision = noItemsPlaceholder() ?? NO_ITEMS_PLACEHOLDER_DELAY_MS;
21
23
  if (model().items.length) {
22
24
  clearTimeout(timeout);
23
25
  showNoItemsPlaceholder = false;
24
26
  return;
25
27
  }
28
+ if (typeof decision === 'boolean') {
29
+ clearTimeout(timeout);
30
+ showNoItemsPlaceholder = decision;
31
+ return;
32
+ }
26
33
  timeout = window.setTimeout(() => {
27
34
  showNoItemsPlaceholder = !model().items.length;
28
- }, 700);
35
+ }, decision);
29
36
  return () => clearTimeout(timeout);
30
37
  });
31
38
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@streamscloud/kit",
3
- "version": "0.50.0",
3
+ "version": "0.51.1",
4
4
  "author": "StreamsCloud",
5
5
  "repository": {
6
6
  "type": "git",