@streamscloud/kit 0.45.0 → 0.47.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.
Files changed (30) hide show
  1. package/dist/ui/cropper/img-cropper/cmp.img-cropper.svelte +2 -7
  2. package/dist/ui/cropper/img-cropper/cmp.img-cropper.svelte.d.ts +1 -7
  3. package/dist/ui/cropper/img-cropper/img-cropper-base-worker.svelte.d.ts +1 -1
  4. package/dist/ui/cropper/img-cropper/img-cropper-base-worker.svelte.js +14 -6
  5. package/dist/ui/cropper/img-cropper/img-cropper-contain-worker.svelte.js +17 -5
  6. package/dist/ui/cropper/img-cropper/img-cropper-cover-worker.svelte.js +24 -5
  7. package/dist/ui/cropper/img-cropper/img-cropper-utils.d.ts +12 -0
  8. package/dist/ui/cropper/img-cropper/img-cropper-utils.js +55 -14
  9. package/dist/ui/cropper/img-cropper/img-cropper-worker.svelte.d.ts +1 -1
  10. package/dist/ui/document-tabs/cmp.document-tabs.svelte +245 -0
  11. package/dist/ui/document-tabs/cmp.document-tabs.svelte.d.ts +80 -0
  12. package/dist/ui/document-tabs/document-tab.svelte +210 -0
  13. package/dist/ui/document-tabs/document-tab.svelte.d.ts +34 -0
  14. package/dist/ui/document-tabs/document-tabs-localization.d.ts +6 -0
  15. package/dist/ui/document-tabs/document-tabs-localization.js +33 -0
  16. package/dist/ui/document-tabs/index.d.ts +3 -0
  17. package/dist/ui/document-tabs/index.js +2 -0
  18. package/dist/ui/document-tabs/types.d.ts +20 -0
  19. package/dist/ui/document-tabs/types.js +1 -0
  20. package/dist/ui/page-layout/cmp.page-panel-sidebars.svelte +29 -3
  21. package/dist/ui/page-layout/cmp.page-panel-sidebars.svelte.d.ts +8 -0
  22. package/dist/ui/page-layout/cmp.page-panel.svelte +66 -42
  23. package/dist/ui/tabs/cmp.tabs.svelte +6 -14
  24. package/dist/ui/tabs/cmp.tabs.svelte.d.ts +5 -11
  25. package/dist/ui/tabs/tab.svelte +14 -75
  26. package/dist/ui/tabs/tab.svelte.d.ts +0 -1
  27. package/dist/ui/tabs/types.d.ts +1 -1
  28. package/package.json +5 -1
  29. package/dist/ui/tabs/tabs-localization.d.ts +0 -3
  30. package/dist/ui/tabs/tabs-localization.js +0 -12
@@ -137,13 +137,6 @@ When `showImageShadow` is enabled on the cropper instance, a dark semi-transpare
137
137
  the canvas bounds so the surrounding area is dimmed. The parent element should set
138
138
  `overflow: hidden` to clip the shadow at its boundaries.
139
139
 
140
- ### Props
141
- | Prop | Type | Default | Description |
142
- |---|---|---|---|
143
- | `src` | `string` | — | Image source URL |
144
- | `cropper` | `ImgCropper` | — | Cropper controller instance |
145
- | `showControls` | `boolean` | `true` | Show built-in Apply/Cancel crop controls |
146
-
147
140
  ### CSS Custom Properties
148
141
  None — canvas background, shadow, and sizing are controlled by the `ImgCropper` instance.
149
142
  -->
@@ -161,6 +154,8 @@ None — canvas background, shadow, and sizing are controlled by the `ImgCropper
161
154
  .img-cropper__canvas {
162
155
  width: 100%;
163
156
  height: 100%;
157
+ min-width: 0;
158
+ min-height: 0;
164
159
  background-color: var(--_fill-color);
165
160
  visibility: hidden;
166
161
  }
@@ -2,6 +2,7 @@ import type { ImgCropper } from './img-cropper.svelte';
2
2
  type Props = {
3
3
  src: string;
4
4
  cropper: ImgCropper;
5
+ /** Show the built-in Apply/Cancel crop controls @default true */
5
6
  showControls?: boolean;
6
7
  };
7
8
  /**
@@ -41,13 +42,6 @@ type Props = {
41
42
  * the canvas bounds so the surrounding area is dimmed. The parent element should set
42
43
  * `overflow: hidden` to clip the shadow at its boundaries.
43
44
  *
44
- * ### Props
45
- * | Prop | Type | Default | Description |
46
- * |---|---|---|---|
47
- * | `src` | `string` | — | Image source URL |
48
- * | `cropper` | `ImgCropper` | — | Cropper controller instance |
49
- * | `showControls` | `boolean` | `true` | Show built-in Apply/Cancel crop controls |
50
- *
51
45
  * ### CSS Custom Properties
52
46
  * None — canvas background, shadow, and sizing are controlled by the `ImgCropper` instance.
53
47
  */
@@ -22,7 +22,7 @@ export declare abstract class ImgCropperBaseWorker implements ImgCropperWorker {
22
22
  reset: () => Promise<void>;
23
23
  crop: (options?: {
24
24
  fillColor?: string;
25
- }) => Promise<ImgCropperResult>;
25
+ }) => Promise<ImgCropperResult | null>;
26
26
  save: (options?: {
27
27
  fillColor?: string;
28
28
  }) => Promise<ImgCropperResult>;
@@ -24,7 +24,8 @@ export class ImgCropperBaseWorker {
24
24
  this._selectHandle = params.selectHandle;
25
25
  this.canvasGeometry = { aspectRatio: params.aspectRatio, width: 0, height: 0 };
26
26
  const onSelectionChange = (e) => {
27
- const { width, height } = e.detail;
27
+ // CropperSelection emits before committing, so a change the boundary handler cancelled never reaches the selection.
28
+ const { width, height } = e.defaultPrevented ? this._selection : e.detail;
28
29
  this.cropBoxVisible = width > 0 && height > 0;
29
30
  };
30
31
  let handlingBoundary = false;
@@ -40,8 +41,9 @@ export class ImgCropperBaseWorker {
40
41
  handlingBoundary = false;
41
42
  }
42
43
  };
43
- this._selection.addEventListener('change', onSelectionChange);
44
+ // Boundary check first — onSelectionChange reads defaultPrevented, which it must not set itself.
44
45
  this._selection.addEventListener('change', onBoundaryCheck);
46
+ this._selection.addEventListener('change', onSelectionChange);
45
47
  this.destroy = () => {
46
48
  this._selection.removeEventListener('change', onSelectionChange);
47
49
  this._selection.removeEventListener('change', onBoundaryCheck);
@@ -75,6 +77,10 @@ export class ImgCropperBaseWorker {
75
77
  await this._fitImage();
76
78
  };
77
79
  crop = async (options) => {
80
+ // An empty selection exports a 0x0 canvas, and assigning its "data:," data URL leaves the cropper with an unloadable source.
81
+ if (this._selection.width <= 0 || this._selection.height <= 0) {
82
+ return null;
83
+ }
78
84
  const fillColor = options?.fillColor;
79
85
  const imageScale = computeImageScale(this._image.$getTransform());
80
86
  const outputSize = computeNaturalOutputSize({ displayWidth: this._selection.width, displayHeight: this._selection.height, imageScale });
@@ -107,8 +113,10 @@ export class ImgCropperBaseWorker {
107
113
  // Compute output dimensions before micro-zoom so the result matches
108
114
  // the source pixel density, not the display size.
109
115
  const imageScale = computeImageScale(this._image.$getTransform());
110
- const displayWidth = this.cropBoxVisible ? this._selection.width : this._canvas.offsetWidth;
111
- const displayHeight = this.cropBoxVisible ? this._selection.height : this._canvas.offsetHeight;
116
+ // A zero-sized selection exports a 0x0 canvas, whose toDataURL is the unusable "data:," string.
117
+ const hasCropBox = this._selection.width > 0 && this._selection.height > 0;
118
+ const displayWidth = hasCropBox ? this._selection.width : this._canvas.offsetWidth;
119
+ const displayHeight = hasCropBox ? this._selection.height : this._canvas.offsetHeight;
112
120
  const outputSize = computeNaturalOutputSize({ displayWidth, displayHeight, imageScale });
113
121
  const toCanvasOptions = { width: outputSize.width, height: outputSize.height, beforeDraw };
114
122
  // CropperJS $toCanvas exports only the visible portion of the canvas.
@@ -116,12 +124,12 @@ export class ImgCropperBaseWorker {
116
124
  // tightly fits the canvas. A micro-zoom nudges the image slightly past
117
125
  // the boundary so the export captures the full area; the zoom is
118
126
  // reversed immediately after.
119
- const needsMicroZoom = !this.cropBoxVisible;
127
+ const needsMicroZoom = !hasCropBox;
120
128
  if (needsMicroZoom) {
121
129
  // Forward zoom increases coverage — safe without suspending cover check
122
130
  this._image.$zoom(0.003);
123
131
  }
124
- const canvas = this.cropBoxVisible ? await this._selection.$toCanvas(toCanvasOptions) : await this._canvas.$toCanvas(toCanvasOptions);
132
+ const canvas = hasCropBox ? await this._selection.$toCanvas(toCanvasOptions) : await this._canvas.$toCanvas(toCanvasOptions);
125
133
  if (needsMicroZoom) {
126
134
  // Reverse zoom shrinks image — must suspend cover check to avoid clamping
127
135
  this._wrapTransformOp(() => this._image.$zoom(-0.003));
@@ -1,5 +1,5 @@
1
1
  import { ImgCropperBaseWorker } from './img-cropper-base-worker.svelte';
2
- import { fitInContainer } from './img-cropper-utils';
2
+ import { fitInContainer, resolveCanvasGeometry } from './img-cropper-utils';
3
3
  export class ImgCropperContainWorker extends ImgCropperBaseWorker {
4
4
  _centerMode = 'contain';
5
5
  constructor(params) {
@@ -24,16 +24,28 @@ export class ImgCropperContainWorker extends ImgCropperBaseWorker {
24
24
  const fitted = fitInContainer({ ratio: aspectRatio, containerWidth, containerHeight });
25
25
  const maxWidth = Math.max(visualWidth, visualHeight * aspectRatio);
26
26
  const width = Math.min(fitted.width, maxWidth);
27
- this.canvasGeometry = { aspectRatio, width, height: width / aspectRatio };
27
+ this.canvasGeometry = resolveCanvasGeometry({ aspectRatio, width, height: width / aspectRatio, maxWidth: fitted.width, maxHeight: containerHeight });
28
28
  }
29
29
  else if (visualWidth <= containerWidth && visualHeight <= containerHeight) {
30
- // Free ratio, image fits naturally no upscale
31
- this.canvasGeometry = { aspectRatio, width: visualWidth, height: visualHeight };
30
+ const fitted = fitInContainer({ ratio: visualWidth / visualHeight, containerWidth, containerHeight });
31
+ this.canvasGeometry = resolveCanvasGeometry({
32
+ aspectRatio,
33
+ width: visualWidth,
34
+ height: visualHeight,
35
+ maxWidth: fitted.width,
36
+ maxHeight: containerHeight
37
+ });
32
38
  }
33
39
  else {
34
40
  // Free ratio, image too large — scale down to fit
35
41
  const fitted = fitInContainer({ ratio: visualWidth / visualHeight, containerWidth, containerHeight });
36
- this.canvasGeometry = { aspectRatio, width: fitted.width, height: fitted.height };
42
+ this.canvasGeometry = resolveCanvasGeometry({
43
+ aspectRatio,
44
+ width: fitted.width,
45
+ height: fitted.height,
46
+ maxWidth: fitted.width,
47
+ maxHeight: containerHeight
48
+ });
37
49
  }
38
50
  };
39
51
  }
@@ -1,5 +1,5 @@
1
1
  import { ImgCropperBaseWorker } from './img-cropper-base-worker.svelte';
2
- import { fitInContainer, handleCoverCheck } from './img-cropper-utils';
2
+ import { fitInContainer, handleCoverCheck, resolveCanvasGeometry } from './img-cropper-utils';
3
3
  export class ImgCropperCoverWorker extends ImgCropperBaseWorker {
4
4
  _centerMode = 'cover';
5
5
  constructor(params) {
@@ -53,18 +53,37 @@ export class ImgCropperCoverWorker extends ImgCropperBaseWorker {
53
53
  // Cap so the image can cover the canvas without upscaling
54
54
  const maxWidth = visualWidth > 0 && visualHeight > 0 ? Math.min(visualWidth, visualHeight * aspectRatio) : fitted.width;
55
55
  const width = Math.min(fitted.width, maxWidth);
56
- this.canvasGeometry = { aspectRatio, width, height: width / aspectRatio };
56
+ this.canvasGeometry = resolveCanvasGeometry({ aspectRatio, width, height: width / aspectRatio, maxWidth: fitted.width, maxHeight: containerHeight });
57
57
  }
58
58
  else if (visualWidth <= 0 || visualHeight <= 0) {
59
- this.canvasGeometry = { aspectRatio, width: containerWidth, height: containerHeight };
59
+ this.canvasGeometry = resolveCanvasGeometry({
60
+ aspectRatio,
61
+ width: containerWidth,
62
+ height: containerHeight,
63
+ maxWidth: containerWidth,
64
+ maxHeight: containerHeight
65
+ });
60
66
  }
61
67
  else if (visualWidth <= containerWidth && visualHeight <= containerHeight) {
62
- this.canvasGeometry = { aspectRatio, width: visualWidth, height: visualHeight };
68
+ const fitted = fitInContainer({ ratio: visualWidth / visualHeight, containerWidth, containerHeight });
69
+ this.canvasGeometry = resolveCanvasGeometry({
70
+ aspectRatio,
71
+ width: visualWidth,
72
+ height: visualHeight,
73
+ maxWidth: fitted.width,
74
+ maxHeight: containerHeight
75
+ });
63
76
  }
64
77
  else {
65
78
  // Fit at image AR; per-axis capping collapses an oversized image to the container AR.
66
79
  const fitted = fitInContainer({ ratio: visualWidth / visualHeight, containerWidth, containerHeight });
67
- this.canvasGeometry = { aspectRatio, width: fitted.width, height: fitted.height };
80
+ this.canvasGeometry = resolveCanvasGeometry({
81
+ aspectRatio,
82
+ width: fitted.width,
83
+ height: fitted.height,
84
+ maxWidth: fitted.width,
85
+ maxHeight: containerHeight
86
+ });
68
87
  }
69
88
  };
70
89
  }
@@ -1,3 +1,4 @@
1
+ import type { CanvasGeometry } from './img-cropper-worker.svelte';
1
2
  import type { CropperCanvas, CropperImage, CropperSelection } from 'cropperjs';
2
3
  type Rect = {
3
4
  left: number;
@@ -34,6 +35,17 @@ export declare const fitInContainer: (params: {
34
35
  width: number;
35
36
  height: number;
36
37
  };
38
+ /**
39
+ * Applies the minimum canvas size by scaling the whole rect, bounded by `maxWidth` / `maxHeight`, and rounds to whole pixels.
40
+ * The minimums are soft: a container smaller than them wins, and the returned canvas is smaller than `MIN_CANVAS_WIDTH` / `MIN_CANVAS_HEIGHT`.
41
+ */
42
+ export declare const resolveCanvasGeometry: (params: {
43
+ aspectRatio: number | null;
44
+ width: number;
45
+ height: number;
46
+ maxWidth: number;
47
+ maxHeight: number;
48
+ }) => CanvasGeometry;
37
49
  export declare const handleSelectionBoundary: (params: {
38
50
  event: Event;
39
51
  selection: CropperSelection;
@@ -1,5 +1,7 @@
1
1
  import { Base64Helper } from '../../../core/files/base64-helper';
2
2
  import { default as mime } from 'mime';
3
+ const COVERAGE_TOLERANCE = 0.5;
4
+ const SHRINK_THRESHOLD = 0.5;
3
5
  // Detect MIME type from a URL without fetching the resource.
4
6
  // Returns a MIME string for data: URLs, extension-based URLs, or null for blob: / unknown URLs.
5
7
  export const detectMimeFromUrl = (url) => {
@@ -84,35 +86,56 @@ export const handleCoverCheck = (params) => {
84
86
  right: currentRect.right + (newAABB.right - oldAABB.right),
85
87
  bottom: currentRect.bottom + (newAABB.bottom - oldAABB.bottom)
86
88
  };
87
- // Strict check (tolerance 0) any gap triggers clamping
88
- if (rectCoversRect(proposedRect, canvasRect, 0)) {
89
+ // Sub-pixel tolerance: a stricter check re-enters on the clamped transform this handler issues itself.
90
+ if (rectCoversRect(proposedRect, canvasRect, COVERAGE_TOLERANCE)) {
89
91
  return;
90
92
  }
91
93
  event.preventDefault();
92
- // If the proposed image is still large enough to cover the canvas, clamp
93
- // translation to maintain coverage. Otherwise block the transform entirely.
94
94
  const proposedWidth = proposedRect.right - proposedRect.left;
95
95
  const proposedHeight = proposedRect.bottom - proposedRect.top;
96
96
  const canvasWidth = canvasRect.right - canvasRect.left;
97
97
  const canvasHeight = canvasRect.bottom - canvasRect.top;
98
+ const oldAABBWidth = oldAABB.right - oldAABB.left;
99
+ const oldAABBHeight = oldAABB.bottom - oldAABB.top;
100
+ const newAABBWidth = newAABB.right - newAABB.left;
101
+ const newAABBHeight = newAABB.bottom - newAABB.top;
102
+ const shrinksImage = newAABBWidth < oldAABBWidth - SHRINK_THRESHOLD || newAABBHeight < oldAABBHeight - SHRINK_THRESHOLD;
103
+ let [scaleX, skewY, skewX, scaleY] = matrix;
104
+ let clampedRect = proposedRect;
98
105
  if (proposedWidth < canvasWidth - 0.5 || proposedHeight < canvasHeight - 0.5) {
99
- return;
106
+ // Rescaling anything but a shrink would turn a pan, whose proposal is the same size, into an upscale.
107
+ if (!shrinksImage) {
108
+ return;
109
+ }
110
+ const coverScale = Math.max(canvasWidth / proposedWidth, canvasHeight / proposedHeight);
111
+ if (!Number.isFinite(coverScale)) {
112
+ return;
113
+ }
114
+ scaleX *= coverScale;
115
+ skewY *= coverScale;
116
+ skewX *= coverScale;
117
+ scaleY *= coverScale;
118
+ const halfWidth = (proposedWidth * coverScale) / 2;
119
+ const halfHeight = (proposedHeight * coverScale) / 2;
120
+ const centerX = (proposedRect.left + proposedRect.right) / 2;
121
+ const centerY = (proposedRect.top + proposedRect.bottom) / 2;
122
+ clampedRect = { left: centerX - halfWidth, top: centerY - halfHeight, right: centerX + halfWidth, bottom: centerY + halfHeight };
100
123
  }
101
124
  let clampedTx = matrix[4];
102
125
  let clampedTy = matrix[5];
103
- if (proposedRect.left > canvasRect.left) {
104
- clampedTx -= proposedRect.left - canvasRect.left;
126
+ if (clampedRect.left > canvasRect.left) {
127
+ clampedTx -= clampedRect.left - canvasRect.left;
105
128
  }
106
- else if (proposedRect.right < canvasRect.right) {
107
- clampedTx += canvasRect.right - proposedRect.right;
129
+ else if (clampedRect.right < canvasRect.right) {
130
+ clampedTx += canvasRect.right - clampedRect.right;
108
131
  }
109
- if (proposedRect.top > canvasRect.top) {
110
- clampedTy -= proposedRect.top - canvasRect.top;
132
+ if (clampedRect.top > canvasRect.top) {
133
+ clampedTy -= clampedRect.top - canvasRect.top;
111
134
  }
112
- else if (proposedRect.bottom < canvasRect.bottom) {
113
- clampedTy += canvasRect.bottom - proposedRect.bottom;
135
+ else if (clampedRect.bottom < canvasRect.bottom) {
136
+ clampedTy += canvasRect.bottom - clampedRect.bottom;
114
137
  }
115
- image.$setTransform(matrix[0], matrix[1], matrix[2], matrix[3], clampedTx, clampedTy);
138
+ image.$setTransform(scaleX, skewY, skewX, scaleY, clampedTx, clampedTy);
116
139
  };
117
140
  export const fitInContainer = (params) => {
118
141
  const { ratio, containerWidth, containerHeight } = params;
@@ -121,6 +144,24 @@ export const fitInContainer = (params) => {
121
144
  }
122
145
  return { width: containerHeight * ratio, height: containerHeight };
123
146
  };
147
+ // Same limits cropper-canvas declares in its shadow style, where they clamp one axis at a time and break the canvas ratio.
148
+ const MIN_CANVAS_WIDTH = 200;
149
+ const MIN_CANVAS_HEIGHT = 100;
150
+ /**
151
+ * Applies the minimum canvas size by scaling the whole rect, bounded by `maxWidth` / `maxHeight`, and rounds to whole pixels.
152
+ * The minimums are soft: a container smaller than them wins, and the returned canvas is smaller than `MIN_CANVAS_WIDTH` / `MIN_CANVAS_HEIGHT`.
153
+ */
154
+ export const resolveCanvasGeometry = (params) => {
155
+ const { aspectRatio, width, height, maxWidth, maxHeight } = params;
156
+ if (width <= 0 || height <= 0) {
157
+ return { aspectRatio, width: 0, height: 0 };
158
+ }
159
+ const ratio = width / height;
160
+ const floorScale = Math.max(MIN_CANVAS_WIDTH / width, MIN_CANVAS_HEIGHT / height, 1);
161
+ const boundedWidth = Math.min(width * floorScale, maxWidth, maxHeight * ratio);
162
+ // A zero side reads as "unset" in the template, which falls the canvas back to its CSS size and desyncs it from this geometry.
163
+ return { aspectRatio, width: Math.max(1, Math.round(boundedWidth)), height: Math.max(1, Math.round(boundedWidth / ratio)) };
164
+ };
124
165
  export const handleSelectionBoundary = (params) => {
125
166
  const { event, selection, canvas } = params;
126
167
  const { x, y, width, height } = event.detail;
@@ -32,7 +32,7 @@ export interface ImgCropperWorker {
32
32
  reset: () => Promise<void>;
33
33
  crop: (options?: {
34
34
  fillColor?: string;
35
- }) => Promise<ImgCropperResult>;
35
+ }) => Promise<ImgCropperResult | null>;
36
36
  save: (options?: {
37
37
  fillColor?: string;
38
38
  }) => Promise<ImgCropperResult>;
@@ -0,0 +1,245 @@
1
+ <script lang="ts" generics="T">import { horizontalWheelScroll } from '../../core/actions';
2
+ import { Icon } from '../icon';
3
+ import DocumentTabItem from './document-tab.svelte';
4
+ import { DocumentTabsLocalization } from './document-tabs-localization';
5
+ import IconChevronLeft from '@fluentui/svg-icons/icons/chevron_left_20_regular.svg?raw';
6
+ import IconChevronRight from '@fluentui/svg-icons/icons/chevron_right_20_regular.svg?raw';
7
+ const { value, tabs, overflow = 'visible', compare, on } = $props();
8
+ const localization = new DocumentTabsLocalization();
9
+ const SCROLL_STEP = 160;
10
+ let trackNode = $state(null);
11
+ let canScrollBack = $state(false);
12
+ let canScrollForward = $state(false);
13
+ let scrollSyncArmed = false;
14
+ const isActive = (tab) => (compare ? compare(value, tab.value) : value === tab.value);
15
+ const isClosable = (tab) => !!on?.close && tab.closable !== false && !tab.disabled;
16
+ const activeIndex = $derived(tabs.findIndex((tab) => isActive(tab)));
17
+ const tabButtons = () => Array.from(trackNode?.querySelectorAll('[role="tab"]') ?? []);
18
+ const updateScrollState = (node) => {
19
+ if (overflow !== 'scroll') {
20
+ canScrollBack = false;
21
+ canScrollForward = false;
22
+ return;
23
+ }
24
+ const { scrollLeft, scrollWidth, clientWidth } = node;
25
+ canScrollBack = scrollLeft > 0;
26
+ canScrollForward = scrollLeft < scrollWidth - clientWidth - 1;
27
+ };
28
+ const trackMounted = (node) => {
29
+ trackNode = node;
30
+ const observer = new ResizeObserver(() => updateScrollState(node));
31
+ observer.observe(node);
32
+ updateScrollState(node);
33
+ return {
34
+ destroy: () => observer.disconnect()
35
+ };
36
+ };
37
+ const scrollBack = () => {
38
+ trackNode?.scrollBy({ left: -SCROLL_STEP, behavior: 'smooth' });
39
+ };
40
+ const scrollForward = () => {
41
+ trackNode?.scrollBy({ left: SCROLL_STEP, behavior: 'smooth' });
42
+ };
43
+ const stepFocus = (buttons, from, step) => {
44
+ const count = buttons.length;
45
+ for (let offset = 1; offset <= count; offset++) {
46
+ const candidate = buttons[(((from + step * offset) % count) + count) % count];
47
+ if (!candidate.disabled) {
48
+ return candidate;
49
+ }
50
+ }
51
+ return null;
52
+ };
53
+ const handleKeydown = (event) => {
54
+ const buttons = tabButtons();
55
+ const current = buttons.indexOf(document.activeElement);
56
+ if (current === -1) {
57
+ return;
58
+ }
59
+ if (event.key === 'Delete' || event.key === 'Backspace') {
60
+ const tab = tabs[current];
61
+ if (tab && isClosable(tab)) {
62
+ event.preventDefault();
63
+ on?.close?.(tab.value);
64
+ }
65
+ return;
66
+ }
67
+ let target;
68
+ switch (event.key) {
69
+ case 'ArrowRight':
70
+ target = stepFocus(buttons, current, 1);
71
+ break;
72
+ case 'ArrowLeft':
73
+ target = stepFocus(buttons, current, -1);
74
+ break;
75
+ case 'Home':
76
+ target = stepFocus(buttons, -1, 1);
77
+ break;
78
+ case 'End':
79
+ target = stepFocus(buttons, buttons.length, -1);
80
+ break;
81
+ default:
82
+ return;
83
+ }
84
+ if (!target) {
85
+ return;
86
+ }
87
+ event.preventDefault();
88
+ target.focus();
89
+ target.click();
90
+ };
91
+ $effect(() => {
92
+ const index = activeIndex;
93
+ if (!trackNode || index < 0) {
94
+ return;
95
+ }
96
+ if (!scrollSyncArmed) {
97
+ scrollSyncArmed = true;
98
+ return;
99
+ }
100
+ tabButtons()[index]?.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'smooth' });
101
+ });
102
+ </script>
103
+
104
+ <div class="document-tabs">
105
+ {#if canScrollBack}
106
+ <button type="button" class="document-tabs__scroll-btn document-tabs__scroll-btn--back" onclick={scrollBack} aria-label={localization.scrollBack}>
107
+ <Icon src={IconChevronLeft} size="sm" />
108
+ </button>
109
+ {/if}
110
+ <div
111
+ class="document-tabs__track"
112
+ class:document-tabs__track--scrollable={overflow === 'scroll'}
113
+ class:document-tabs__track--mask-back={canScrollBack && !canScrollForward}
114
+ class:document-tabs__track--mask-forward={canScrollForward && !canScrollBack}
115
+ class:document-tabs__track--mask-both={canScrollBack && canScrollForward}
116
+ role="tablist"
117
+ use:trackMounted
118
+ use:horizontalWheelScroll={{ isolateScroll: overflow === 'scroll' }}
119
+ onscroll={(event) => updateScrollState(event.currentTarget)}>
120
+ {#each tabs as tab (tab.value)}
121
+ <DocumentTabItem tab={tab} active={isActive(tab)} closable={isClosable(tab)} on={{ select: on?.change, close: on?.close, keydown: handleKeydown }} />
122
+ {/each}
123
+ </div>
124
+ {#if canScrollForward}
125
+ <button type="button" class="document-tabs__scroll-btn document-tabs__scroll-btn--forward" onclick={scrollForward} aria-label={localization.scrollForward}>
126
+ <Icon src={IconChevronRight} size="sm" />
127
+ </button>
128
+ {/if}
129
+ </div>
130
+
131
+ <!--
132
+ @component
133
+ DocumentTabs — browser-style tabs for an editor: the active tab is taller than its neighbours and
134
+ sits flush on the container's bottom rule, so it reads as continuous with the panel below while the
135
+ inactive ones stay behind that line. Each tab carries an optional icon, an unsaved-changes dot, a
136
+ `tone` for a tab of a different kind (a live preview), and its own `closable` opt-out. A closable or
137
+ dirty tab reserves the trailing slot permanently: the dot sits there at rest, the close button takes
138
+ its place on hover, and a tab's width never depends on which of them is showing. Arrow keys / Home /
139
+ End move between tabs (roving tabindex), Delete closes the focused one.
140
+
141
+ `overflow="scroll"` gives the strip its own scroll container with edge fades, chevrons and
142
+ wheel-to-scroll. Leave it `'visible'` where the container already scrolls the strip — two nested
143
+ scrollers fight each other.
144
+
145
+ The active tab dips `--sc-kit--document-tabs--baseline-overlap` below the strip to cover the bottom
146
+ rule of the bar it sits in. A container that clips (any `overflow` other than `visible`) has to
147
+ leave that last pixel inside its own clip region, or the overlap is cut off.
148
+
149
+ ### CSS Custom Properties
150
+ | Property | Description | Default |
151
+ |---|---|---|
152
+ | `--sc-kit--document-tabs--height` | Inactive tab height | `29px` |
153
+ | `--sc-kit--document-tabs--height--active` | Active tab height | `32px` |
154
+ | `--sc-kit--document-tabs--padding-inline` | Tab horizontal padding — the same in both states, so activating a tab shifts nothing sideways | `10px` |
155
+ | `--sc-kit--document-tabs--min-width` | Minimum tab width | `112px` |
156
+ | `--sc-kit--document-tabs--max-width` | Maximum tab width — the label ellipsizes past it | `200px` |
157
+ | `--sc-kit--document-tabs--gap` | Gap between tabs | `1px` |
158
+ | `--sc-kit--document-tabs--tab--gap` | Gap between a tab's icon, label and trailing slot | `8px` |
159
+ | `--sc-kit--document-tabs--radius` | Top corner radius | `--sc-kit--radius--sm` |
160
+ | `--sc-kit--document-tabs--border-width` | Tab border width | `1px` |
161
+ | `--sc-kit--document-tabs--border-color` | Tab border color | `--sc-kit--color--border` |
162
+ | `--sc-kit--document-tabs--baseline-overlap` | How far the active tab dips below the strip to cover its container's bottom rule | `1px` |
163
+ | `--sc-kit--document-tabs--background` | Inactive tab background | `--sc-kit--color--bg--app` |
164
+ | `--sc-kit--document-tabs--background--active` | Active tab background — matches the pane below | `--sc-kit--color--bg--panel` |
165
+ | `--sc-kit--document-tabs--background--hover` | Inactive tab hover background | `--sc-kit--color--bg--hover` |
166
+ | `--sc-kit--document-tabs--color` | Inactive tab label color | `--sc-kit--color--text--secondary` |
167
+ | `--sc-kit--document-tabs--color--active` | Active tab label color | `--sc-kit--color--text--primary` |
168
+ | `--sc-kit--document-tabs--color--hover` | Inactive tab hover label color | `--sc-kit--color--text--primary` |
169
+ | `--sc-kit--document-tabs--color--disabled` | Disabled tab label color | `--sc-kit--color--text--muted` |
170
+ | `--sc-kit--document-tabs--font-size` | Label font size | `--sc-kit--font-size--sm` |
171
+ | `--sc-kit--document-tabs--font-weight` | Label font weight | `--sc-kit--font-weight--regular` |
172
+ | `--sc-kit--document-tabs--scroll-btn--background` | Fade behind the overflow chevrons | `--sc-kit--color--bg--panel` |
173
+ -->
174
+
175
+ <style>.document-tabs {
176
+ --_document-tabs--gap: var(--sc-kit--document-tabs--gap, 1px);
177
+ --_document-tabs--baseline-overlap: var(--sc-kit--document-tabs--baseline-overlap, 1px);
178
+ --_document-tabs--scroll-btn-background: var(--sc-kit--document-tabs--scroll-btn--background, var(--sc-kit--color--bg--panel));
179
+ position: relative;
180
+ display: flex;
181
+ align-self: stretch;
182
+ align-items: flex-end;
183
+ min-inline-size: 0;
184
+ margin-block-end: calc(-1 * var(--_document-tabs--baseline-overlap));
185
+ }
186
+ .document-tabs:hover {
187
+ --_scroll-btn-opacity: 1;
188
+ }
189
+ .document-tabs__track {
190
+ --_mask-size: 1.5rem;
191
+ display: flex;
192
+ align-items: flex-end;
193
+ gap: var(--_document-tabs--gap);
194
+ min-inline-size: 0;
195
+ }
196
+ .document-tabs__track--scrollable {
197
+ overflow-x: auto;
198
+ scrollbar-width: none;
199
+ }
200
+ .document-tabs__track--scrollable::-webkit-scrollbar {
201
+ display: none;
202
+ }
203
+ .document-tabs__track--mask-back {
204
+ mask-image: linear-gradient(to right, transparent 0%, black var(--_mask-size), black 100%);
205
+ }
206
+ .document-tabs__track--mask-forward {
207
+ mask-image: linear-gradient(to right, black 0%, black calc(100% - var(--_mask-size)), transparent 100%);
208
+ }
209
+ .document-tabs__track--mask-both {
210
+ mask-image: linear-gradient(to right, transparent 0%, black var(--_mask-size), black calc(100% - var(--_mask-size)), transparent 100%);
211
+ }
212
+ .document-tabs__scroll-btn {
213
+ position: absolute;
214
+ inset-block: 0;
215
+ z-index: 1;
216
+ display: flex;
217
+ align-items: center;
218
+ justify-content: center;
219
+ padding-inline: 0.25rem;
220
+ background: transparent;
221
+ backdrop-filter: blur(2px);
222
+ border: none;
223
+ cursor: pointer;
224
+ color: var(--sc-kit--color--text--secondary);
225
+ opacity: var(--_scroll-btn-opacity, 0);
226
+ transition: opacity var(--sc-kit--duration--base) var(--sc-kit--ease--default);
227
+ }
228
+ .document-tabs__scroll-btn:hover {
229
+ color: var(--sc-kit--color--text--primary);
230
+ }
231
+ .document-tabs__scroll-btn:focus-visible {
232
+ outline: 2px solid var(--sc-kit--color--border--focus);
233
+ outline-offset: -2px;
234
+ opacity: 1;
235
+ }
236
+ .document-tabs__scroll-btn--back {
237
+ inset-inline-start: 0;
238
+ padding-inline-end: 0.625rem;
239
+ background: linear-gradient(to right, var(--_document-tabs--scroll-btn-background) 60%, transparent);
240
+ }
241
+ .document-tabs__scroll-btn--forward {
242
+ inset-inline-end: 0;
243
+ padding-inline-start: 0.625rem;
244
+ background: linear-gradient(to left, var(--_document-tabs--scroll-btn-background) 60%, transparent);
245
+ }</style>