@streamscloud/kit 0.71.0 → 0.73.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 +1,3 @@
1
1
  export declare const preloadImage: (src: string, throwOnError?: boolean) => Promise<void>;
2
+ /** Decodes a loaded `<img>` once, so a drag ghost snapshotted right after includes the picture. A broken image is ignored. */
3
+ export declare const predecodeImage: (img: HTMLImageElement) => Promise<void>;
@@ -8,3 +8,12 @@ export const preloadImage = (src, throwOnError = false) => {
8
8
  img.src = src;
9
9
  });
10
10
  };
11
+ /** Decodes a loaded `<img>` once, so a drag ghost snapshotted right after includes the picture. A broken image is ignored. */
12
+ export const predecodeImage = async (img) => {
13
+ try {
14
+ await img.decode();
15
+ }
16
+ catch {
17
+ // a broken image has nothing to decode
18
+ }
19
+ };
@@ -8,7 +8,7 @@ import { ProportionalContainer } from '../proportional-container';
8
8
  import { SeekBar } from '../seek-bar';
9
9
  import VideoPlayer from './video-player.svelte';
10
10
  import IconImageOff from '@fluentui/svg-icons/icons/image_off_20_regular.svg?raw';
11
- const { items, aspectRatio = 'vertical', objectFit = 'contain', showSeekBar = true, duration, decoding = 'auto' } = $props();
11
+ const { items, aspectRatio = 'vertical', objectFit = 'contain', showSeekBar = true, duration, predecode = false } = $props();
12
12
  let currentIndex = $state(0);
13
13
  let currentTime = $state(0);
14
14
  let mediaDuration = $state(NaN);
@@ -63,7 +63,7 @@ const onCarouselIndexChanged = (index) => {
63
63
  active={true}
64
64
  on={{ timeUpdate: onPlayerTimeUpdate, durationChange: onPlayerDurationChange, activate: onPlayerActivate }} />
65
65
  {:else}
66
- <Image src={items[0].url} showStubOnError={true} decoding={decoding} />
66
+ <Image src={items[0].url} showStubOnError={true} predecode={predecode} />
67
67
  {/if}
68
68
  </div>
69
69
  {:else}
@@ -78,7 +78,7 @@ const onCarouselIndexChanged = (index) => {
78
78
  active={item === items[currentIndex]}
79
79
  on={{ timeUpdate: onPlayerTimeUpdate, durationChange: onPlayerDurationChange, activate: onPlayerActivate }} />
80
80
  {:else}
81
- <Image src={item.url} showStubOnError={true} decoding={decoding} />
81
+ <Image src={item.url} showStubOnError={true} predecode={predecode} />
82
82
  {/if}
83
83
  </div>
84
84
  {/snippet}
@@ -15,8 +15,8 @@ type Props = {
15
15
  showSeekBar?: boolean;
16
16
  /** Duration in seconds; rendered as a badge overlay at the bottom-right of the media. `≤ 0` / `undefined` renders nothing. */
17
17
  duration?: number;
18
- /** Forwarded to each `Image`. Pass `'sync'` when the card sits in a `DragSource`, or the first drag ghost comes out without the picture. @default 'auto' */
19
- decoding?: 'sync' | 'async' | 'auto';
18
+ /** Forwarded to each `Image`. Set it when the card sits in a `DragSource`, or the first drag ghost comes out without the picture. @default false */
19
+ predecode?: boolean;
20
20
  };
21
21
  /**
22
22
  * Media slot for `GridCard`. Renders an empty placeholder, a single `Image` / `VideoPlayer`, or
@@ -1,14 +1,18 @@
1
- <script lang="ts">import { Icon } from '../icon';
1
+ <script lang="ts">import { predecodeImage } from '../../core/utils';
2
+ import { Icon } from '../icon';
2
3
  import { calculateImageState } from './image-state';
3
4
  import IconImageOff from '@fluentui/svg-icons/icons/image_off_20_regular.svg?raw';
4
- let { src, alt = '', showStubOnError = false, decoding = 'auto', on, stub } = $props();
5
+ let { src, alt = '', showStubOnError = false, predecode = false, on, stub } = $props();
5
6
  let hasError = $state(false);
6
7
  const imageState = $derived(calculateImageState(src, hasError, showStubOnError));
7
8
  // A 0.01x density makes the intrinsic size 100× the natural one, so `contain` sizing always scales the picture
8
9
  // down to fit — small pictures upscale too — and the box hugs the picture, rounding its own corners.
9
10
  // `src` must stay unset: it would join srcset as the 1x candidate and win.
10
11
  const srcset = $derived(src ? `${src.replace(/\s/g, (char) => encodeURIComponent(char))} 0.01x` : undefined);
11
- const onLoad = () => {
12
+ const onLoad = (event) => {
13
+ if (predecode && event.currentTarget instanceof HTMLImageElement) {
14
+ void predecodeImage(event.currentTarget);
15
+ }
12
16
  hasError = false;
13
17
  on?.load?.(true);
14
18
  };
@@ -26,7 +30,6 @@ const onLoadError = () => {
26
30
  srcset={srcset}
27
31
  alt={alt || ''}
28
32
  draggable="false"
29
- decoding={decoding}
30
33
  onload={onLoad}
31
34
  onerror={onLoadError} />
32
35
  {/if}
@@ -5,8 +5,8 @@ type Props = {
5
5
  alt?: string;
6
6
  /** Show a placeholder stub instead of an error icon when image fails to load */
7
7
  showStubOnError?: boolean;
8
- /** `'sync'` paints the picture in the same frame as its box — needed when the element can be dragged, or the first drag ghost comes out empty. @default 'auto' */
9
- decoding?: 'sync' | 'async' | 'auto';
8
+ /** Decodes the picture once right after it loads. Set it when the element can be dragged, or the first drag ghost comes out empty. @default false */
9
+ predecode?: boolean;
10
10
  on?: {
11
11
  /** Fires after load attempt; `true` if successful, `false` on error */
12
12
  load?: (loadedSuccessfully: boolean) => void;
@@ -1,13 +1,16 @@
1
- <script lang="ts">import LayoutControlButton from './layout-control-button.svelte';
1
+ <script lang="ts">import { Spinner } from '../spinner';
2
+ import LayoutControlButton from './layout-control-button.svelte';
2
3
  import { PageLayoutLocalization } from './page-layout-localization';
4
+ import { resolvePageLoading } from './page-loading';
3
5
  import IconDismiss from '@fluentui/svg-icons/icons/dismiss_20_regular.svg?raw';
4
6
  import IconWindow from '@fluentui/svg-icons/icons/window_20_regular.svg?raw';
5
7
  import IconWindowMultiple from '@fluentui/svg-icons/icons/window_multiple_20_regular.svg?raw';
6
- const { children, title, actions, navigationToggle, editorControls, scrollable = false, inert = false } = $props();
8
+ const { children, title, actions, navigationToggle, editorControls, scrollable = false, inert = false, loading = false } = $props();
7
9
  const localization = new PageLayoutLocalization();
10
+ const { timeout: loadingTimeout, blocking: loadingBlocking } = $derived(resolvePageLoading(loading));
8
11
  </script>
9
12
 
10
- <div class="page-layout" class:page-layout--scrollable={scrollable} inert={inert}>
13
+ <div class="page-layout" class:page-layout--scrollable={scrollable} inert={inert || loadingBlocking}>
11
14
  <div class="page-layout__header">
12
15
  <div class="page-layout__title">
13
16
  {#if typeof title === 'function'}
@@ -50,6 +53,9 @@ const localization = new PageLayoutLocalization();
50
53
  <div class="page-layout__body">
51
54
  {@render children()}
52
55
  </div>
56
+ {#if loading}
57
+ <Spinner position="absolute-center" timeout={loadingTimeout} blocking={loadingBlocking} />
58
+ {/if}
53
59
  </div>
54
60
 
55
61
  <!--
@@ -63,6 +69,12 @@ outlined squares. Every entry takes `{ active?, onClick }`, and the group render
63
69
  navigation → panels → close. Set `scrollable` to let the whole page scroll as one document with
64
70
  sticky panel headers.
65
71
 
72
+ `loading` (`true` or `{ timeout, blocking }`) shows a delayed `Spinner` centered over the layout.
73
+ `blocking` makes the whole layout — header included — `inert` at once and dims it with the
74
+ `Spinner` overlay after the same `timeout`; `inert` alone blocks without a spinner. With
75
+ `scrollable` the layout itself scrolls, so the spinner and overlay stay at the top of the scroll.
76
+ A loading `PagePanel` inside a loading layout draws its own spinner and overlay on top.
77
+
66
78
  ### CSS Custom Properties
67
79
  | Property | Description | Default |
68
80
  |---|---|---|
@@ -84,6 +96,7 @@ sticky panel headers.
84
96
  flex-direction: column;
85
97
  height: 100%;
86
98
  max-height: 100%;
99
+ position: relative;
87
100
  background: var(--_page-layout--background);
88
101
  color: var(--_page-layout--color);
89
102
  }
@@ -1,4 +1,4 @@
1
- import type { EditorControls, PageLayoutAction } from './types';
1
+ import type { EditorControls, PageLayoutAction, PageLoading } from './types';
2
2
  import type { Snippet } from 'svelte';
3
3
  type Props = {
4
4
  children: Snippet;
@@ -11,6 +11,8 @@ type Props = {
11
11
  scrollable?: boolean;
12
12
  /** Sets `inert` on the whole layout — header and body stop taking focus, clicks and assistive-tech interaction. */
13
13
  inert?: boolean;
14
+ /** Shows a `Spinner` centered over the layout; `blocking` also makes the whole layout `inert` and dims it. @default false */
15
+ loading?: PageLoading;
14
16
  };
15
17
  /**
16
18
  * PageLayout — top-level window chrome. Renders a fixed-height header (title + actions + an optional
@@ -22,6 +24,12 @@ type Props = {
22
24
  * navigation → panels → close. Set `scrollable` to let the whole page scroll as one document with
23
25
  * sticky panel headers.
24
26
  *
27
+ * `loading` (`true` or `{ timeout, blocking }`) shows a delayed `Spinner` centered over the layout.
28
+ * `blocking` makes the whole layout — header included — `inert` at once and dims it with the
29
+ * `Spinner` overlay after the same `timeout`; `inert` alone blocks without a spinner. With
30
+ * `scrollable` the layout itself scrolls, so the spinner and overlay stay at the top of the scroll.
31
+ * A loading `PagePanel` inside a loading layout draws its own spinner and overlay on top.
32
+ *
25
33
  * ### CSS Custom Properties
26
34
  * | Property | Description | Default |
27
35
  * |---|---|---|
@@ -1,13 +1,16 @@
1
1
  <script lang="ts">import { horizontalWheelScroll } from '../../core/actions';
2
2
  import { slideHorizontally, transitionDuration } from '../../core/transitions';
3
3
  import { Icon } from '../icon';
4
+ import { Spinner } from '../spinner';
4
5
  import PanelCollapseButton from './cmp.panel-collapse-button.svelte';
5
6
  import { PageLayoutLocalization } from './page-layout-localization';
7
+ import { resolvePageLoading } from './page-loading';
6
8
  import IconChevronLeft from '@fluentui/svg-icons/icons/chevron_left_20_regular.svg?raw';
7
9
  import IconChevronRight from '@fluentui/svg-icons/icons/chevron_right_20_regular.svg?raw';
8
10
  import { untrack } from 'svelte';
9
- const { panelState, children, id, header, footer, title, actionsLeft, actionsCenter, actionsRight, collapseButtons, hideHeader = false, balanceHeaderCenter = false, resizing = null, borders = [], on } = $props();
11
+ const { panelState, children, id, header, footer, title, actionsLeft, actionsCenter, actionsRight, collapseButtons, hideHeader = false, balanceHeaderCenter = false, resizing = null, borders = [], loading = false, on } = $props();
10
12
  const localization = new PageLayoutLocalization();
13
+ const { timeout: loadingTimeout, blocking: loadingBlocking } = $derived(resolvePageLoading(loading));
11
14
  const isFlex = untrack(() => !resizing);
12
15
  const HEADER_SCROLL_STEP = 160;
13
16
  let panelRef = $state(undefined);
@@ -137,7 +140,8 @@ $effect(() => {
137
140
  class:page-panel--border-top={borders.includes('top')}
138
141
  class:page-panel--border-right={borders.includes('right')}
139
142
  class:page-panel--border-bottom={borders.includes('bottom')}
140
- class:page-panel--border-left={borders.includes('left')}>
143
+ class:page-panel--border-left={borders.includes('left')}
144
+ inert={loadingBlocking}>
141
145
  {#if !hideHeader}
142
146
  <div class="page-panel__header" class:page-panel__header--balanced={balanceHeaderCenter}>
143
147
  {#if header}
@@ -239,6 +243,10 @@ $effect(() => {
239
243
  aria-valuenow={entry.width ?? 0}>
240
244
  </button>
241
245
  {/if}
246
+
247
+ {#if loading}
248
+ <Spinner position="absolute-center" timeout={loadingTimeout} blocking={loadingBlocking} />
249
+ {/if}
242
250
  </div>
243
251
  {/if}
244
252
 
@@ -250,6 +258,11 @@ instance, and configurable borders. Collapse uses the `slideHorizontally` transi
250
258
 
251
259
  Pass the same `panelState` instance to every panel + collapse button that should coordinate.
252
260
 
261
+ `loading` (`true` or `{ timeout, blocking }`) shows a delayed `Spinner` centered over the panel.
262
+ `blocking` makes the whole panel — header and footer included — `inert` at once and dims it with
263
+ the `Spinner` overlay after the same `timeout`. Inside a `scrollable` `PageLayout` the panel is as
264
+ tall as its content, so the spinner sits in the middle of the whole panel, not of the viewport.
265
+
253
266
  ### Header modes
254
267
  - `title` (string | Snippet) — renders in the left section; `actionsLeft` / `actionsRight` and
255
268
  `collapseButtons` are placed alongside.
@@ -1,5 +1,5 @@
1
1
  import type { PanelState } from './panel-state.svelte';
2
- import type { PanelBorder, PanelResizingSettings } from './types';
2
+ import type { PageLoading, PanelBorder, PanelResizingSettings } from './types';
3
3
  import type { Snippet } from 'svelte';
4
4
  type Props = {
5
5
  /** The shared `PanelState` instance this panel registers with. */
@@ -22,6 +22,8 @@ type Props = {
22
22
  /** Resize / collapse config. `null` ⇒ the panel is `flex: 1` (no resizing). @default null */
23
23
  resizing?: PanelResizingSettings | null;
24
24
  borders?: PanelBorder[];
25
+ /** Shows a `Spinner` centered over the panel; `blocking` also makes the whole panel `inert` and dims it. @default false */
26
+ loading?: PageLoading;
25
27
  on?: {
26
28
  collapse?: (collapsed: boolean) => void;
27
29
  };
@@ -33,6 +35,11 @@ type Props = {
33
35
  *
34
36
  * Pass the same `panelState` instance to every panel + collapse button that should coordinate.
35
37
  *
38
+ * `loading` (`true` or `{ timeout, blocking }`) shows a delayed `Spinner` centered over the panel.
39
+ * `blocking` makes the whole panel — header and footer included — `inert` at once and dims it with
40
+ * the `Spinner` overlay after the same `timeout`. Inside a `scrollable` `PageLayout` the panel is as
41
+ * tall as its content, so the spinner sits in the middle of the whole panel, not of the viewport.
42
+ *
36
43
  * ### Header modes
37
44
  * - `title` (string | Snippet) — renders in the left section; `actionsLeft` / `actionsRight` and
38
45
  * `collapseButtons` are placed alongside.
@@ -5,4 +5,4 @@ export { default as PagePanels } from './cmp.page-panels.svelte';
5
5
  export { default as PanelCollapseButton } from './cmp.panel-collapse-button.svelte';
6
6
  export { default as PanelContent } from './cmp.panel-content.svelte';
7
7
  export { PanelEntry, PanelState, SidebarsToggle } from './panel-state.svelte';
8
- export type { EditorControls, PageLayoutAction, PanelBorder, PanelResizingSettings } from './types';
8
+ export type { EditorControls, PageLayoutAction, PageLoading, PanelBorder, PanelResizingSettings } from './types';
@@ -0,0 +1,5 @@
1
+ import type { PageLoading } from './types';
2
+ export declare const resolvePageLoading: (loading: PageLoading) => {
3
+ timeout: number;
4
+ blocking: boolean;
5
+ };
@@ -0,0 +1,7 @@
1
+ const DEFAULT_LOADING_TIMEOUT = 500;
2
+ export const resolvePageLoading = (loading) => {
3
+ if (typeof loading === 'object') {
4
+ return { timeout: loading.timeout ?? DEFAULT_LOADING_TIMEOUT, blocking: loading.blocking === true };
5
+ }
6
+ return { timeout: DEFAULT_LOADING_TIMEOUT, blocking: false };
7
+ };
@@ -30,3 +30,11 @@ export type EditorControls = {
30
30
  panelsToggle: PageLayoutAction;
31
31
  close: PageLayoutAction;
32
32
  };
33
+ /**
34
+ * `PageLayout` / `PagePanel` loading state. `true` shows a non-blocking `Spinner` after 500ms. The object form sets the delay in ms (`timeout`, default
35
+ * 500) and, with `blocking`, makes the whole component `inert` at once and dims it with the `Spinner` overlay after the same delay.
36
+ */
37
+ export type PageLoading = boolean | {
38
+ timeout?: number;
39
+ blocking?: boolean;
40
+ };
@@ -1,11 +1,17 @@
1
- <script lang="ts">import { Checkbox } from '../checkbox';
1
+ <script lang="ts">import { predecodeImage } from '../../core/utils';
2
+ import { Checkbox } from '../checkbox';
2
3
  import { ToolbarIconButton } from '../page-toolbar';
3
4
  import { PlayIndicator } from '../play-indicator';
4
5
  import { Popover } 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, decoding = 'auto', menu, menuLabel, selected = false, checkboxVisibility = 'hover', on } = $props();
8
+ const { title, meta, preview, predecode = false, menu, menuLabel, selected = false, checkboxVisibility = 'hover', on } = $props();
8
9
  const localization = new TileCardLocalization();
10
+ const onPictureLoad = (event) => {
11
+ if (predecode && event.currentTarget instanceof HTMLImageElement) {
12
+ void predecodeImage(event.currentTarget);
13
+ }
14
+ };
9
15
  // Safari paints no frame for preload="metadata" until a seek target is given.
10
16
  const withFirstFrame = (src) => (src.includes('#') ? src : `${src}#t=0.001`);
11
17
  </script>
@@ -19,7 +25,7 @@ const withFirstFrame = (src) => (src.includes('#') ? src : `${src}#t=0.001`);
19
25
  {#if typeof preview === 'function'}
20
26
  {@render preview()}
21
27
  {:else if 'image' in preview}
22
- <img class="tile-card__picture" src={preview.image} alt="" decoding={decoding} />
28
+ <img class="tile-card__picture" src={preview.image} alt="" onload={onPictureLoad} />
23
29
  {:else}
24
30
  <video class="tile-card__picture" src={withFirstFrame(preview.video)} poster={preview.poster} preload="metadata" muted playsinline></video>
25
31
  <span class="tile-card__play"><PlayIndicator /></span>
@@ -6,8 +6,8 @@ type Props = {
6
6
  meta?: string;
7
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`. */
8
8
  preview?: TileCardPreview;
9
- /** Decoding of the `image` preview. Pass `'sync'` when the tile sits in a `DragSource`, or the first drag ghost comes out without the picture — the same goes for an `<img>` in a preview snippet. @default 'auto' */
10
- decoding?: 'sync' | 'async' | 'auto';
9
+ /** Decodes the `image` preview once right after it loads. Set it when the tile sits in a `DragSource`, or the first drag ghost comes out without the picture — an `<img>` in a preview snippet needs `predecodeImage` from its owner. @default false */
10
+ predecode?: boolean;
11
11
  /** `PopoverItem`s of the kebab menu at the end of the footer. */
12
12
  menu?: Snippet;
13
13
  /** Accessible name of the kebab menu. @default localized "Actions" */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@streamscloud/kit",
3
- "version": "0.71.0",
3
+ "version": "0.73.0",
4
4
  "author": "StreamsCloud",
5
5
  "repository": {
6
6
  "type": "git",