@streamscloud/kit 0.66.0 → 0.68.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.
@@ -91,6 +91,9 @@ isn't in `acceptedEvents` — useful for visible zones where silent inertia coul
91
91
  height: 100%;
92
92
  position: relative;
93
93
  }
94
+ .drop-zone--active, .drop-zone--drag-reject {
95
+ isolation: isolate;
96
+ }
94
97
  .drop-zone--active::before {
95
98
  content: "";
96
99
  position: absolute;
@@ -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 } = $props();
11
+ const { items, aspectRatio = 'vertical', objectFit = 'contain', showSeekBar = true, duration, decoding = 'auto' } = $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} />
66
+ <Image src={items[0].url} showStubOnError={true} decoding={decoding} />
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} />
81
+ <Image src={item.url} showStubOnError={true} decoding={decoding} />
82
82
  {/if}
83
83
  </div>
84
84
  {/snippet}
@@ -123,6 +123,7 @@ The play glyph of a playable item is a `PlayIndicator` — size and shadow are t
123
123
 
124
124
  <style>.grid-card-media {
125
125
  --_grid-card-media--padding: var(--sc-kit--grid-card--media--padding, 0);
126
+ isolation: isolate;
126
127
  width: 100%;
127
128
  margin-bottom: 0.5rem;
128
129
  }
@@ -15,6 +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
20
  };
19
21
  /**
20
22
  * Media slot for `GridCard`. Renders an empty placeholder, a single `Image` / `VideoPlayer`, or
@@ -2,7 +2,7 @@
2
2
  const { selected, children, on } = $props();
3
3
  const interactive = $derived(!!on?.activate);
4
4
  const onCardKeydown = (e) => {
5
- if (!on?.activate) {
5
+ if (!on?.activate || e.target !== e.currentTarget) {
6
6
  return;
7
7
  }
8
8
  if (e.key === 'Enter' || e.key === ' ') {
@@ -87,5 +87,4 @@ Inner interactive elements stop event propagation so they don't double-fire acti
87
87
  position: absolute;
88
88
  top: 0.75rem;
89
89
  left: 0.75rem;
90
- z-index: 10;
91
90
  }</style>
@@ -124,7 +124,10 @@ export const seekTo = (time) => {
124
124
  onplay={() => setPlaying(true)}
125
125
  onpause={() => setPlaying(false)}
126
126
  onended={() => setPlaying(false)}
127
- onclick={togglePlay}>
127
+ onclick={(e) => {
128
+ e.stopPropagation();
129
+ togglePlay();
130
+ }}>
128
131
  <track kind="captions" />
129
132
  </video>
130
133
 
@@ -1,7 +1,7 @@
1
1
  <script lang="ts">import { Icon } from '../icon';
2
2
  import { calculateImageState } from './image-state';
3
3
  import IconImageOff from '@fluentui/svg-icons/icons/image_off_20_regular.svg?raw';
4
- let { src, alt = '', showStubOnError = false, on, stub } = $props();
4
+ let { src, alt = '', showStubOnError = false, decoding = 'auto', on, stub } = $props();
5
5
  let hasError = $state(false);
6
6
  const imageState = $derived(calculateImageState(src, hasError, showStubOnError));
7
7
  // A 0.01x density makes the intrinsic size 100× the natural one, so `contain` sizing always scales the picture
@@ -20,7 +20,15 @@ const onLoadError = () => {
20
20
 
21
21
  <div class="image">
22
22
  {#if !!src}
23
- <img class="image__img" class:image__img--hidden={imageState !== 'showImage'} srcset={srcset} alt={alt || ''} onload={onLoad} onerror={onLoadError} />
23
+ <img
24
+ class="image__img"
25
+ class:image__img--hidden={imageState !== 'showImage'}
26
+ srcset={srcset}
27
+ alt={alt || ''}
28
+ draggable="false"
29
+ decoding={decoding}
30
+ onload={onLoad}
31
+ onerror={onLoadError} />
24
32
  {/if}
25
33
 
26
34
  {#if imageState === 'showError'}
@@ -5,6 +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
10
  on?: {
9
11
  /** Fires after load attempt; `true` if successful, `false` on error */
10
12
  load?: (loadedSuccessfully: boolean) => void;
@@ -199,6 +199,18 @@ const loadPrevious = Utils.throttle(() => {
199
199
  const loadNext = Utils.throttle(() => {
200
200
  setIndex(selectedIndex + 1);
201
201
  }, animationDuration);
202
+ const onPreviousClick = (e) => {
203
+ e.stopPropagation();
204
+ loadPrevious();
205
+ };
206
+ const onNextClick = (e) => {
207
+ e.stopPropagation();
208
+ loadNext();
209
+ };
210
+ const onDotClick = (e, index) => {
211
+ e.stopPropagation();
212
+ setIndex(index);
213
+ };
202
214
  $effect(() => {
203
215
  if (previousItems && items !== previousItems) {
204
216
  setIndex(0);
@@ -243,24 +255,24 @@ const slidesTranslateX = $derived(swipeTransition || (items.length > 1 ? -slider
243
255
  {#if items.length > 1}
244
256
  {#if mode === 'arrows-with-counts'}
245
257
  <div class="carousel__arrows-with-counts">
246
- <button type="button" class="carousel__counts-navigation-button" onclick={loadPrevious} aria-label="none">
258
+ <button type="button" class="carousel__counts-navigation-button" onclick={onPreviousClick} aria-label="none">
247
259
  <Icon src={IconChevronLeft} />
248
260
  </button>
249
261
  <div class="carousel__counts">{localization.nOfM(selectedIndex + 1, items.length)}</div>
250
- <button type="button" class="carousel__counts-navigation-button" onclick={loadNext} aria-label="none">
262
+ <button type="button" class="carousel__counts-navigation-button" onclick={onNextClick} aria-label="none">
251
263
  <Icon src={IconChevronRight} />
252
264
  </button>
253
265
  </div>
254
266
  {:else}
255
- <button type="button" class="carousel__navigation-button carousel__navigation-button--prev" onclick={loadPrevious} aria-label="none">
267
+ <button type="button" class="carousel__navigation-button carousel__navigation-button--prev" onclick={onPreviousClick} aria-label="none">
256
268
  <Icon src={IconChevronLeft} />
257
269
  </button>
258
- <button type="button" class="carousel__navigation-button carousel__navigation-button--next" onclick={loadNext} aria-label="none">
270
+ <button type="button" class="carousel__navigation-button carousel__navigation-button--next" onclick={onNextClick} aria-label="none">
259
271
  <Icon src={IconChevronRight} />
260
272
  </button>
261
273
  <div class="carousel__dots">
262
274
  {#each itemIndices as index (index)}
263
- <button type="button" class="carousel__dot" onclick={() => setIndex(index)}>
275
+ <button type="button" class="carousel__dot" onclick={(e) => onDotClick(e, index)}>
264
276
  {#if dot}
265
277
  {@render dot({ active: index === selectedIndex })}
266
278
  {:else}
@@ -0,0 +1,193 @@
1
+ <script lang="ts">import { Checkbox } from '../checkbox';
2
+ import { ToolbarIconButton } from '../page-toolbar';
3
+ import { PlayIndicator } from '../play-indicator';
4
+ import { Popover } from '../popover';
5
+ import { TileCardLocalization } from './tile-card-localization';
6
+ 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 localization = new TileCardLocalization();
9
+ // Safari paints no frame for preload="metadata" until a seek target is given.
10
+ const withFirstFrame = (src) => (src.includes('#') ? src : `${src}#t=0.001`);
11
+ </script>
12
+
13
+ <div class="tile-card" class:tile-card--selected={selected} class:tile-card--checkbox-always={checkboxVisibility === 'always'}>
14
+ {#if on?.activate}
15
+ <button type="button" class="tile-card__hit" aria-label={title} title={title} onclick={() => on?.activate?.()}></button>
16
+ {/if}
17
+ {#if preview}
18
+ <div class="tile-card__preview">
19
+ {#if typeof preview === 'function'}
20
+ {@render preview()}
21
+ {:else if 'image' in preview}
22
+ <img class="tile-card__picture" src={preview.image} alt="" decoding={decoding} />
23
+ {:else}
24
+ <video class="tile-card__picture" src={withFirstFrame(preview.video)} poster={preview.poster} preload="metadata" muted playsinline></video>
25
+ <span class="tile-card__play"><PlayIndicator /></span>
26
+ {/if}
27
+ </div>
28
+ {/if}
29
+ <div class="tile-card__footer">
30
+ <div class="tile-card__text">
31
+ <span class="tile-card__title" title={title}>{title}</span>
32
+ {#if meta}
33
+ <span class="tile-card__meta">{meta}</span>
34
+ {/if}
35
+ </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>
45
+ {/if}
46
+ </div>
47
+ {#if on?.select}
48
+ <div class="tile-card__checkbox">
49
+ <Checkbox checked={selected} variant="on-accent" title={localization.select} on={{ change: (value) => on?.select?.(value) }} />
50
+ </div>
51
+ {/if}
52
+ </div>
53
+
54
+ <!--
55
+ @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`.
58
+
59
+ ### CSS Custom Properties
60
+ | Property | Description | Default |
61
+ |---|---|---|
62
+ | `--sc-kit--tile-card--background` | Card background | `var(--sc-kit--color--bg--panel)` |
63
+ | `--sc-kit--tile-card--border-color` | Border color | `var(--sc-kit--color--border)` |
64
+ | `--sc-kit--tile-card--border-color--hover` | Border color on hover | `var(--sc-kit--color--border--strong)` |
65
+ | `--sc-kit--tile-card--border-color--selected` | Border color when selected | `var(--sc-kit--color--accent)` |
66
+ | `--sc-kit--tile-card--border-radius` | Border radius | `var(--sc-kit--radius--md)` |
67
+ | `--sc-kit--tile-card--preview--background` | Preview area background | `var(--sc-kit--color--bg--field-alt)` |
68
+ | `--sc-kit--tile-card--preview--aspect-ratio` | Preview aspect ratio | `7 / 5` |
69
+ | `--sc-kit--tile-card--footer--min-height` | Footer minimum height | `52px` |
70
+ -->
71
+
72
+ <style>.tile-card {
73
+ --_tile-card--background: var(--sc-kit--tile-card--background, var(--sc-kit--color--bg--panel));
74
+ --_tile-card--border-color: var(--sc-kit--tile-card--border-color, var(--sc-kit--color--border));
75
+ --_tile-card--border-color-hover: var(--sc-kit--tile-card--border-color--hover, var(--sc-kit--color--border--strong));
76
+ --_tile-card--border-color-selected: var(--sc-kit--tile-card--border-color--selected, var(--sc-kit--color--accent));
77
+ --_tile-card--border-radius: var(--sc-kit--tile-card--border-radius, var(--sc-kit--radius--md));
78
+ --_tile-card--preview-background: var(--sc-kit--tile-card--preview--background, var(--sc-kit--color--bg--field-alt));
79
+ --_tile-card--preview-aspect-ratio: var(--sc-kit--tile-card--preview--aspect-ratio, 7 / 5);
80
+ --_tile-card--footer-min-height: var(--sc-kit--tile-card--footer--min-height, 3.25rem);
81
+ --_tile-card--checkbox-opacity: 0;
82
+ position: relative;
83
+ display: flex;
84
+ flex-direction: column;
85
+ inline-size: 100%;
86
+ overflow: hidden;
87
+ color: var(--sc-kit--color--text--primary);
88
+ background: var(--_tile-card--background);
89
+ border: 1px solid var(--_tile-card--border-color);
90
+ border-radius: var(--_tile-card--border-radius);
91
+ transition: border-color var(--sc-kit--duration--base) var(--sc-kit--ease--default);
92
+ }
93
+ .tile-card:hover {
94
+ --_tile-card--border-color: var(--_tile-card--border-color-hover);
95
+ }
96
+ .tile-card:hover, .tile-card:focus-within {
97
+ --_tile-card--checkbox-opacity: 1;
98
+ }
99
+ .tile-card:has(> .tile-card__hit:focus-visible) {
100
+ outline: 2px solid var(--sc-kit--color--border--focus);
101
+ outline-offset: 2px;
102
+ }
103
+ .tile-card--selected {
104
+ --_tile-card--checkbox-opacity: 1;
105
+ }
106
+ .tile-card--selected, .tile-card--selected:hover {
107
+ --_tile-card--border-color: var(--_tile-card--border-color-selected);
108
+ }
109
+ .tile-card--checkbox-always {
110
+ --_tile-card--checkbox-opacity: 1;
111
+ }
112
+ @media (hover: none) {
113
+ .tile-card {
114
+ --_tile-card--checkbox-opacity: 1;
115
+ }
116
+ }
117
+ .tile-card__hit {
118
+ position: absolute;
119
+ inset: 0;
120
+ padding: 0;
121
+ background: none;
122
+ border: 0;
123
+ outline: none;
124
+ cursor: pointer;
125
+ }
126
+ .tile-card__preview {
127
+ position: relative;
128
+ isolation: isolate;
129
+ container-type: inline-size;
130
+ display: flex;
131
+ align-items: center;
132
+ justify-content: center;
133
+ aspect-ratio: var(--_tile-card--preview-aspect-ratio);
134
+ overflow: hidden;
135
+ background: var(--_tile-card--preview-background);
136
+ pointer-events: none;
137
+ }
138
+ .tile-card__picture {
139
+ display: block;
140
+ max-inline-size: 100%;
141
+ max-block-size: 100%;
142
+ object-fit: contain;
143
+ }
144
+ .tile-card__play {
145
+ position: absolute;
146
+ inset: 0;
147
+ display: flex;
148
+ align-items: center;
149
+ justify-content: center;
150
+ }
151
+ .tile-card__footer {
152
+ display: grid;
153
+ grid-template-columns: minmax(0, 1fr) auto;
154
+ align-items: center;
155
+ column-gap: var(--sc-kit--space--2);
156
+ min-block-size: var(--_tile-card--footer-min-height);
157
+ padding: var(--sc-kit--space--2) var(--sc-kit--space--3) var(--sc-kit--space--2) var(--sc-kit--space--4);
158
+ }
159
+ .tile-card__text {
160
+ display: flex;
161
+ flex-direction: column;
162
+ gap: 0.125rem;
163
+ min-inline-size: 0;
164
+ }
165
+ .tile-card__title {
166
+ font-size: var(--sc-kit--font-size--md);
167
+ font-weight: var(--sc-kit--font-weight--semibold);
168
+ line-height: var(--sc-kit--leading--tight);
169
+ text-overflow: ellipsis;
170
+ max-width: 100%;
171
+ white-space: nowrap;
172
+ overflow: hidden;
173
+ }
174
+ .tile-card__meta {
175
+ font-size: var(--sc-kit--font-size--xs);
176
+ line-height: var(--sc-kit--leading--tight);
177
+ color: var(--sc-kit--color--text--muted);
178
+ text-overflow: ellipsis;
179
+ max-width: 100%;
180
+ white-space: nowrap;
181
+ overflow: hidden;
182
+ }
183
+ .tile-card__menu {
184
+ position: relative;
185
+ display: flex;
186
+ }
187
+ .tile-card__checkbox {
188
+ position: absolute;
189
+ inset-block-start: var(--sc-kit--space--3);
190
+ inset-inline-start: var(--sc-kit--space--3);
191
+ opacity: var(--_tile-card--checkbox-opacity);
192
+ transition: opacity var(--sc-kit--duration--base) var(--sc-kit--ease--default);
193
+ }</style>
@@ -0,0 +1,43 @@
1
+ import type { TileCardCheckboxVisibility, TileCardPreview } from './types';
2
+ import type { Snippet } from 'svelte';
3
+ type Props = {
4
+ title: string;
5
+ /** Secondary line under the title. */
6
+ 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`. */
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';
11
+ /** `PopoverItem`s of the kebab menu at the end of the footer. */
12
+ menu?: Snippet;
13
+ /** Accessible name of the kebab menu. @default localized "Actions" */
14
+ menuLabel?: string;
15
+ selected?: boolean;
16
+ /** When the checkbox shows: `'hover'` — on hover, focus-within, when selected and on touch screens; `'always'` — permanently. @default 'hover' */
17
+ checkboxVisibility?: TileCardCheckboxVisibility;
18
+ on?: {
19
+ /** Renders the checkbox at the top-left of the preview. */
20
+ select?: (value: boolean) => void;
21
+ /** Fires on a click on the card surface or Enter / Space; the checkbox and the menu don't trigger it. */
22
+ activate?: () => void;
23
+ };
24
+ };
25
+ /**
26
+ * File-browser style tile: a full-bleed preview on top and a footer with title, optional meta line and a kebab menu. Selection
27
+ * checkbox via `on.select`, whole-card activation via `on.activate`. For a drop-target highlight wrap it in `DropZone`.
28
+ *
29
+ * ### CSS Custom Properties
30
+ * | Property | Description | Default |
31
+ * |---|---|---|
32
+ * | `--sc-kit--tile-card--background` | Card background | `var(--sc-kit--color--bg--panel)` |
33
+ * | `--sc-kit--tile-card--border-color` | Border color | `var(--sc-kit--color--border)` |
34
+ * | `--sc-kit--tile-card--border-color--hover` | Border color on hover | `var(--sc-kit--color--border--strong)` |
35
+ * | `--sc-kit--tile-card--border-color--selected` | Border color when selected | `var(--sc-kit--color--accent)` |
36
+ * | `--sc-kit--tile-card--border-radius` | Border radius | `var(--sc-kit--radius--md)` |
37
+ * | `--sc-kit--tile-card--preview--background` | Preview area background | `var(--sc-kit--color--bg--field-alt)` |
38
+ * | `--sc-kit--tile-card--preview--aspect-ratio` | Preview aspect ratio | `7 / 5` |
39
+ * | `--sc-kit--tile-card--footer--min-height` | Footer minimum height | `52px` |
40
+ */
41
+ declare const Cmp: import("svelte").Component<Props, {}, "">;
42
+ type Cmp = ReturnType<typeof Cmp>;
43
+ export default Cmp;
@@ -0,0 +1,2 @@
1
+ export type { TileCardCheckboxVisibility, TileCardPreview } from './types';
2
+ export { default as TileCard } from './cmp.tile-card.svelte';
@@ -0,0 +1 @@
1
+ export { default as TileCard } from './cmp.tile-card.svelte';
@@ -0,0 +1,4 @@
1
+ export declare class TileCardLocalization {
2
+ get select(): string;
3
+ get actions(): string;
4
+ }
@@ -0,0 +1,19 @@
1
+ import { AppLocale } from '../../core/locale';
2
+ const loc = {
3
+ select: {
4
+ en: 'Select',
5
+ no: 'Velg'
6
+ },
7
+ actions: {
8
+ en: 'Actions',
9
+ no: 'Handlinger'
10
+ }
11
+ };
12
+ export class TileCardLocalization {
13
+ get select() {
14
+ return loc.select[AppLocale.current];
15
+ }
16
+ get actions() {
17
+ return loc.actions[AppLocale.current];
18
+ }
19
+ }
@@ -0,0 +1,8 @@
1
+ import type { Snippet } from 'svelte';
2
+ export type TileCardPreview = {
3
+ image: string;
4
+ } | {
5
+ video: string;
6
+ poster?: string;
7
+ } | Snippet;
8
+ export type TileCardCheckboxVisibility = 'hover' | 'always';
@@ -0,0 +1 @@
1
+ export {};
@@ -341,6 +341,7 @@ const handleActionsClosed = () => {
341
341
  .tree-node__leaf--drag-over {
342
342
  background: var(--sc-kit--color--bg--menu-active);
343
343
  outline: 1px solid var(--sc-kit--color--accent);
344
+ outline-offset: -1px;
344
345
  }
345
346
  .tree-node__children {
346
347
  width: 100%;
@@ -263,7 +263,7 @@ const handleSeek = (percent) => {
263
263
  <track src="" kind="captions" />
264
264
  </video>
265
265
  {#if !everActivated && poster}
266
- <img class="video__poster" src={poster} alt="" />
266
+ <img class="video__poster" src={poster} alt="" draggable="false" />
267
267
  {/if}
268
268
  <div
269
269
  class="video__overlay"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@streamscloud/kit",
3
- "version": "0.66.0",
3
+ "version": "0.68.0",
4
4
  "author": "StreamsCloud",
5
5
  "repository": {
6
6
  "type": "git",
@@ -411,6 +411,10 @@
411
411
  "types": "./dist/ui/textarea/index.d.ts",
412
412
  "svelte": "./dist/ui/textarea/index.js"
413
413
  },
414
+ "./ui/tile-card": {
415
+ "types": "./dist/ui/tile-card/index.d.ts",
416
+ "svelte": "./dist/ui/tile-card/index.js"
417
+ },
414
418
  "./ui/time-ago": {
415
419
  "types": "./dist/ui/time-ago/index.d.ts",
416
420
  "svelte": "./dist/ui/time-ago/index.js"