@streamscloud/kit 0.85.1 → 0.87.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.
- package/README.md +2 -2
- package/dist/core/files/file-decorators.d.ts +3 -0
- package/dist/core/files/file-decorators.js +2 -0
- package/dist/core/files/file-validation-rules.d.ts +3 -8
- package/dist/core/files/file-with-blob-data-helper.d.ts +1 -1
- package/dist/core/files/file-with-blob-data-helper.js +1 -1
- package/dist/core/files/image-resizer.d.ts +17 -29
- package/dist/core/files/image-resizer.js +30 -57
- package/dist/core/files/index.d.ts +4 -4
- package/dist/core/files/index.js +2 -2
- package/dist/core/files/types.d.ts +14 -2
- package/dist/core/files/types.js +1 -10
- package/dist/core/files/upload-media-store.svelte.d.ts +11 -17
- package/dist/core/files/upload-media-store.svelte.js +32 -35
- package/dist/styles/_mixins.scss +71 -17
- package/dist/styles/reset.css +19 -12
- package/dist/ui/ai-panel/cmp.ai-panel.svelte +27 -0
- package/dist/ui/cropper/image-editor-dialog/cmp.image-editor-dialog.svelte +8 -12
- package/dist/ui/cropper/image-editor-dialog/crop-and-resize.d.ts +6 -13
- package/dist/ui/cropper/image-editor-dialog/crop-and-resize.js +6 -18
- package/dist/ui/cropper/image-editor-dialog/open-image-editor-dialog.d.ts +6 -1
- package/dist/ui/cropper/image-editor-dialog/open-image-editor-dialog.js +6 -1
- package/dist/ui/cropper/image-editor-dialog/types.d.ts +8 -5
- package/dist/ui/cropper/img-cropper/img-cropper.svelte.d.ts +6 -2
- package/dist/ui/cropper/img-cropper/img-cropper.svelte.js +18 -11
- package/dist/ui/date-picker/cmp.date-time-picker.svelte +67 -8
- package/dist/ui/date-picker/cmp.date-time-picker.svelte.d.ts +8 -5
- package/dist/ui/date-picker/cmp.time-picker.svelte +3 -2
- package/dist/ui/date-picker/cmp.time-picker.svelte.d.ts +2 -0
- package/dist/ui/dialog/cmp.dialog-container.svelte +19 -12
- package/dist/ui/dialog/cmp.dialog.svelte +19 -12
- package/dist/ui/dialog/layout/cmp.stepper-dialog.svelte +54 -0
- package/dist/ui/emoji-picker/cmp.emoji-panel.svelte +19 -12
- package/dist/ui/file-uploader/cmp.upload-progress-toaster.svelte +20 -11
- package/dist/ui/grouped-sidebar/cmp.grouped-sidebar.svelte +20 -11
- package/dist/ui/navigation/cmp.nav-menu.svelte +20 -11
- package/dist/ui/page-layout/cmp.page-layout.svelte +38 -24
- package/dist/ui/page-layout/cmp.page-panel-sidebars.svelte +40 -22
- package/dist/ui/page-layout/cmp.page-panel.svelte +20 -11
- package/dist/ui/popover/cmp.popover.svelte +19 -12
- package/dist/ui/select/cmp.input-suggest.svelte +1 -0
- package/dist/ui/select/cmp.singleselect-async.svelte +1 -0
- package/dist/ui/select/multiselect-base.svelte +1 -0
- package/dist/ui/select/select-core.svelte.js +41 -11
- package/dist/ui/select/select-listbox.svelte +42 -19
- package/dist/ui/select/select-listbox.svelte.d.ts +5 -3
- package/dist/ui/table/table-core.svelte +19 -12
- package/dist/ui/textarea/cmp.textarea.svelte +20 -11
- package/package.json +4 -7
package/dist/styles/reset.css
CHANGED
|
@@ -123,20 +123,27 @@ textarea {
|
|
|
123
123
|
resize: vertical;
|
|
124
124
|
--_cross-browser-scrollbar--thumb-color: var(--scrollbar--thumb-color, light-dark(#d1d5db, #4b5563));
|
|
125
125
|
--_cross-browser-scrollbar--track-color: var(--scrollbar--track-color, transparent);
|
|
126
|
+
--_cross-browser-scrollbar--gutter: 0px;
|
|
126
127
|
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
128
|
+
@supports selector(::-webkit-scrollbar) {
|
|
129
|
+
textarea {
|
|
130
|
+
scrollbar-color: auto;
|
|
131
|
+
scrollbar-width: auto;
|
|
132
|
+
}
|
|
133
|
+
textarea::-webkit-scrollbar {
|
|
134
|
+
width: 6px;
|
|
135
|
+
height: 6px;
|
|
136
|
+
}
|
|
137
|
+
textarea::-webkit-scrollbar-track {
|
|
138
|
+
background: var(--_cross-browser-scrollbar--track-color);
|
|
139
|
+
border-radius: 100vw;
|
|
140
|
+
}
|
|
141
|
+
textarea::-webkit-scrollbar-thumb {
|
|
142
|
+
background: var(--_cross-browser-scrollbar--thumb-color);
|
|
143
|
+
border-radius: 100vw;
|
|
144
|
+
}
|
|
138
145
|
}
|
|
139
|
-
@supports (scrollbar
|
|
146
|
+
@supports not selector(::-webkit-scrollbar) {
|
|
140
147
|
textarea {
|
|
141
148
|
scrollbar-color: var(--_cross-browser-scrollbar--thumb-color) var(--_cross-browser-scrollbar--track-color);
|
|
142
149
|
scrollbar-width: thin;
|
|
@@ -69,6 +69,33 @@ layout's concern. User-facing copy (placeholder, Generate label) is consumer-sup
|
|
|
69
69
|
background: var(--_ai-panel--surface);
|
|
70
70
|
border-radius: var(--_ai-panel--surface-radius);
|
|
71
71
|
overflow-y: auto;
|
|
72
|
+
--_cross-browser-scrollbar--thumb-color: var(--scrollbar--thumb-color, light-dark(#d1d5db, #4b5563));
|
|
73
|
+
--_cross-browser-scrollbar--track-color: var(--scrollbar--track-color, transparent);
|
|
74
|
+
--_cross-browser-scrollbar--gutter: 0px;
|
|
75
|
+
}
|
|
76
|
+
@supports selector(::-webkit-scrollbar) {
|
|
77
|
+
.ai-panel__body {
|
|
78
|
+
scrollbar-color: auto;
|
|
79
|
+
scrollbar-width: auto;
|
|
80
|
+
}
|
|
81
|
+
.ai-panel__body::-webkit-scrollbar {
|
|
82
|
+
width: 6px;
|
|
83
|
+
height: 6px;
|
|
84
|
+
}
|
|
85
|
+
.ai-panel__body::-webkit-scrollbar-track {
|
|
86
|
+
background: var(--_cross-browser-scrollbar--track-color);
|
|
87
|
+
border-radius: 100vw;
|
|
88
|
+
}
|
|
89
|
+
.ai-panel__body::-webkit-scrollbar-thumb {
|
|
90
|
+
background: var(--_cross-browser-scrollbar--thumb-color);
|
|
91
|
+
border-radius: 100vw;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
@supports not selector(::-webkit-scrollbar) {
|
|
95
|
+
.ai-panel__body {
|
|
96
|
+
scrollbar-color: var(--_cross-browser-scrollbar--thumb-color) var(--_cross-browser-scrollbar--track-color);
|
|
97
|
+
scrollbar-width: thin;
|
|
98
|
+
}
|
|
72
99
|
}
|
|
73
100
|
.ai-panel__composer {
|
|
74
101
|
position: relative;
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
<script lang="ts">import {
|
|
1
|
+
<script lang="ts">import { FileHelper } from '../../../core/files';
|
|
2
2
|
import { Dialog, DialogButton, DialogCancelButton } from '../../dialog';
|
|
3
3
|
import { Toastr } from '../../toast';
|
|
4
4
|
import { ImgCropper, ImgCropperToolbar, ImgCropperView } from '../img-cropper';
|
|
5
5
|
import { ImageEditorDialogLocalization } from './image-editor-dialog-localization';
|
|
6
|
+
import { default as mime } from 'mime';
|
|
7
|
+
import { nanoid } from 'nanoid';
|
|
6
8
|
import { untrack } from 'svelte';
|
|
7
9
|
const { controller, data } = $props();
|
|
8
10
|
const loc = new ImageEditorDialogLocalization();
|
|
@@ -12,22 +14,16 @@ const cropper = untrack(() => new ImgCropper({
|
|
|
12
14
|
fillColor: data.fillColor,
|
|
13
15
|
tools: data.tools
|
|
14
16
|
}));
|
|
15
|
-
const url = $derived(data.url);
|
|
16
17
|
const save = async () => {
|
|
17
18
|
try {
|
|
18
|
-
const result = await cropper.save();
|
|
19
|
-
// Closed mid-save: destroy() already ran, and a file made now would leak its blob URL.
|
|
20
|
-
if (!cropper.ready) {
|
|
21
|
-
return;
|
|
22
|
-
}
|
|
19
|
+
const result = await cropper.save(data.resize);
|
|
23
20
|
if (!result) {
|
|
24
21
|
Toastr.error(loc.saveError);
|
|
25
22
|
return;
|
|
26
23
|
}
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
});
|
|
24
|
+
const baseName = data.fileName ? FileHelper.omitFileExtension(data.fileName) : nanoid(15);
|
|
25
|
+
const file = FileHelper.blobToFile(result.blob, `${baseName}.${mime.getExtension(result.blob.type)}`);
|
|
26
|
+
const croppedFile = Object.assign(file, { width: result.width, height: result.height });
|
|
31
27
|
controller.ok({ croppedFile, selectedRatio: cropper.aspectRatio });
|
|
32
28
|
}
|
|
33
29
|
catch {
|
|
@@ -50,7 +46,7 @@ $effect(() => untrack(() => {
|
|
|
50
46
|
{/snippet}
|
|
51
47
|
{#snippet bodySection()}
|
|
52
48
|
<div class="image-editor-dialog__workspace">
|
|
53
|
-
<ImgCropperView src={url} cropper={cropper}>
|
|
49
|
+
<ImgCropperView src={data.url} cropper={cropper}>
|
|
54
50
|
{#snippet toolbar()}
|
|
55
51
|
<ImgCropperToolbar cropper={cropper} />
|
|
56
52
|
{/snippet}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type
|
|
2
|
-
import type { ImageEditorDynamicAspectRatio, ImageEditorTool } from './types';
|
|
1
|
+
import { type ResizeLimits } from '../../../core/files';
|
|
2
|
+
import type { CroppedFile, ImageEditorDynamicAspectRatio, ImageEditorTool } from './types';
|
|
3
3
|
export type CropAndResizeOptions = {
|
|
4
4
|
/** Fixed aspect ratio, or dynamic selector, or omit to follow the picture's own. */
|
|
5
5
|
aspectRatio?: number | ImageEditorDynamicAspectRatio;
|
|
@@ -7,20 +7,13 @@ export type CropAndResizeOptions = {
|
|
|
7
7
|
mode?: 'contain' | 'cover';
|
|
8
8
|
/** Fill color for whatever the picture doesn't cover — `contain` gaps, free position, its own transparency (transparent by default). */
|
|
9
9
|
fillColor?: string;
|
|
10
|
-
/**
|
|
11
|
-
|
|
12
|
-
* height — the longer side matches the larger limit. Pass `false` to disable resize.
|
|
13
|
-
* @default `{ max1: 1600, max2: 1000 }`
|
|
14
|
-
*/
|
|
15
|
-
resize?: {
|
|
16
|
-
max1: number;
|
|
17
|
-
max2: number;
|
|
18
|
-
} | false;
|
|
10
|
+
/** Renders the cropped picture straight at a size that fits these limits; `false` keeps its natural resolution. @default DEFAULT_RESIZE_LIMITS (1600 / 1000) */
|
|
11
|
+
resize?: ResizeLimits | false;
|
|
19
12
|
/** Actions that rework the picture itself (e.g. background removal), one toolbar button each. */
|
|
20
13
|
tools?: ImageEditorTool[];
|
|
21
14
|
};
|
|
22
15
|
/**
|
|
23
|
-
* Opens the kit's image editor dialog
|
|
16
|
+
* Opens the kit's image editor dialog and returns the cropped picture, rendered at a size that fits `resize`.
|
|
24
17
|
* Returns `null` when the user cancels the dialog.
|
|
25
18
|
*/
|
|
26
|
-
export declare const cropAndResizeImage: (file: File, options?: CropAndResizeOptions) => Promise<
|
|
19
|
+
export declare const cropAndResizeImage: (file: File, options?: CropAndResizeOptions) => Promise<CroppedFile | null>;
|
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { DEFAULT_RESIZE_LIMITS } from '../../../core/files';
|
|
2
2
|
import { openImageEditorDialog } from './open-image-editor-dialog';
|
|
3
|
-
const DEFAULT_RESIZE_LIMITS = { max1: 1600, max2: 1000 };
|
|
4
3
|
/**
|
|
5
|
-
* Opens the kit's image editor dialog
|
|
4
|
+
* Opens the kit's image editor dialog and returns the cropped picture, rendered at a size that fits `resize`.
|
|
6
5
|
* Returns `null` when the user cancels the dialog.
|
|
7
6
|
*/
|
|
8
7
|
export const cropAndResizeImage = async (file, options = {}) => {
|
|
@@ -10,25 +9,14 @@ export const cropAndResizeImage = async (file, options = {}) => {
|
|
|
10
9
|
try {
|
|
11
10
|
const result = await openImageEditorDialog({
|
|
12
11
|
url: sourceUrl,
|
|
12
|
+
fileName: file.name,
|
|
13
13
|
aspectRatio: options.aspectRatio,
|
|
14
14
|
mode: options.mode,
|
|
15
15
|
fillColor: options.fillColor,
|
|
16
|
-
tools: options.tools
|
|
16
|
+
tools: options.tools,
|
|
17
|
+
resize: options.resize === false ? undefined : (options.resize ?? DEFAULT_RESIZE_LIMITS)
|
|
17
18
|
});
|
|
18
|
-
|
|
19
|
-
return null;
|
|
20
|
-
}
|
|
21
|
-
const cropped = result.value.croppedFile;
|
|
22
|
-
if (options.resize === false) {
|
|
23
|
-
return cropped;
|
|
24
|
-
}
|
|
25
|
-
const limits = options.resize ?? DEFAULT_RESIZE_LIMITS;
|
|
26
|
-
const resizedBlob = await resizeBlob(cropped, limits);
|
|
27
|
-
if (resizedBlob === cropped) {
|
|
28
|
-
return cropped;
|
|
29
|
-
}
|
|
30
|
-
FileWithBlobDataHelper.dispose(cropped.blobUrl);
|
|
31
|
-
return FileWithBlobDataHelper.fromBlob(resizedBlob, { id: cropped.id });
|
|
19
|
+
return result.wasCanceled ? null : result.value.croppedFile;
|
|
32
20
|
}
|
|
33
21
|
finally {
|
|
34
22
|
URL.revokeObjectURL(sourceUrl);
|
|
@@ -18,8 +18,13 @@ import type { ImageEditorDialogOptions, ImageEditorDialogResult } from './types'
|
|
|
18
18
|
* - `tools` — one toolbar button per tool; `run` gets the picture as shown — cropped and rotated, at its natural
|
|
19
19
|
* resolution — and its result replaces it (`null` leaves it as it is). Reset returns to the source.
|
|
20
20
|
*
|
|
21
|
+
* ### Size
|
|
22
|
+
* - `resize` — the saved picture is rendered straight at a size that fits these limits (one encode, no upscale past
|
|
23
|
+
* them). Omitted — its natural resolution.
|
|
24
|
+
*
|
|
21
25
|
* ### Result
|
|
22
|
-
* - `croppedFile` — `
|
|
26
|
+
* - `croppedFile` — a plain `File` with its `width`/`height` — natural resolution, or fitted to `resize` when given; no
|
|
27
|
+
* object URL is created for it. Named after `fileName` with the saved picture's extension, or randomly without it.
|
|
23
28
|
* - `selectedRatio` — the active aspect ratio at save time (`null` when following the picture's own).
|
|
24
29
|
*/
|
|
25
30
|
export declare const openImageEditorDialog: (options: ImageEditorDialogOptions) => Promise<DialogResult<ImageEditorDialogResult>>;
|
|
@@ -18,8 +18,13 @@ import { default as ImageEditorDialog } from './cmp.image-editor-dialog.svelte';
|
|
|
18
18
|
* - `tools` — one toolbar button per tool; `run` gets the picture as shown — cropped and rotated, at its natural
|
|
19
19
|
* resolution — and its result replaces it (`null` leaves it as it is). Reset returns to the source.
|
|
20
20
|
*
|
|
21
|
+
* ### Size
|
|
22
|
+
* - `resize` — the saved picture is rendered straight at a size that fits these limits (one encode, no upscale past
|
|
23
|
+
* them). Omitted — its natural resolution.
|
|
24
|
+
*
|
|
21
25
|
* ### Result
|
|
22
|
-
* - `croppedFile` — `
|
|
26
|
+
* - `croppedFile` — a plain `File` with its `width`/`height` — natural resolution, or fitted to `resize` when given; no
|
|
27
|
+
* object URL is created for it. Named after `fileName` with the saved picture's extension, or randomly without it.
|
|
23
28
|
* - `selectedRatio` — the active aspect ratio at save time (`null` when following the picture's own).
|
|
24
29
|
*/
|
|
25
30
|
export const openImageEditorDialog = async (options) => {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { ResizeLimits, SizedFile } from '../../../core/files';
|
|
2
2
|
export type ImageEditorDynamicAspectRatio = {
|
|
3
3
|
initial: number;
|
|
4
4
|
supported: number[];
|
|
@@ -23,17 +23,20 @@ export type ImageEditorTool = {
|
|
|
23
23
|
export type ImageEditorDialogOptions = {
|
|
24
24
|
/** The picture to edit — same-origin, a `blob:` / `data:` URL, or a server that sends CORS headers; anything else fails to load. */
|
|
25
25
|
url: string;
|
|
26
|
+
/** Name of the saved file; its extension follows the saved picture's type. Omitted — a random name. */
|
|
27
|
+
fileName?: string;
|
|
28
|
+
/** Fill color for whatever the picture doesn't cover — `contain` gaps, free position, its own transparency (transparent by default). */
|
|
26
29
|
fillColor?: string;
|
|
27
30
|
/** @default 'cover' */
|
|
28
31
|
mode?: 'contain' | 'cover';
|
|
32
|
+
/** Fixed aspect ratio, or dynamic selector, or omit to follow the picture's own. */
|
|
29
33
|
aspectRatio?: number | ImageEditorDynamicAspectRatio;
|
|
30
34
|
/** Actions that rework the picture itself (e.g. background removal), one toolbar button each. */
|
|
31
35
|
tools?: ImageEditorTool[];
|
|
36
|
+
/** Renders the saved picture straight at a size that fits these limits. Omitted — its natural resolution. */
|
|
37
|
+
resize?: ResizeLimits;
|
|
32
38
|
};
|
|
33
|
-
export type CroppedFile =
|
|
34
|
-
width: number;
|
|
35
|
-
height: number;
|
|
36
|
-
};
|
|
39
|
+
export type CroppedFile = SizedFile;
|
|
37
40
|
export type ImageEditorDialogResult = {
|
|
38
41
|
croppedFile: CroppedFile;
|
|
39
42
|
selectedRatio: number | null;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type ResizeLimits } from '../../../core/files';
|
|
1
2
|
import { type FrameFitMode, type Point, type Rect, type Size } from './frame-geometry';
|
|
2
3
|
export type CropperMode = FrameFitMode;
|
|
3
4
|
export type { Point, Rect };
|
|
@@ -135,8 +136,11 @@ export declare class ImgCropper {
|
|
|
135
136
|
* along and keeps what's inside it; a fixed-aspect frame can't turn, so the picture is laid out afresh instead. */
|
|
136
137
|
rotate: (direction: 1 | -1) => void;
|
|
137
138
|
reset: () => void;
|
|
138
|
-
/**
|
|
139
|
-
|
|
139
|
+
/**
|
|
140
|
+
* Renders the frame to an image, applying a pending selection first, downscaled to fit `limits` when given;
|
|
141
|
+
* `null` when there's nothing to render or the browser can't.
|
|
142
|
+
*/
|
|
143
|
+
save: (limits?: ResizeLimits) => Promise<ImgCropperSaveResult | null>;
|
|
140
144
|
/**
|
|
141
145
|
* Where the whole turned source lands when the picture is drawn into `picture` — its center and its size on each axis,
|
|
142
146
|
* before `rotation` swaps them. The view and `save()` both place the source through this, so they can't drift apart.
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { fitSize, toPixelSize } from '../../../core/files';
|
|
1
2
|
import { ColorHelper, Utils } from '../../../core/utils';
|
|
2
3
|
import { alignBox, boxAtZoom, boxTurnedWithFrame, clampBox, containBox, containZoom, coverBox, fitFrame, MAX_FRAME_ZOOM, MIN_FREE_ZOOM, moveBox, scaleBox, selectionFromDrag, shiftSelection, zoomOf } from './frame-geometry';
|
|
3
4
|
import { Picture } from './picture';
|
|
@@ -403,14 +404,17 @@ export class ImgCropper {
|
|
|
403
404
|
this.aspectRatio = this._initialAspectRatio;
|
|
404
405
|
this.layout();
|
|
405
406
|
};
|
|
406
|
-
/**
|
|
407
|
-
|
|
407
|
+
/**
|
|
408
|
+
* Renders the frame to an image, applying a pending selection first, downscaled to fit `limits` when given;
|
|
409
|
+
* `null` when there's nothing to render or the browser can't.
|
|
410
|
+
*/
|
|
411
|
+
save = async (limits) => {
|
|
408
412
|
if (this.hasSelection) {
|
|
409
413
|
this.applyCrop();
|
|
410
414
|
}
|
|
411
415
|
this._processing = true;
|
|
412
416
|
try {
|
|
413
|
-
const rendered = this._render();
|
|
417
|
+
const rendered = this._render(limits);
|
|
414
418
|
if (!rendered) {
|
|
415
419
|
return null;
|
|
416
420
|
}
|
|
@@ -441,7 +445,7 @@ export class ImgCropper {
|
|
|
441
445
|
size: { w: turned.w * perX, h: turned.h * perY }
|
|
442
446
|
};
|
|
443
447
|
};
|
|
444
|
-
_render = () => {
|
|
448
|
+
_render = (limits) => {
|
|
445
449
|
const source = this._current;
|
|
446
450
|
const { box, frame, croppedSize } = this;
|
|
447
451
|
if (this.failed || !source || !box || !frame || !croppedSize) {
|
|
@@ -450,13 +454,16 @@ export class ImgCropper {
|
|
|
450
454
|
const nativeScale = croppedSize.w / box.w;
|
|
451
455
|
const nativeArea = frame.w * frame.h * nativeScale * nativeScale;
|
|
452
456
|
const areaLimit = Math.min(croppedSize.w * croppedSize.h * MAX_OUTPUT_AREA_UPSCALE, MAX_OUTPUT_PIXELS);
|
|
453
|
-
const
|
|
454
|
-
const
|
|
455
|
-
const height =
|
|
456
|
-
|
|
457
|
-
const
|
|
458
|
-
const
|
|
459
|
-
const
|
|
457
|
+
const cappedScale = nativeArea > areaLimit ? nativeScale * Math.sqrt(areaLimit / nativeArea) : nativeScale;
|
|
458
|
+
const cappedSize = { width: frame.w * cappedScale, height: frame.h * cappedScale };
|
|
459
|
+
const { width, height } = limits ? fitSize(cappedSize, limits) : toPixelSize(cappedSize);
|
|
460
|
+
// Per axis from the whole-pixel canvas: one shared scale rounds a frame-wide box a pixel past the canvas edge.
|
|
461
|
+
const scaleX = width / frame.w;
|
|
462
|
+
const scaleY = height / frame.h;
|
|
463
|
+
const left = Math.round(box.x * scaleX);
|
|
464
|
+
const top = Math.round(box.y * scaleY);
|
|
465
|
+
const right = Math.round((box.x + box.w) * scaleX);
|
|
466
|
+
const bottom = Math.round((box.y + box.h) * scaleY);
|
|
460
467
|
const canvas = document.createElement('canvas');
|
|
461
468
|
canvas.width = width;
|
|
462
469
|
canvas.height = height;
|
|
@@ -2,10 +2,47 @@
|
|
|
2
2
|
import TimePicker from './cmp.time-picker.svelte';
|
|
3
3
|
const { value, size = 'md', datePlaceholder = '', min, step = 30, on } = $props();
|
|
4
4
|
const DEFAULT_TIME = '12:00';
|
|
5
|
+
const MINUTES_PER_DAY = 24 * 60;
|
|
6
|
+
const AFTER_LAST_SLOT = '24:00';
|
|
5
7
|
const pad = (n) => n.toString().padStart(2, '0');
|
|
6
8
|
const toTimeValue = (date) => `${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
|
9
|
+
const minutesToTimeValue = (minutes) => `${pad(Math.floor(minutes / 60))}:${pad(minutes % 60)}`;
|
|
10
|
+
const isSameDay = (a, b) => a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
|
|
7
11
|
const currentDate = $derived(value ? new Date(value) : null);
|
|
8
12
|
const timeValue = $derived(currentDate ? toTimeValue(currentDate) : DEFAULT_TIME);
|
|
13
|
+
const minDate = $derived.by(() => {
|
|
14
|
+
if (min === undefined) {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
const date = new Date(min);
|
|
18
|
+
return Number.isNaN(date.getTime()) ? null : date;
|
|
19
|
+
});
|
|
20
|
+
const minSlotMinutes = $derived.by(() => {
|
|
21
|
+
if (!minDate) {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
const elapsed = minDate.getHours() * 60 + minDate.getMinutes() + minDate.getSeconds() / 60 + minDate.getMilliseconds() / 60_000;
|
|
25
|
+
const slot = Math.ceil(elapsed / step) * step;
|
|
26
|
+
return slot < MINUTES_PER_DAY ? slot : null;
|
|
27
|
+
});
|
|
28
|
+
const firstAllowedDate = $derived.by(() => {
|
|
29
|
+
if (!minDate) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
if (minSlotMinutes === null) {
|
|
33
|
+
return new Date(minDate.getFullYear(), minDate.getMonth(), minDate.getDate() + 1);
|
|
34
|
+
}
|
|
35
|
+
return new Date(minDate.getFullYear(), minDate.getMonth(), minDate.getDate(), Math.floor(minSlotMinutes / 60), minSlotMinutes % 60);
|
|
36
|
+
});
|
|
37
|
+
const timeMin = $derived.by(() => {
|
|
38
|
+
if (!currentDate || !firstAllowedDate) {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
if (isSameDay(currentDate, firstAllowedDate)) {
|
|
42
|
+
return minutesToTimeValue(firstAllowedDate.getHours() * 60 + firstAllowedDate.getMinutes());
|
|
43
|
+
}
|
|
44
|
+
return currentDate < firstAllowedDate ? AFTER_LAST_SLOT : undefined;
|
|
45
|
+
});
|
|
9
46
|
const onDateChange = (next) => {
|
|
10
47
|
if (!next) {
|
|
11
48
|
on?.change?.(null);
|
|
@@ -18,6 +55,9 @@ const onDateChange = (next) => {
|
|
|
18
55
|
combined.setHours(hours, minutes, 0, 0);
|
|
19
56
|
}
|
|
20
57
|
combined.setFullYear(picked.getFullYear(), picked.getMonth(), picked.getDate());
|
|
58
|
+
if (minDate && minSlotMinutes !== null && combined < minDate) {
|
|
59
|
+
combined.setHours(Math.floor(minSlotMinutes / 60), minSlotMinutes % 60, 0, 0);
|
|
60
|
+
}
|
|
21
61
|
on?.change?.(combined.toISOString());
|
|
22
62
|
};
|
|
23
63
|
const onTimeChange = (next) => {
|
|
@@ -31,35 +71,54 @@ const onTimeChange = (next) => {
|
|
|
31
71
|
};
|
|
32
72
|
</script>
|
|
33
73
|
|
|
34
|
-
<div class="date-time" class:date-time--date-only={!currentDate}>
|
|
35
|
-
<DatePicker clearable size={size} value={value} placeholder={datePlaceholder} min={
|
|
74
|
+
<div class="date-time date-time--{size}" class:date-time--date-only={!currentDate}>
|
|
75
|
+
<DatePicker clearable size={size} value={value} placeholder={datePlaceholder} min={firstAllowedDate ?? undefined} on={{ change: onDateChange }} />
|
|
36
76
|
{#if currentDate}
|
|
37
|
-
<TimePicker clearable={false} size={size} step={step} value={timeValue} on={{ change: onTimeChange }} />
|
|
77
|
+
<TimePicker clearable={false} size={size} step={step} min={timeMin} value={timeValue} on={{ change: onTimeChange }} />
|
|
38
78
|
{/if}
|
|
39
79
|
</div>
|
|
40
80
|
|
|
41
81
|
<!--
|
|
42
82
|
@component
|
|
43
|
-
DateTimePicker — composes `DatePicker` + `TimePicker` into one ISO date-time field
|
|
44
|
-
|
|
45
|
-
Emits an ISO 8601 string — or `null` when the date is cleared — via `on.change`.
|
|
83
|
+
DateTimePicker — composes `DatePicker` + `TimePicker` into one ISO 8601 date-time field whose time row appears
|
|
84
|
+
once a date is picked (defaulting to `12:00`). Emits the ISO string — or `null` when the date is cleared — via `on.change`.
|
|
46
85
|
|
|
47
86
|
### CSS Custom Properties
|
|
48
87
|
| Property | Description | Default |
|
|
49
88
|
|---|---|---|
|
|
50
|
-
| `--sc-kit--date-time-picker--time-width` | Width of the time column | `
|
|
89
|
+
| `--sc-kit--date-time-picker--time-width` | Width of the time column | per size — `sm` 5.75rem, `md` 6.25rem, `lg` 6.75rem (+0.75rem without `::-webkit-scrollbar`, e.g. Firefox) |
|
|
51
90
|
| `--sc-kit--date-time-picker--gap` | Gap between the date and time fields | `var(--sc-kit--space--2)` |
|
|
52
91
|
-->
|
|
53
92
|
|
|
54
93
|
<style>.date-time {
|
|
55
|
-
--_dtp--
|
|
94
|
+
--_dtp--scrollbar-allowance: 0px;
|
|
95
|
+
--_dtp--time-width: var(
|
|
96
|
+
--sc-kit--date-time-picker--time-width,
|
|
97
|
+
calc(var(--_dtp--size-time-width, 6.25rem) + var(--_dtp--scrollbar-allowance))
|
|
98
|
+
);
|
|
56
99
|
--_dtp--gap: var(--sc-kit--date-time-picker--gap, var(--sc-kit--space--2));
|
|
100
|
+
}
|
|
101
|
+
@supports not selector(::-webkit-scrollbar) {
|
|
102
|
+
.date-time {
|
|
103
|
+
--_dtp--scrollbar-allowance: 12px;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
.date-time {
|
|
57
107
|
display: grid;
|
|
58
108
|
grid-template-columns: minmax(0, 1fr) var(--_dtp--time-width);
|
|
59
109
|
gap: var(--_dtp--gap);
|
|
60
110
|
width: 100%;
|
|
61
111
|
max-width: 100%;
|
|
62
112
|
}
|
|
113
|
+
.date-time--sm {
|
|
114
|
+
--_dtp--size-time-width: 5.75rem;
|
|
115
|
+
}
|
|
116
|
+
.date-time--md {
|
|
117
|
+
--_dtp--size-time-width: 6.25rem;
|
|
118
|
+
}
|
|
119
|
+
.date-time--lg {
|
|
120
|
+
--_dtp--size-time-width: 6.75rem;
|
|
121
|
+
}
|
|
63
122
|
.date-time--date-only {
|
|
64
123
|
grid-template-columns: minmax(0, 1fr);
|
|
65
124
|
}</style>
|
|
@@ -4,7 +4,11 @@ type Props = {
|
|
|
4
4
|
/** @default 'md' */
|
|
5
5
|
size?: 'sm' | 'md' | 'lg';
|
|
6
6
|
datePlaceholder?: string;
|
|
7
|
-
/**
|
|
7
|
+
/**
|
|
8
|
+
* Earliest selectable date-time (inclusive). On its day only the `step` slots at or after it are offered and an
|
|
9
|
+
* earlier time is raised to the first of them; a day with no slot left is not selectable. A `value` before the first
|
|
10
|
+
* allowed day still renders, but its time list is empty — only picking an allowed date changes it.
|
|
11
|
+
*/
|
|
8
12
|
min?: LikeDate;
|
|
9
13
|
/** Minute interval for the time list. @default 30 */
|
|
10
14
|
step?: number;
|
|
@@ -13,14 +17,13 @@ type Props = {
|
|
|
13
17
|
};
|
|
14
18
|
};
|
|
15
19
|
/**
|
|
16
|
-
* DateTimePicker — composes `DatePicker` + `TimePicker` into one ISO date-time field
|
|
17
|
-
*
|
|
18
|
-
* Emits an ISO 8601 string — or `null` when the date is cleared — via `on.change`.
|
|
20
|
+
* DateTimePicker — composes `DatePicker` + `TimePicker` into one ISO 8601 date-time field whose time row appears
|
|
21
|
+
* once a date is picked (defaulting to `12:00`). Emits the ISO string — or `null` when the date is cleared — via `on.change`.
|
|
19
22
|
*
|
|
20
23
|
* ### CSS Custom Properties
|
|
21
24
|
* | Property | Description | Default |
|
|
22
25
|
* |---|---|---|
|
|
23
|
-
* | `--sc-kit--date-time-picker--time-width` | Width of the time column | `
|
|
26
|
+
* | `--sc-kit--date-time-picker--time-width` | Width of the time column | per size — `sm` 5.75rem, `md` 6.25rem, `lg` 6.75rem (+0.75rem without `::-webkit-scrollbar`, e.g. Firefox) |
|
|
24
27
|
* | `--sc-kit--date-time-picker--gap` | Gap between the date and time fields | `var(--sc-kit--space--2)` |
|
|
25
28
|
*/
|
|
26
29
|
declare const Cmp: import("svelte").Component<Props, {}, "">;
|
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
<script lang="ts">import { Singleselect } from '../select';
|
|
2
|
-
const { size = 'md', step = 30, placeholder = '', disabled = false, readonly = false, inert = false, error = false, borderless = false, id, name, 'aria-label': ariaLabel, 'aria-describedby': ariaDescribedby, 'aria-required': ariaRequired, ...mode } = $props();
|
|
2
|
+
const { size = 'md', step = 30, min, placeholder = '', disabled = false, readonly = false, inert = false, error = false, borderless = false, id, name, 'aria-label': ariaLabel, 'aria-describedby': ariaDescribedby, 'aria-required': ariaRequired, ...mode } = $props();
|
|
3
3
|
const MINUTES_PER_DAY = 24 * 60;
|
|
4
4
|
const pad = (n) => n.toString().padStart(2, '0');
|
|
5
5
|
const stepOptions = $derived.by(() => {
|
|
6
6
|
const count = Math.max(1, Math.floor(MINUTES_PER_DAY / step));
|
|
7
|
-
|
|
7
|
+
const grid = Array.from({ length: count }, (_, i) => {
|
|
8
8
|
const total = i * step;
|
|
9
9
|
const label = `${pad(Math.floor(total / 60))}:${pad(total % 60)}`;
|
|
10
10
|
return { label, value: label };
|
|
11
11
|
});
|
|
12
|
+
return min ? grid.filter((option) => option.value >= min) : grid;
|
|
12
13
|
});
|
|
13
14
|
const options = $derived.by(() => {
|
|
14
15
|
const current = mode.value;
|
|
@@ -3,6 +3,8 @@ type BaseProps = {
|
|
|
3
3
|
size?: 'sm' | 'md' | 'lg';
|
|
4
4
|
/** Minute interval between generated options. @default 30 */
|
|
5
5
|
step?: number;
|
|
6
|
+
/** Earliest selectable time (`'HH:mm'`, inclusive) — earlier grid slots are hidden; an earlier `value` still renders. */
|
|
7
|
+
min?: string;
|
|
6
8
|
placeholder?: string;
|
|
7
9
|
disabled?: boolean;
|
|
8
10
|
/** Focusable, value visible, but selection cannot change. */
|
|
@@ -140,20 +140,27 @@ DialogContainer — wraps the native `<dialog>` element with sizing, positioning
|
|
|
140
140
|
padding: var(--_dialog-container--padding);
|
|
141
141
|
--_cross-browser-scrollbar--thumb-color: var(--sc-kit--color--border--strong);
|
|
142
142
|
--_cross-browser-scrollbar--track-color: var(--scrollbar--track-color, transparent);
|
|
143
|
+
--_cross-browser-scrollbar--gutter: 0px;
|
|
143
144
|
}
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
145
|
+
@supports selector(::-webkit-scrollbar) {
|
|
146
|
+
.dialog-container {
|
|
147
|
+
scrollbar-color: auto;
|
|
148
|
+
scrollbar-width: auto;
|
|
149
|
+
}
|
|
150
|
+
.dialog-container::-webkit-scrollbar {
|
|
151
|
+
width: 6px;
|
|
152
|
+
height: 6px;
|
|
153
|
+
}
|
|
154
|
+
.dialog-container::-webkit-scrollbar-track {
|
|
155
|
+
background: var(--_cross-browser-scrollbar--track-color);
|
|
156
|
+
border-radius: 100vw;
|
|
157
|
+
}
|
|
158
|
+
.dialog-container::-webkit-scrollbar-thumb {
|
|
159
|
+
background: var(--_cross-browser-scrollbar--thumb-color);
|
|
160
|
+
border-radius: 100vw;
|
|
161
|
+
}
|
|
155
162
|
}
|
|
156
|
-
@supports (scrollbar
|
|
163
|
+
@supports not selector(::-webkit-scrollbar) {
|
|
157
164
|
.dialog-container {
|
|
158
165
|
scrollbar-color: var(--_cross-browser-scrollbar--thumb-color) var(--_cross-browser-scrollbar--track-color);
|
|
159
166
|
scrollbar-width: thin;
|
|
@@ -136,20 +136,27 @@ A structured dialog panel with header (title + close button), scrollable body, a
|
|
|
136
136
|
padding: var(--_--dialog--body--padding-block) var(--_--dialog--body--padding-inline);
|
|
137
137
|
--_cross-browser-scrollbar--thumb-color: var(--scrollbar--thumb-color, light-dark(#d1d5db, #4b5563));
|
|
138
138
|
--_cross-browser-scrollbar--track-color: var(--scrollbar--track-color, transparent);
|
|
139
|
+
--_cross-browser-scrollbar--gutter: 0px;
|
|
139
140
|
}
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
141
|
+
@supports selector(::-webkit-scrollbar) {
|
|
142
|
+
.dialog__body {
|
|
143
|
+
scrollbar-color: auto;
|
|
144
|
+
scrollbar-width: auto;
|
|
145
|
+
}
|
|
146
|
+
.dialog__body::-webkit-scrollbar {
|
|
147
|
+
width: 6px;
|
|
148
|
+
height: 6px;
|
|
149
|
+
}
|
|
150
|
+
.dialog__body::-webkit-scrollbar-track {
|
|
151
|
+
background: var(--_cross-browser-scrollbar--track-color);
|
|
152
|
+
border-radius: 100vw;
|
|
153
|
+
}
|
|
154
|
+
.dialog__body::-webkit-scrollbar-thumb {
|
|
155
|
+
background: var(--_cross-browser-scrollbar--thumb-color);
|
|
156
|
+
border-radius: 100vw;
|
|
157
|
+
}
|
|
151
158
|
}
|
|
152
|
-
@supports (scrollbar
|
|
159
|
+
@supports not selector(::-webkit-scrollbar) {
|
|
153
160
|
.dialog__body {
|
|
154
161
|
scrollbar-color: var(--_cross-browser-scrollbar--thumb-color) var(--_cross-browser-scrollbar--track-color);
|
|
155
162
|
scrollbar-width: thin;
|