@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.
Files changed (49) hide show
  1. package/README.md +2 -2
  2. package/dist/core/files/file-decorators.d.ts +3 -0
  3. package/dist/core/files/file-decorators.js +2 -0
  4. package/dist/core/files/file-validation-rules.d.ts +3 -8
  5. package/dist/core/files/file-with-blob-data-helper.d.ts +1 -1
  6. package/dist/core/files/file-with-blob-data-helper.js +1 -1
  7. package/dist/core/files/image-resizer.d.ts +17 -29
  8. package/dist/core/files/image-resizer.js +30 -57
  9. package/dist/core/files/index.d.ts +4 -4
  10. package/dist/core/files/index.js +2 -2
  11. package/dist/core/files/types.d.ts +14 -2
  12. package/dist/core/files/types.js +1 -10
  13. package/dist/core/files/upload-media-store.svelte.d.ts +11 -17
  14. package/dist/core/files/upload-media-store.svelte.js +32 -35
  15. package/dist/styles/_mixins.scss +71 -17
  16. package/dist/styles/reset.css +19 -12
  17. package/dist/ui/ai-panel/cmp.ai-panel.svelte +27 -0
  18. package/dist/ui/cropper/image-editor-dialog/cmp.image-editor-dialog.svelte +8 -12
  19. package/dist/ui/cropper/image-editor-dialog/crop-and-resize.d.ts +6 -13
  20. package/dist/ui/cropper/image-editor-dialog/crop-and-resize.js +6 -18
  21. package/dist/ui/cropper/image-editor-dialog/open-image-editor-dialog.d.ts +6 -1
  22. package/dist/ui/cropper/image-editor-dialog/open-image-editor-dialog.js +6 -1
  23. package/dist/ui/cropper/image-editor-dialog/types.d.ts +8 -5
  24. package/dist/ui/cropper/img-cropper/img-cropper.svelte.d.ts +6 -2
  25. package/dist/ui/cropper/img-cropper/img-cropper.svelte.js +18 -11
  26. package/dist/ui/date-picker/cmp.date-time-picker.svelte +67 -8
  27. package/dist/ui/date-picker/cmp.date-time-picker.svelte.d.ts +8 -5
  28. package/dist/ui/date-picker/cmp.time-picker.svelte +3 -2
  29. package/dist/ui/date-picker/cmp.time-picker.svelte.d.ts +2 -0
  30. package/dist/ui/dialog/cmp.dialog-container.svelte +19 -12
  31. package/dist/ui/dialog/cmp.dialog.svelte +19 -12
  32. package/dist/ui/dialog/layout/cmp.stepper-dialog.svelte +54 -0
  33. package/dist/ui/emoji-picker/cmp.emoji-panel.svelte +19 -12
  34. package/dist/ui/file-uploader/cmp.upload-progress-toaster.svelte +20 -11
  35. package/dist/ui/grouped-sidebar/cmp.grouped-sidebar.svelte +20 -11
  36. package/dist/ui/navigation/cmp.nav-menu.svelte +20 -11
  37. package/dist/ui/page-layout/cmp.page-layout.svelte +38 -24
  38. package/dist/ui/page-layout/cmp.page-panel-sidebars.svelte +40 -22
  39. package/dist/ui/page-layout/cmp.page-panel.svelte +20 -11
  40. package/dist/ui/popover/cmp.popover.svelte +19 -12
  41. package/dist/ui/select/cmp.input-suggest.svelte +1 -0
  42. package/dist/ui/select/cmp.singleselect-async.svelte +1 -0
  43. package/dist/ui/select/multiselect-base.svelte +1 -0
  44. package/dist/ui/select/select-core.svelte.js +41 -11
  45. package/dist/ui/select/select-listbox.svelte +42 -19
  46. package/dist/ui/select/select-listbox.svelte.d.ts +5 -3
  47. package/dist/ui/table/table-core.svelte +19 -12
  48. package/dist/ui/textarea/cmp.textarea.svelte +20 -11
  49. package/package.json +4 -7
@@ -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
- textarea::-webkit-scrollbar {
128
- width: 6px;
129
- height: 6px;
130
- }
131
- textarea::-webkit-scrollbar-track {
132
- background: var(--_cross-browser-scrollbar--track-color);
133
- border-radius: 100vw;
134
- }
135
- textarea::-webkit-scrollbar-thumb {
136
- background: var(--_cross-browser-scrollbar--thumb-color);
137
- border-radius: 100vw;
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-color: transparent transparent) {
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 { FileWithBlobDataHelper } from '../../../core/files';
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 croppedFile = Object.assign(FileWithBlobDataHelper.fromBlob(result.blob), {
28
- width: result.width,
29
- height: result.height
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 { FileWithBlobUrl } from '../../../core/files';
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
- * After cropping, downscale to fit these limits. `max1` / `max2` are not tied to width /
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, lets the user crop, and (optionally) downscales the result before returning.
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<FileWithBlobUrl | null>;
19
+ export declare const cropAndResizeImage: (file: File, options?: CropAndResizeOptions) => Promise<CroppedFile | null>;
@@ -1,8 +1,7 @@
1
- import { FileWithBlobDataHelper, resizeBlob } from '../../../core/files';
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, lets the user crop, and (optionally) downscales the result before returning.
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
- if (result.wasCanceled) {
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` — `FileWithBlobUrl` with `width`/`height` at natural resolution.
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` — `FileWithBlobUrl` with `width`/`height` at natural resolution.
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 { FileWithBlobUrl } from '../../../core/files';
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 = FileWithBlobUrl & {
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
- /** Renders the frame to an image, applying a pending selection first; `null` when there's nothing to render or the browser can't. */
139
- save: () => Promise<ImgCropperSaveResult | null>;
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
- /** Renders the frame to an image, applying a pending selection first; `null` when there's nothing to render or the browser can't. */
407
- save = async () => {
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 scale = nativeArea > areaLimit ? nativeScale * Math.sqrt(areaLimit / nativeArea) : nativeScale;
454
- const width = Math.max(1, Math.floor(frame.w * scale));
455
- const height = Math.max(1, Math.floor(frame.h * scale));
456
- const left = Math.round(box.x * scale);
457
- const top = Math.round(box.y * scale);
458
- const right = Math.round((box.x + box.w) * scale);
459
- const bottom = Math.round((box.y + box.h) * scale);
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={min} on={{ change: onDateChange }} />
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. The time row appears
44
- only once a date is picked (defaulting to `12:00`); `size` and `step` thread down to both controls.
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 | `6rem` |
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--time-width: var(--sc-kit--date-time-picker--time-width, 6rem);
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
- /** Earliest selectable date (inclusive). */
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. The time row appears
17
- * only once a date is picked (defaulting to `12:00`); `size` and `step` thread down to both controls.
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 | `6rem` |
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
- return Array.from({ length: count }, (_, i) => {
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
- .dialog-container::-webkit-scrollbar {
145
- width: 6px;
146
- height: 6px;
147
- }
148
- .dialog-container::-webkit-scrollbar-track {
149
- background: var(--_cross-browser-scrollbar--track-color);
150
- border-radius: 100vw;
151
- }
152
- .dialog-container::-webkit-scrollbar-thumb {
153
- background: var(--_cross-browser-scrollbar--thumb-color);
154
- border-radius: 100vw;
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-color: transparent transparent) {
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
- .dialog__body::-webkit-scrollbar {
141
- width: 6px;
142
- height: 6px;
143
- }
144
- .dialog__body::-webkit-scrollbar-track {
145
- background: var(--_cross-browser-scrollbar--track-color);
146
- border-radius: 100vw;
147
- }
148
- .dialog__body::-webkit-scrollbar-thumb {
149
- background: var(--_cross-browser-scrollbar--thumb-color);
150
- border-radius: 100vw;
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-color: transparent transparent) {
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;