@streamscloud/kit 0.55.1 → 0.57.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.
@@ -2,13 +2,14 @@ import { ContinuationToken, type CursorResult } from '..';
2
2
  import type { IDataLoader } from './data-loader';
3
3
  export declare class CursorDataLoaderWithSearch<T> implements IDataLoader<T> {
4
4
  items: T[];
5
- private continuationToken;
5
+ private _continuationToken;
6
6
  private _searchString;
7
- private loadPage;
8
- private pending;
9
- private generation;
10
- private searchStringMinLength;
7
+ private _loadPage;
8
+ private _pending;
9
+ private _generation;
10
+ private _searchStringMinLength;
11
11
  private _loading;
12
+ private _failed;
12
13
  constructor(init: {
13
14
  loadPage: (continuationToken: ContinuationToken, searchString: string) => Promise<CursorResult<T> | null>;
14
15
  searchStringMinLength?: number;
@@ -16,6 +17,7 @@ export declare class CursorDataLoaderWithSearch<T> implements IDataLoader<T> {
16
17
  get searchString(): string;
17
18
  get loading(): boolean;
18
19
  get initialLoading(): boolean;
20
+ get failed(): boolean;
19
21
  loadMore: () => Promise<T[]>;
20
22
  reset(): Promise<void>;
21
23
  updateSearchString: (searchString: string | null) => void;
@@ -2,17 +2,18 @@ import { ContinuationToken } from '..';
2
2
  import { Utils } from '../utils';
3
3
  export class CursorDataLoaderWithSearch {
4
4
  items = $state.raw([]);
5
- continuationToken = $state.raw(ContinuationToken.init());
5
+ _continuationToken = $state.raw(ContinuationToken.init());
6
6
  _searchString = $state.raw('');
7
- loadPage;
8
- pending = null;
9
- generation = 0;
10
- searchStringMinLength = 1;
7
+ _loadPage;
8
+ _pending = null;
9
+ _generation = 0;
10
+ _searchStringMinLength = 1;
11
11
  _loading = $state(false);
12
+ _failed = $state(false);
12
13
  constructor(init) {
13
- this.loadPage = init.loadPage;
14
+ this._loadPage = init.loadPage;
14
15
  if (init.searchStringMinLength !== undefined) {
15
- this.searchStringMinLength = init.searchStringMinLength;
16
+ this._searchStringMinLength = init.searchStringMinLength;
16
17
  }
17
18
  this.updateSearchString = Utils.debounce(this.updateSearchString, 400);
18
19
  }
@@ -25,33 +26,36 @@ export class CursorDataLoaderWithSearch {
25
26
  get initialLoading() {
26
27
  return this._loading && this.items.length === 0;
27
28
  }
29
+ get failed() {
30
+ return this._failed;
31
+ }
28
32
  loadMore = async () => {
29
- if (this.pending) {
30
- return this.pending;
33
+ if (this._pending) {
34
+ return this._pending;
31
35
  }
32
- if (!this.continuationToken.canLoadMore) {
36
+ if (!this._continuationToken.canLoadMore) {
33
37
  return [];
34
38
  }
35
39
  this._loading = true;
36
40
  const pending = this.runLoad();
37
- this.pending = pending;
41
+ this._pending = pending;
38
42
  try {
39
43
  return await pending;
40
44
  }
41
45
  finally {
42
46
  // a concurrent reset() may have installed a newer load — leave that one alone
43
- if (this.pending === pending) {
44
- this.pending = null;
47
+ if (this._pending === pending) {
48
+ this._pending = null;
45
49
  this._loading = false;
46
50
  }
47
51
  }
48
52
  };
49
53
  async reset() {
50
- this.generation++;
51
- this.pending = null;
54
+ this._generation++;
55
+ this._pending = null;
52
56
  this._loading = false;
53
57
  this.items = [];
54
- this.continuationToken = ContinuationToken.init();
58
+ this._continuationToken = ContinuationToken.init();
55
59
  await this.loadMore();
56
60
  }
57
61
  updateSearchString = (searchString) => {
@@ -67,22 +71,28 @@ export class CursorDataLoaderWithSearch {
67
71
  }
68
72
  };
69
73
  runLoad = async () => {
70
- const generation = this.generation;
74
+ const generation = this._generation;
71
75
  const search = this.isSearchStringEffective(this._searchString) ? this._searchString : '';
76
+ this._failed = false;
72
77
  try {
73
- const result = (await this.loadPage(this.continuationToken, search)) ?? { items: [], continuationToken: ContinuationToken.preventLoading() };
78
+ const page = await this._loadPage(this._continuationToken, search);
74
79
  // a reset() mid-load bumps the generation — drop the stale page instead of appending it to the cleared list
75
- if (generation !== this.generation) {
80
+ if (generation !== this._generation) {
76
81
  return [];
77
82
  }
78
- this.continuationToken = result.continuationToken;
83
+ const result = page ?? { items: [], continuationToken: ContinuationToken.preventLoading() };
84
+ this._failed = page === null;
85
+ this._continuationToken = result.continuationToken;
79
86
  this.items = [...this.items, ...result.items];
80
87
  return result.items;
81
88
  }
82
89
  catch (error) {
83
90
  console.error('CursorDataLoaderWithSearch: failed to load page', error);
91
+ if (generation === this._generation) {
92
+ this._failed = true;
93
+ }
84
94
  return [];
85
95
  }
86
96
  };
87
- isSearchStringEffective = (searchString) => searchString && searchString.length >= this.searchStringMinLength;
97
+ isSearchStringEffective = (searchString) => searchString && searchString.length >= this._searchStringMinLength;
88
98
  }
@@ -3,15 +3,17 @@ import type { IDataLoader } from './data-loader';
3
3
  export declare class CursorDataLoader<T> implements IDataLoader<T> {
4
4
  items: T[];
5
5
  continuationToken: ContinuationToken;
6
- private loadPage;
7
- private pending;
8
- private generation;
6
+ private _loadPage;
7
+ private _pending;
8
+ private _generation;
9
9
  private _loading;
10
+ private _failed;
10
11
  constructor(init: {
11
12
  loadPage: (continuationToken: ContinuationToken) => Promise<CursorResult<T> | null>;
12
13
  });
13
14
  get loading(): boolean;
14
15
  get initialLoading(): boolean;
16
+ get failed(): boolean;
15
17
  loadMore: () => Promise<T[]>;
16
18
  reset(): Promise<void>;
17
19
  private runLoad;
@@ -2,12 +2,13 @@ import { ContinuationToken } from '..';
2
2
  export class CursorDataLoader {
3
3
  items = $state.raw([]);
4
4
  continuationToken = $state.raw(ContinuationToken.init());
5
- loadPage;
6
- pending = null;
7
- generation = 0;
5
+ _loadPage;
6
+ _pending = null;
7
+ _generation = 0;
8
8
  _loading = $state(false);
9
+ _failed = $state(false);
9
10
  constructor(init) {
10
- this.loadPage = init.loadPage;
11
+ this._loadPage = init.loadPage;
11
12
  }
12
13
  get loading() {
13
14
  return this._loading;
@@ -15,49 +16,58 @@ export class CursorDataLoader {
15
16
  get initialLoading() {
16
17
  return this._loading && this.items.length === 0;
17
18
  }
19
+ get failed() {
20
+ return this._failed;
21
+ }
18
22
  loadMore = async () => {
19
- if (this.pending) {
20
- return this.pending;
23
+ if (this._pending) {
24
+ return this._pending;
21
25
  }
22
26
  if (!this.continuationToken.canLoadMore) {
23
27
  return [];
24
28
  }
25
29
  this._loading = true;
26
30
  const pending = this.runLoad();
27
- this.pending = pending;
31
+ this._pending = pending;
28
32
  try {
29
33
  return await pending;
30
34
  }
31
35
  finally {
32
36
  // a concurrent reset() may have installed a newer load — leave that one alone
33
- if (this.pending === pending) {
34
- this.pending = null;
37
+ if (this._pending === pending) {
38
+ this._pending = null;
35
39
  this._loading = false;
36
40
  }
37
41
  }
38
42
  };
39
43
  async reset() {
40
- this.generation++;
41
- this.pending = null;
44
+ this._generation++;
45
+ this._pending = null;
42
46
  this._loading = false;
43
47
  this.items = [];
44
48
  this.continuationToken = ContinuationToken.init();
45
49
  await this.loadMore();
46
50
  }
47
51
  runLoad = async () => {
48
- const generation = this.generation;
52
+ const generation = this._generation;
53
+ this._failed = false;
49
54
  try {
50
- const result = (await this.loadPage(this.continuationToken)) ?? { items: [], continuationToken: ContinuationToken.preventLoading() };
55
+ const page = await this._loadPage(this.continuationToken);
51
56
  // a reset() mid-load bumps the generation — drop the stale page instead of appending it to the cleared list
52
- if (generation !== this.generation) {
57
+ if (generation !== this._generation) {
53
58
  return [];
54
59
  }
60
+ const result = page ?? { items: [], continuationToken: ContinuationToken.preventLoading() };
61
+ this._failed = page === null;
55
62
  this.continuationToken = result.continuationToken;
56
63
  this.items = [...this.items, ...result.items];
57
64
  return result.items;
58
65
  }
59
66
  catch (error) {
60
67
  console.error('CursorDataLoader: failed to load page', error);
68
+ if (generation === this._generation) {
69
+ this._failed = true;
70
+ }
61
71
  return [];
62
72
  }
63
73
  };
@@ -3,5 +3,7 @@ export interface IDataLoader<T> {
3
3
  loading: boolean;
4
4
  /** Loading with nothing to show yet — first load, or a reload after the list was cleared by a reset. */
5
5
  initialLoading: boolean;
6
+ /** The last attempt did not deliver a page, whether it returned nothing or threw; cleared when the next attempt starts. */
7
+ failed: boolean;
6
8
  loadMore: () => Promise<T[]>;
7
9
  }
@@ -1,4 +1,5 @@
1
1
  export type { IDataLoader } from './data-loader';
2
2
  export { CursorDataLoader } from './cursor-data-loader.svelte';
3
3
  export { CursorDataLoaderWithSearch } from './cursor-data-loader-with-search.svelte';
4
+ export { KeyedCursorDataLoader } from './keyed-cursor-data-loader.svelte';
4
5
  export { PageDataLoader } from './page-data-loader.svelte';
@@ -1,4 +1,5 @@
1
1
  // Data loaders
2
2
  export { CursorDataLoader } from './cursor-data-loader.svelte';
3
3
  export { CursorDataLoaderWithSearch } from './cursor-data-loader-with-search.svelte';
4
+ export { KeyedCursorDataLoader } from './keyed-cursor-data-loader.svelte';
4
5
  export { PageDataLoader } from './page-data-loader.svelte';
@@ -0,0 +1,28 @@
1
+ import { ContinuationToken, type CursorResult } from '..';
2
+ export declare class KeyedCursorDataLoader<T> {
3
+ private _loaders;
4
+ private _loaded;
5
+ private _pending;
6
+ private _loadPage;
7
+ private _loadAlongside;
8
+ constructor(init: {
9
+ loadPage: (key: string, continuationToken: ContinuationToken) => Promise<CursorResult<T> | null>;
10
+ /** Started with the key's first page and awaited by `ensureLoaded`; its outcome never reaches `isLoaded`. */
11
+ loadAlongside?: (key: string) => Promise<void>;
12
+ });
13
+ items: (key?: string) => T[];
14
+ loading: (key?: string) => boolean;
15
+ initialLoading: (key?: string) => boolean;
16
+ canLoadMore: (key?: string) => boolean;
17
+ /** False until the key's first page loaded without failing, so a failed load can be retried; a later failed page never clears it. */
18
+ isLoaded: (key?: string) => boolean;
19
+ /** Loads the key's first page once; concurrent and repeat calls share that load's promise. */
20
+ ensureLoaded: (key?: string) => Promise<void>;
21
+ loadMore: (key?: string) => Promise<T[]>;
22
+ /** Drops the key's pages and loads them again from the first one. */
23
+ reload: (key?: string) => Promise<void>;
24
+ private startLoad;
25
+ private runLoad;
26
+ private runAlongside;
27
+ private loaderFor;
28
+ }
@@ -0,0 +1,66 @@
1
+ import { ContinuationToken } from '..';
2
+ import { CursorDataLoader } from './cursor-data-loader.svelte';
3
+ export class KeyedCursorDataLoader {
4
+ _loaders = {};
5
+ _loaded = $state.raw({});
6
+ _pending = {};
7
+ _loadPage;
8
+ _loadAlongside;
9
+ constructor(init) {
10
+ this._loadPage = init.loadPage;
11
+ this._loadAlongside = init.loadAlongside;
12
+ }
13
+ items = (key = '') => this.loaderFor(key).items;
14
+ loading = (key = '') => this.loaderFor(key).loading;
15
+ initialLoading = (key = '') => this.loaderFor(key).initialLoading;
16
+ canLoadMore = (key = '') => this.loaderFor(key).continuationToken.canLoadMore;
17
+ /** False until the key's first page loaded without failing, so a failed load can be retried; a later failed page never clears it. */
18
+ isLoaded = (key = '') => this._loaded[key] === true;
19
+ /** Loads the key's first page once; concurrent and repeat calls share that load's promise. */
20
+ ensureLoaded = (key = '') => {
21
+ if (this._loaded[key]) {
22
+ return Promise.resolve();
23
+ }
24
+ return this._pending[key] ?? this.startLoad(key);
25
+ };
26
+ loadMore = (key = '') => this.loaderFor(key).loadMore();
27
+ /** Drops the key's pages and loads them again from the first one. */
28
+ reload = (key = '') => {
29
+ this._loaded = { ...this._loaded, [key]: false };
30
+ return this.startLoad(key);
31
+ };
32
+ startLoad = async (key) => {
33
+ const pending = this.runLoad(key);
34
+ this._pending[key] = pending;
35
+ try {
36
+ await pending;
37
+ }
38
+ finally {
39
+ // a concurrent reload() may have installed a newer load — leave that one alone
40
+ if (this._pending[key] === pending) {
41
+ this._pending[key] = undefined;
42
+ }
43
+ }
44
+ };
45
+ runLoad = async (key) => {
46
+ const loader = this.loaderFor(key);
47
+ const alongside = this.runAlongside(key);
48
+ await loader.reset();
49
+ this._loaded = { ...this._loaded, [key]: !loader.failed };
50
+ await alongside;
51
+ };
52
+ runAlongside = async (key) => {
53
+ try {
54
+ await this._loadAlongside?.(key);
55
+ }
56
+ catch (error) {
57
+ console.error('KeyedCursorDataLoader: failed to load alongside the page', error);
58
+ }
59
+ };
60
+ loaderFor = (key) => {
61
+ if (!this._loaders[key]) {
62
+ this._loaders[key] = new CursorDataLoader({ loadPage: (continuationToken) => this._loadPage(key, continuationToken) });
63
+ }
64
+ return this._loaders[key];
65
+ };
66
+ }
@@ -2,11 +2,12 @@ import type { IDataLoader } from './data-loader';
2
2
  export declare class PageDataLoader<T> implements IDataLoader<T> {
3
3
  private _items;
4
4
  private _loading;
5
- private canLoadMore;
6
- private perPage;
7
- private loadPage;
8
- private pageNumber;
9
- private generation;
5
+ private _failed;
6
+ private _canLoadMore;
7
+ private _perPage;
8
+ private _loadPage;
9
+ private _pageNumber;
10
+ private _generation;
10
11
  constructor(init: {
11
12
  loadPage: (page: number, perPage: number) => Promise<T[]>;
12
13
  perPage?: number;
@@ -14,6 +15,7 @@ export declare class PageDataLoader<T> implements IDataLoader<T> {
14
15
  get items(): T[];
15
16
  get loading(): boolean;
16
17
  get initialLoading(): boolean;
18
+ get failed(): boolean;
17
19
  loadMore: () => Promise<T[]>;
18
20
  reset: () => Promise<void>;
19
21
  }
@@ -1,15 +1,16 @@
1
1
  export class PageDataLoader {
2
2
  _items = $state.raw([]);
3
3
  _loading = $state(false);
4
- canLoadMore = $state(true);
5
- perPage = 20;
6
- loadPage;
7
- pageNumber = 1;
8
- generation = 0;
4
+ _failed = $state(false);
5
+ _canLoadMore = $state(true);
6
+ _perPage = 20;
7
+ _loadPage;
8
+ _pageNumber = 1;
9
+ _generation = 0;
9
10
  constructor(init) {
10
- this.loadPage = init.loadPage;
11
+ this._loadPage = init.loadPage;
11
12
  if (init.perPage) {
12
- this.perPage = init.perPage;
13
+ this._perPage = init.perPage;
13
14
  }
14
15
  }
15
16
  get items() {
@@ -21,39 +22,46 @@ export class PageDataLoader {
21
22
  get initialLoading() {
22
23
  return this._loading && this._items.length === 0;
23
24
  }
25
+ get failed() {
26
+ return this._failed;
27
+ }
24
28
  loadMore = async () => {
25
- if (this._loading || !this.canLoadMore) {
29
+ if (this._loading || !this._canLoadMore) {
26
30
  return [];
27
31
  }
28
- const generation = this.generation;
32
+ const generation = this._generation;
29
33
  this._loading = true;
34
+ this._failed = false;
30
35
  try {
31
- const items = await this.loadPage(this.pageNumber, this.perPage);
36
+ const items = await this._loadPage(this._pageNumber, this._perPage);
32
37
  // a reset() mid-load bumps the generation — drop the stale page instead of appending it to the cleared list
33
- if (generation !== this.generation) {
38
+ if (generation !== this._generation) {
34
39
  return [];
35
40
  }
36
- this.pageNumber++;
37
- this.canLoadMore = items.length === this.perPage;
41
+ this._pageNumber++;
42
+ this._canLoadMore = items.length === this._perPage;
38
43
  this._items = [...this._items, ...items];
39
44
  return items;
40
45
  }
41
46
  catch (error) {
42
47
  console.error('PageDataLoader: failed to load page', error);
48
+ if (generation === this._generation) {
49
+ this._failed = true;
50
+ }
43
51
  return [];
44
52
  }
45
53
  finally {
46
- if (generation === this.generation) {
54
+ if (generation === this._generation) {
47
55
  this._loading = false;
48
56
  }
49
57
  }
50
58
  };
51
59
  reset = async () => {
52
- this.generation++;
60
+ this._generation++;
53
61
  this._loading = false;
54
- this.pageNumber = 1;
62
+ this._pageNumber = 1;
55
63
  this._items = [];
56
- this.canLoadMore = true;
64
+ this._canLoadMore = true;
57
65
  await this.loadMore();
58
66
  };
59
67
  }
@@ -5,11 +5,19 @@ let { actions, contextActions = [], contextPosition = 'left-end' } = $props();
5
5
 
6
6
  <div class="mobile-player-buttons">
7
7
  {#each actions as action, index (index)}
8
- <button type="button" class="mobile-player-buttons__action" disabled={action.disabled} onclick={action.callback}>
9
- <span class="mobile-player-buttons__action-icon">
10
- <IconSlot icon={action.icon} />
8
+ {#if action.on}
9
+ <button type="button" class="mobile-player-buttons__action" disabled={action.disabled} aria-label={action.label || undefined} onclick={action.on.click}>
10
+ <span class="mobile-player-buttons__action-icon">
11
+ <IconSlot icon={action.icon} />
12
+ </span>
13
+ </button>
14
+ {:else}
15
+ <span class="mobile-player-buttons__action mobile-player-buttons__action--static" role="img" aria-label={action.label || undefined}>
16
+ <span class="mobile-player-buttons__action-icon">
17
+ <IconSlot icon={action.icon} />
18
+ </span>
11
19
  </span>
12
- </button>
20
+ {/if}
13
21
  {/each}
14
22
  {#if contextActions.length}
15
23
  <div class="mobile-player-buttons__action mobile-player-buttons__action--menu">
@@ -21,7 +29,9 @@ let { actions, contextActions = [], contextPosition = 'left-end' } = $props();
21
29
  <!--
22
30
  @component
23
31
  A vertically stacked column of player action buttons optimized for mobile touch targets.
24
- `contextActions` appends an overflow-menu cell below the buttons.
32
+ `contextActions` appends an overflow-menu cell below the buttons. An action without `on` renders as a static
33
+ plaque — same cell, no pointer feedback, out of the tab order — while `disabled` keeps dimming an action that
34
+ exists but is currently unavailable.
25
35
 
26
36
  ### CSS Custom Properties
27
37
  | Property | Description | Default |
@@ -37,7 +47,6 @@ A vertically stacked column of player action buttons optimized for mobile touch
37
47
  --_player-button--menu--border-color: var(--sc-kit--player-button--menu--border-color, rgb(255 255 255 / 15%));
38
48
  --_player-button--menu--item-color: var(--sc-kit--player-button--menu--item--color, var(--sc-kit--color--text--on-accent));
39
49
  --_player-button--menu--item-background-hover: var(--sc-kit--player-button--menu--item--background--hover, rgb(255 255 255 / 12%));
40
- cursor: pointer;
41
50
  display: flex;
42
51
  flex-direction: column;
43
52
  justify-content: center;
@@ -48,6 +57,7 @@ A vertically stacked column of player action buttons optimized for mobile touch
48
57
  display: flex;
49
58
  justify-content: center;
50
59
  align-items: center;
60
+ cursor: pointer;
51
61
  padding: var(--_player-button--cell-padding);
52
62
  --sc-kit--icon--color: var(--sc-kit--color--text--on-accent);
53
63
  --sc-kit--icon--size: 2rem;
@@ -57,6 +67,9 @@ A vertically stacked column of player action buttons optimized for mobile touch
57
67
  opacity: 0.5;
58
68
  cursor: default;
59
69
  }
70
+ .mobile-player-buttons__action--static {
71
+ cursor: default;
72
+ }
60
73
  .mobile-player-buttons__action--menu {
61
74
  padding: 0;
62
75
  min-block-size: calc(var(--sc-kit--icon--size) + 2 * var(--_player-button--cell-padding));
@@ -1,6 +1,7 @@
1
1
  import type { PlayerButtonDef, PlayerContextActionDef } from './types';
2
2
  import type { Placement } from '@floating-ui/dom';
3
3
  type Props = {
4
+ /** Button definitions — one without `on` renders as a static plaque instead of a button. */
4
5
  actions: PlayerButtonDef[];
5
6
  /** Rows of an overflow menu rendered as the final cell — an ellipsis trigger opening a popover. Empty ⇒ no menu cell. @default [] */
6
7
  contextActions?: PlayerContextActionDef[];
@@ -9,7 +10,9 @@ type Props = {
9
10
  };
10
11
  /**
11
12
  * A vertically stacked column of player action buttons optimized for mobile touch targets.
12
- * `contextActions` appends an overflow-menu cell below the buttons.
13
+ * `contextActions` appends an overflow-menu cell below the buttons. An action without `on` renders as a static
14
+ * plaque — same cell, no pointer feedback, out of the tab order — while `disabled` keeps dimming an action that
15
+ * exists but is currently unavailable.
13
16
  *
14
17
  * ### CSS Custom Properties
15
18
  * | Property | Description | Default |
@@ -5,38 +5,63 @@ const hasMenu = $derived(contextActions.length > 0);
5
5
  const cellCount = $derived(actions.length + (hasMenu ? 1 : 0));
6
6
  </script>
7
7
 
8
- {#if cellCount === 1}
9
- {#if hasMenu}
10
- <div class="player-button player-button--menu" style:zoom={zoom}>
11
- <PlayerContextMenu actions={contextActions} position={contextPosition} />
12
- </div>
13
- {:else}
14
- {@const action = actions[0]}
8
+ {#snippet soloCell(action: PlayerButtonDef)}
9
+ {#if action.on}
15
10
  <button
16
11
  type="button"
17
12
  class="player-button"
18
13
  style:zoom={zoom}
19
14
  class:player-button--scale-effect={scaleEffect}
20
15
  disabled={action.disabled}
21
- onclick={action.callback}>
16
+ aria-label={action.label || undefined}
17
+ onclick={action.on.click}>
22
18
  <span class="player-button__icon">
23
19
  <IconSlot icon={action.icon} />
24
20
  </span>
25
21
  </button>
22
+ {:else}
23
+ <span class="player-button player-button--static" style:zoom={zoom} role="img" aria-label={action.label || undefined}>
24
+ <span class="player-button__icon">
25
+ <IconSlot icon={action.icon} />
26
+ </span>
27
+ </span>
28
+ {/if}
29
+ {/snippet}
30
+
31
+ {#snippet pillCell(action: PlayerButtonDef)}
32
+ {#if action.on}
33
+ <button
34
+ type="button"
35
+ class="player-buttons__action"
36
+ class:player-buttons__action--scale-effect={scaleEffect}
37
+ disabled={action.disabled}
38
+ aria-label={action.label || undefined}
39
+ onclick={action.on.click}>
40
+ <span class="player-buttons__action-icon">
41
+ <IconSlot icon={action.icon} />
42
+ </span>
43
+ </button>
44
+ {:else}
45
+ <span class="player-buttons__action player-buttons__action--static" role="img" aria-label={action.label || undefined}>
46
+ <span class="player-buttons__action-icon">
47
+ <IconSlot icon={action.icon} />
48
+ </span>
49
+ </span>
50
+ {/if}
51
+ {/snippet}
52
+
53
+ {#if cellCount === 1}
54
+ {#if hasMenu}
55
+ <div class="player-button player-button--menu" style:zoom={zoom}>
56
+ <PlayerContextMenu actions={contextActions} position={contextPosition} />
57
+ </div>
58
+ {:else}
59
+ {@render soloCell(actions[0])}
26
60
  {/if}
27
61
  {:else if cellCount > 1}
28
62
  <div class="player-buttons" style:zoom={zoom}>
29
63
  {#each actions as action, index (index)}
30
- <button
31
- type="button"
32
- class="player-buttons__action"
33
- class:player-buttons__action--scale-effect={scaleEffect}
34
- disabled={action.disabled}
35
- onclick={action.callback}>
36
- <span class="player-buttons__action-icon">
37
- <IconSlot icon={action.icon} />
38
- </span>
39
- </button>
64
+ {@render pillCell(action)}
40
65
  {/each}
41
66
  {#if hasMenu}
42
67
  <div class="player-buttons__action player-buttons__action--menu">
@@ -50,7 +75,9 @@ const cellCount = $derived(actions.length + (hasMenu ? 1 : 0));
50
75
  @component
51
76
  Desktop player action buttons — renders a single circular button or a vertically stacked pill for multiple actions.
52
77
  `contextActions` adds an overflow-menu cell as the last item; it counts towards the single / pill split, so one
53
- action plus a menu renders as a two-cell pill.
78
+ action plus a menu renders as a two-cell pill. An action without `on` renders as a static plaque — same cell
79
+ chrome, no hover, no scale, out of the tab order — while `disabled` keeps dimming an action that exists but is
80
+ currently unavailable.
54
81
 
55
82
  ### CSS Custom Properties
56
83
  | Property | Description | Default |
@@ -70,6 +97,7 @@ action plus a menu renders as a two-cell pill.
70
97
  --_player-button--menu--item-background-hover: var(--sc-kit--player-button--menu--item--background--hover, rgb(255 255 255 / 12%));
71
98
  --_player-button--icon-scale: 1;
72
99
  --_player-button--cell-padding: 0.625rem;
100
+ --_player-button--background--hover: var(--_player-button--color);
73
101
  pointer-events: auto;
74
102
  padding: var(--_player-button--cell-padding);
75
103
  display: flex;
@@ -83,16 +111,21 @@ action plus a menu renders as a two-cell pill.
83
111
  --sc-kit--icon--size: 1.75rem;
84
112
  }
85
113
  .player-button:hover:not(:disabled) {
86
- background-color: var(--_player-button--color);
114
+ background-color: var(--_player-button--background--hover);
87
115
  }
88
116
  .player-button:disabled {
89
117
  opacity: 0.5;
90
118
  cursor: default;
91
119
  }
120
+ .player-button--static {
121
+ --_player-button--background--hover: var(--_player-button--color--inactive);
122
+ cursor: default;
123
+ }
92
124
  .player-button--scale-effect:hover:not(:disabled) {
93
125
  --_player-button--icon-scale: 1.2;
94
126
  }
95
127
  .player-button--menu {
128
+ --_player-button--background--hover: var(--_player-button--color--inactive);
96
129
  padding: 0;
97
130
  min-block-size: calc(var(--sc-kit--icon--size) + 2 * var(--_player-button--cell-padding));
98
131
  min-inline-size: calc(var(--sc-kit--icon--size) + 2 * var(--_player-button--cell-padding));
@@ -101,9 +134,6 @@ action plus a menu renders as a two-cell pill.
101
134
  --sc-kit--popover-item--color: var(--_player-button--menu--item-color);
102
135
  --sc-kit--popover-item--background--hover: var(--_player-button--menu--item-background-hover);
103
136
  }
104
- .player-button--menu:hover:not(:disabled) {
105
- background-color: var(--_player-button--color--inactive);
106
- }
107
137
  .player-button__icon {
108
138
  display: block;
109
139
  transform: scale(var(--_player-button--icon-scale));
@@ -125,7 +155,6 @@ action plus a menu renders as a two-cell pill.
125
155
  --_player-button--menu--border-color: var(--sc-kit--player-button--menu--border-color, rgb(255 255 255 / 15%));
126
156
  --_player-button--menu--item-color: var(--sc-kit--player-button--menu--item--color, var(--sc-kit--color--text--on-accent));
127
157
  --_player-button--menu--item-background-hover: var(--sc-kit--player-button--menu--item--background--hover, rgb(255 255 255 / 12%));
128
- cursor: pointer;
129
158
  display: flex;
130
159
  flex-direction: column;
131
160
  justify-content: center;
@@ -137,9 +166,11 @@ action plus a menu renders as a two-cell pill.
137
166
  .player-buttons__action {
138
167
  --_player-button--icon-scale: 1;
139
168
  --_player-button--cell-padding: 0.5625rem;
169
+ --_player-button--background--hover: var(--_player-button--color);
140
170
  display: flex;
141
171
  justify-content: center;
142
172
  align-items: center;
173
+ cursor: pointer;
143
174
  padding: var(--_player-button--cell-padding);
144
175
  border-radius: 1.25rem;
145
176
  color: var(--sc-kit--color--text--on-accent);
@@ -148,17 +179,24 @@ action plus a menu renders as a two-cell pill.
148
179
  transition: background-color 0.5s;
149
180
  }
150
181
  .player-buttons__action:hover:not(:disabled) {
151
- background-color: var(--_player-button--color);
182
+ background-color: var(--_player-button--background--hover);
152
183
  }
153
184
  .player-buttons__action:disabled {
154
185
  opacity: 0.5;
155
186
  cursor: default;
156
187
  }
188
+ .player-buttons__action--static {
189
+ --_player-button--background--hover: transparent;
190
+ cursor: default;
191
+ }
192
+ .player-buttons__action--scale-effect {
193
+ --_player-button--background--hover: transparent;
194
+ }
157
195
  .player-buttons__action--scale-effect:hover:not(:disabled) {
158
196
  --_player-button--icon-scale: 1.2;
159
- background-color: transparent;
160
197
  }
161
198
  .player-buttons__action--menu {
199
+ --_player-button--background--hover: transparent;
162
200
  padding: 0;
163
201
  min-block-size: calc(var(--sc-kit--icon--size) + 2 * var(--_player-button--cell-padding));
164
202
  min-inline-size: calc(var(--sc-kit--icon--size) + 2 * var(--_player-button--cell-padding));
@@ -167,9 +205,6 @@ action plus a menu renders as a two-cell pill.
167
205
  --sc-kit--popover-item--color: var(--_player-button--menu--item-color);
168
206
  --sc-kit--popover-item--background--hover: var(--_player-button--menu--item-background-hover);
169
207
  }
170
- .player-buttons__action--menu:hover:not(:disabled) {
171
- background-color: transparent;
172
- }
173
208
  .player-buttons__action-icon {
174
209
  display: block;
175
210
  transform: scale(var(--_player-button--icon-scale));
@@ -1,7 +1,7 @@
1
1
  import type { PlayerButtonDef, PlayerContextActionDef } from './types';
2
2
  import type { Placement } from '@floating-ui/dom';
3
3
  type Props = {
4
- /** Array of button definitions (icon, callback, disabled state) */
4
+ /** Button definitions one without `on` renders as a static plaque instead of a button. */
5
5
  actions: PlayerButtonDef[];
6
6
  /** Rows of an overflow menu rendered as the final cell — an ellipsis trigger opening a popover. Empty ⇒ no menu cell. @default [] */
7
7
  contextActions?: PlayerContextActionDef[];
@@ -15,7 +15,9 @@ type Props = {
15
15
  /**
16
16
  * Desktop player action buttons — renders a single circular button or a vertically stacked pill for multiple actions.
17
17
  * `contextActions` adds an overflow-menu cell as the last item; it counts towards the single / pill split, so one
18
- * action plus a menu renders as a two-cell pill.
18
+ * action plus a menu renders as a two-cell pill. An action without `on` renders as a static plaque — same cell
19
+ * chrome, no hover, no scale, out of the tab order — while `disabled` keeps dimming an action that exists but is
20
+ * currently unavailable.
19
21
  *
20
22
  * ### CSS Custom Properties
21
23
  * | Property | Description | Default |
@@ -13,7 +13,7 @@ const localization = new PlayerButtonsLocalization();
13
13
  {/snippet}
14
14
 
15
15
  {#each actions as action (action.label)}
16
- <PopoverItem disabled={action.disabled} on={{ click: action.callback }}>
16
+ <PopoverItem disabled={action.disabled} on={{ click: action.on?.click }}>
17
17
  <IconText icon={action.icon} size="sm">{action.label}</IconText>
18
18
  </PopoverItem>
19
19
  {/each}
@@ -1,12 +1,20 @@
1
1
  import type { IconProp } from '../../icon';
2
2
  export type PlayerButtonDef = {
3
3
  icon: IconProp;
4
- callback: () => void;
4
+ /** Accessible name — rendered as `aria-label`, never as visible text. */
5
+ label: string;
6
+ /** Omit for a control with no action: it renders as a static plaque with the button's chrome and no pointer feedback, rather than a dimmed button. */
7
+ on?: {
8
+ click: () => void;
9
+ };
5
10
  disabled?: boolean;
6
11
  };
7
12
  export type PlayerContextActionDef = {
8
13
  icon: IconProp;
9
14
  label: string;
10
- callback: () => void;
15
+ /** Omit for a row with no action: it renders as static text that does not highlight and does not dismiss the menu. */
16
+ on?: {
17
+ click: () => void;
18
+ };
11
19
  disabled?: boolean;
12
20
  };
@@ -1,23 +1,33 @@
1
1
  <script lang="ts">import { popoverIgnore } from './popover-ignore';
2
2
  const { disabled = false, keepOpen = false, divider = false, inset = true, on, children } = $props();
3
+ const isStatic = $derived(!on?.click);
4
+ const interactive = $derived(!disabled && !isStatic);
3
5
  const handleClick = () => {
4
- if (disabled) {
6
+ if (!interactive) {
5
7
  return;
6
8
  }
7
9
  on?.click?.();
8
10
  };
11
+ const handleKeydown = (event) => {
12
+ if (event.key !== 'Enter' && event.key !== ' ') {
13
+ return;
14
+ }
15
+ event.preventDefault();
16
+ event.currentTarget.click();
17
+ };
9
18
  </script>
10
19
 
11
20
  <div
12
21
  class="popover-item"
13
22
  class:popover-item--disabled={disabled}
23
+ class:popover-item--static={isStatic}
14
24
  class:popover-item--divider={divider}
15
25
  class:popover-item--inset={inset}
16
- use:popoverIgnore={keepOpen}
26
+ use:popoverIgnore={keepOpen || isStatic}
17
27
  role="menuitem"
18
- tabindex={disabled ? -1 : 0}
28
+ tabindex={interactive ? 0 : -1}
19
29
  onclick={handleClick}
20
- onkeydown={() => undefined}>
30
+ onkeydown={handleKeydown}>
21
31
  <div class="popover-item__content">
22
32
  {@render children()}
23
33
  </div>
@@ -27,7 +37,9 @@ const handleClick = () => {
27
37
  @component
28
38
  PopoverItem — single row inside a Popover's content. Hover / disabled / opt-in divider
29
39
  variants. Set `keepOpen` to prevent the parent popover from closing when clicked (useful
30
- for toggle items inside menus).
40
+ for toggle items inside menus). A row given no `on` is static — full text color, no hover,
41
+ no focus, and a click neither fires nor dismisses the popover; `disabled` stays for a row
42
+ whose action exists but is currently unavailable.
31
43
 
32
44
  ### CSS Custom Properties
33
45
  | Property | Description | Default |
@@ -70,7 +82,7 @@ for toggle items inside menus).
70
82
  cursor: pointer;
71
83
  transition: background-color var(--sc-kit--duration--base) var(--sc-kit--ease--default), color var(--sc-kit--duration--base) var(--sc-kit--ease--default);
72
84
  }
73
- .popover-item:hover:not(.popover-item--disabled) {
85
+ .popover-item:hover:not(.popover-item--disabled):not(.popover-item--static) {
74
86
  background: var(--_di--background-hover);
75
87
  }
76
88
  .popover-item:focus-visible {
@@ -81,6 +93,9 @@ for toggle items inside menus).
81
93
  color: var(--_di--color-disabled);
82
94
  cursor: default;
83
95
  }
96
+ .popover-item--static {
97
+ cursor: default;
98
+ }
84
99
  .popover-item--divider {
85
100
  box-shadow: 0 1px 0 0 var(--_di--divider-color);
86
101
  margin-bottom: var(--_di--divider-spacing);
@@ -10,6 +10,7 @@ type Props = {
10
10
  * @default true
11
11
  */
12
12
  inset?: boolean;
13
+ /** Omit for a row with no action: it renders as static text that does not highlight, take focus, or dismiss the popover. */
13
14
  on?: {
14
15
  click?: () => void;
15
16
  };
@@ -18,7 +19,9 @@ type Props = {
18
19
  /**
19
20
  * PopoverItem — single row inside a Popover's content. Hover / disabled / opt-in divider
20
21
  * variants. Set `keepOpen` to prevent the parent popover from closing when clicked (useful
21
- * for toggle items inside menus).
22
+ * for toggle items inside menus). A row given no `on` is static — full text color, no hover,
23
+ * no focus, and a click neither fires nor dismisses the popover; `disabled` stays for a row
24
+ * whose action exists but is currently unavailable.
22
25
  *
23
26
  * ### CSS Custom Properties
24
27
  * | Property | Description | Default |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@streamscloud/kit",
3
- "version": "0.55.1",
3
+ "version": "0.57.0",
4
4
  "author": "StreamsCloud",
5
5
  "repository": {
6
6
  "type": "git",