@atmo-dev/kit 0.4.0 → 0.6.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 (150) hide show
  1. package/README.md +119 -0
  2. package/dist/at-tags.d.ts +18 -0
  3. package/dist/at-tags.js +18 -0
  4. package/dist/components/AtTags.svelte +17 -0
  5. package/dist/components/AtTags.svelte.d.ts +7 -0
  6. package/dist/events/DatePicker.svelte +222 -0
  7. package/dist/events/DatePicker.svelte.d.ts +12 -0
  8. package/dist/events/DateTimePicker.svelte +112 -0
  9. package/dist/events/DateTimePicker.svelte.d.ts +10 -0
  10. package/dist/events/EventAttendees.svelte +199 -0
  11. package/dist/events/EventAttendees.svelte.d.ts +19 -0
  12. package/dist/events/EventCard.svelte +148 -0
  13. package/dist/events/EventCard.svelte.d.ts +8 -0
  14. package/dist/events/EventComments.svelte +98 -0
  15. package/dist/events/EventComments.svelte.d.ts +8 -0
  16. package/dist/events/EventEditor.svelte +540 -0
  17. package/dist/events/EventEditor.svelte.d.ts +18 -0
  18. package/dist/events/EventRsvp.svelte +178 -0
  19. package/dist/events/EventRsvp.svelte.d.ts +16 -0
  20. package/dist/events/EventView.svelte +488 -0
  21. package/dist/events/EventView.svelte.d.ts +23 -0
  22. package/dist/events/ImageDropper.svelte +66 -0
  23. package/dist/events/ImageDropper.svelte.d.ts +7 -0
  24. package/dist/events/Map.svelte +36 -0
  25. package/dist/events/Map.svelte.d.ts +8 -0
  26. package/dist/events/PostToBlueskyModal.svelte +246 -0
  27. package/dist/events/PostToBlueskyModal.svelte.d.ts +22 -0
  28. package/dist/events/ShareModal.svelte +174 -0
  29. package/dist/events/ShareModal.svelte.d.ts +23 -0
  30. package/dist/events/StarterPackButton.svelte +72 -0
  31. package/dist/events/StarterPackButton.svelte.d.ts +10 -0
  32. package/dist/events/ThemeApply.svelte +30 -0
  33. package/dist/events/ThemeApply.svelte.d.ts +7 -0
  34. package/dist/events/ThemeBackground.svelte +33 -0
  35. package/dist/events/ThemeBackground.svelte.d.ts +7 -0
  36. package/dist/events/ThemePicker.svelte +104 -0
  37. package/dist/events/ThemePicker.svelte.d.ts +7 -0
  38. package/dist/events/ThumbnailPresets.svelte +71 -0
  39. package/dist/events/ThumbnailPresets.svelte.d.ts +11 -0
  40. package/dist/events/TimePicker.svelte +117 -0
  41. package/dist/events/TimePicker.svelte.d.ts +10 -0
  42. package/dist/events/TimezonePicker.svelte +67 -0
  43. package/dist/events/TimezonePicker.svelte.d.ts +6 -0
  44. package/dist/events/VodPlayer.svelte +154 -0
  45. package/dist/events/VodPlayer.svelte.d.ts +14 -0
  46. package/dist/events/VodTranscript.svelte +75 -0
  47. package/dist/events/VodTranscript.svelte.d.ts +8 -0
  48. package/dist/events/atproto-helpers.d.ts +21 -0
  49. package/dist/events/atproto-helpers.js +61 -0
  50. package/dist/events/cal/helper.d.ts +1 -0
  51. package/dist/events/cal/helper.js +20 -0
  52. package/dist/events/cal/ical.d.ts +22 -0
  53. package/dist/events/cal/ical.js +188 -0
  54. package/dist/events/cal/sanitize.d.ts +3 -0
  55. package/dist/events/cal/sanitize.js +25 -0
  56. package/dist/events/conference.d.ts +65 -0
  57. package/dist/events/conference.js +61 -0
  58. package/dist/events/contrail.d.ts +49 -0
  59. package/dist/events/contrail.js +12 -0
  60. package/dist/events/date-format.d.ts +22 -0
  61. package/dist/events/date-format.js +43 -0
  62. package/dist/events/editor/LinkedRecordsPicker.svelte +104 -0
  63. package/dist/events/editor/LinkedRecordsPicker.svelte.d.ts +12 -0
  64. package/dist/events/editor/LinksSection.svelte +154 -0
  65. package/dist/events/editor/LinksSection.svelte.d.ts +13 -0
  66. package/dist/events/editor/LocationSection.svelte +163 -0
  67. package/dist/events/editor/LocationSection.svelte.d.ts +8 -0
  68. package/dist/events/editor/RecurringModal.svelte +313 -0
  69. package/dist/events/editor/RecurringModal.svelte.d.ts +30 -0
  70. package/dist/events/editor/ThemeSection.svelte +24 -0
  71. package/dist/events/editor/ThemeSection.svelte.d.ts +7 -0
  72. package/dist/events/editor/ThumbnailSection.svelte +181 -0
  73. package/dist/events/editor/ThumbnailSection.svelte.d.ts +13 -0
  74. package/dist/events/editor/adapter.d.ts +73 -0
  75. package/dist/events/editor/adapter.js +9 -0
  76. package/dist/events/editor/location.d.ts +16 -0
  77. package/dist/events/editor/location.js +50 -0
  78. package/dist/events/editor/save.d.ts +43 -0
  79. package/dist/events/editor/save.js +168 -0
  80. package/dist/events/editor/types.d.ts +39 -0
  81. package/dist/events/editor/types.js +9 -0
  82. package/dist/events/event-media.d.ts +4 -0
  83. package/dist/events/event-media.js +4 -0
  84. package/dist/events/event-types.d.ts +79 -0
  85. package/dist/events/event-types.js +11 -0
  86. package/dist/events/event-view/AddToCalendarButton.svelte +31 -0
  87. package/dist/events/event-view/AddToCalendarButton.svelte.d.ts +9 -0
  88. package/dist/events/event-view/EventBadges.svelte +20 -0
  89. package/dist/events/event-view/EventBadges.svelte.d.ts +7 -0
  90. package/dist/events/event-view/EventDateBlock.svelte +43 -0
  91. package/dist/events/event-view/EventDateBlock.svelte.d.ts +7 -0
  92. package/dist/events/event-view/EventHostedBy.svelte +61 -0
  93. package/dist/events/event-view/EventHostedBy.svelte.d.ts +16 -0
  94. package/dist/events/event-view/EventLinksList.svelte +24 -0
  95. package/dist/events/event-view/EventLinksList.svelte.d.ts +9 -0
  96. package/dist/events/event-view/EventLocationBlock.svelte +62 -0
  97. package/dist/events/event-view/EventLocationBlock.svelte.d.ts +7 -0
  98. package/dist/events/event-view/EventLocationMap.svelte +66 -0
  99. package/dist/events/event-view/EventLocationMap.svelte.d.ts +7 -0
  100. package/dist/events/event-view/ExternalRsvpNotice.svelte +34 -0
  101. package/dist/events/event-view/ExternalRsvpNotice.svelte.d.ts +6 -0
  102. package/dist/events/event-view/StreamPlacePlayer.svelte +209 -0
  103. package/dist/events/event-view/StreamPlacePlayer.svelte.d.ts +8 -0
  104. package/dist/events/event-view/format.d.ts +10 -0
  105. package/dist/events/event-view/format.js +112 -0
  106. package/dist/events/event-view/locations.d.ts +36 -0
  107. package/dist/events/event-view/locations.js +243 -0
  108. package/dist/events/event-view/plyr-icons.d.ts +3 -0
  109. package/dist/events/event-view/plyr-icons.js +46 -0
  110. package/dist/events/linked-records.d.ts +18 -0
  111. package/dist/events/linked-records.js +11 -0
  112. package/dist/events/profile-url.d.ts +3 -0
  113. package/dist/events/profile-url.js +11 -0
  114. package/dist/events/schedule/ConferenceTimetable.svelte +340 -0
  115. package/dist/events/schedule/ConferenceTimetable.svelte.d.ts +22 -0
  116. package/dist/events/schedule/DaySchedule.svelte +298 -0
  117. package/dist/events/schedule/DaySchedule.svelte.d.ts +26 -0
  118. package/dist/events/schedule/ScheduleEventCell.svelte +166 -0
  119. package/dist/events/schedule/ScheduleEventCell.svelte.d.ts +25 -0
  120. package/dist/events/schedule/schedule-utils.d.ts +44 -0
  121. package/dist/events/schedule/schedule-utils.js +166 -0
  122. package/dist/events/starter-pack-url.d.ts +3 -0
  123. package/dist/events/starter-pack-url.js +16 -0
  124. package/dist/events/theme.d.ts +9 -0
  125. package/dist/events/theme.js +36 -0
  126. package/dist/events/themes/Blobs.svelte +64 -0
  127. package/dist/events/themes/Blobs.svelte.d.ts +18 -0
  128. package/dist/events/themes/Butterflies.svelte +183 -0
  129. package/dist/events/themes/Butterflies.svelte.d.ts +3 -0
  130. package/dist/events/themes/Fireflies.svelte +134 -0
  131. package/dist/events/themes/Fireflies.svelte.d.ts +3 -0
  132. package/dist/events/themes/Kaleidoscope.svelte +183 -0
  133. package/dist/events/themes/Kaleidoscope.svelte.d.ts +3 -0
  134. package/dist/events/themes/Matrix.svelte +153 -0
  135. package/dist/events/themes/Matrix.svelte.d.ts +3 -0
  136. package/dist/events/themes/Stars.svelte +98 -0
  137. package/dist/events/themes/Stars.svelte.d.ts +3 -0
  138. package/dist/events/thumbnails/designs.d.ts +18 -0
  139. package/dist/events/thumbnails/designs.js +316 -0
  140. package/dist/events/view-data.d.ts +38 -0
  141. package/dist/events/view-data.js +1 -0
  142. package/dist/events-data.d.ts +15 -0
  143. package/dist/events-data.js +9 -0
  144. package/dist/events-editor.d.ts +2 -0
  145. package/dist/events-editor.js +2 -0
  146. package/dist/events.d.ts +19 -0
  147. package/dist/events.js +17 -0
  148. package/dist/index.d.ts +3 -0
  149. package/dist/index.js +2 -0
  150. package/package.json +50 -4
package/README.md CHANGED
@@ -9,6 +9,125 @@ Svelte 5 and Tailwind CSS 4 components for Atmo apps.
9
9
  - Releases: `pnpm changeset`, then merge the generated release PR. [Details](.changeset/README.md).
10
10
  - License: [MIT](LICENSE).
11
11
 
12
+ ## AT record metadata (AT Tags)
13
+
14
+ Use `AtTags` to let other clients discover which AT Protocol records and identities
15
+ an HTML page represents. It renders server-side `<meta name="at:…" content="at://…">`
16
+ tags in the document head and updates/removes them when its props change. It needs
17
+ no theme, styles, network requests, or app state.
18
+
19
+ The [AT Tags proposal](https://tangled.org/chrisshank.com/at-tags) is a draft. Its four
20
+ standard relationships are:
21
+
22
+ | Property | Meaning | Content |
23
+ | ----------- | ------------------------------------------------------------------------ | --------------- |
24
+ | `canonical` | The record this page represents; deleting it would remove the page. | A record AT URI |
25
+ | `alternate` | Auxiliary records shown or referenced; the page survives their deletion. | A record AT URI |
26
+ | `author` | Account(s) who authored the page's content. | `at://<DID>` |
27
+ | `me` | Account(s) this site or section belongs to. | `at://<DID>` |
28
+
29
+ Each property accepts an array; multiple values produce separate tags, with exact
30
+ duplicates removed within each property. Use meta **names**, not Open Graph
31
+ `property` attributes or `<link href="at://…">`. These tags supplement—not
32
+ replace—your title, description, HTTPS canonical URL and Open Graph metadata.
33
+
34
+ For a page backed by one record authored by its repository owner, use the
35
+ framework-free `recordAtTags` helper in your server loader:
36
+
37
+ ```ts
38
+ import { recordAtTags } from '@atmo-dev/kit/at-tags';
39
+
40
+ // After app-owned loading, visibility/moderation checks and handle resolution:
41
+ return {
42
+ atTags: recordAtTags(record, relatedRecords)
43
+ };
44
+ ```
45
+
46
+ `record` and each optional `relatedRecords` entry only need `{ uri: string }`.
47
+ The helper sets `canonical`, derives `author` from the record's repository DID,
48
+ and lists related records as `alternate`, excluding the canonical URI. It throws
49
+ `SyntaxError` for non-record or handle-based URIs: pass resolved DID-based record
50
+ URIs, not a URL path or viewer parameter. It does not resolve or fetch anything.
51
+
52
+ Render the metadata once in a SvelteKit root layout (covering embeds too):
53
+
54
+ ```svelte
55
+ <script lang="ts">
56
+ import { page } from '$app/state';
57
+ import { AtTags } from '@atmo-dev/kit';
58
+
59
+ let { children } = $props();
60
+ </script>
61
+
62
+ <AtTags tags={page.error ? undefined : page.data.atTags} />
63
+ {@render children()}
64
+ ```
65
+
66
+ Add `atTags?: import('@atmo-dev/kit/at-tags').AtTagMetadata` to `App.PageData`.
67
+ In other Svelte apps, pass the current page's metadata directly; set `tags` to
68
+ `undefined` on pages with no relationships. Do not mount competing tag owners.
69
+
70
+ You can supply `AtTagMetadata` directly for multiple canonical records, different
71
+ or additional authors, or an account section:
72
+
73
+ ```ts
74
+ import type { AtTagMetadata } from '@atmo-dev/kit/at-tags';
75
+
76
+ const atTags: AtTagMetadata = {
77
+ me: [`at://${profile.did}`],
78
+ alternate: profile.uri ? [profile.uri] : []
79
+ };
80
+ ```
81
+
82
+ An account's events page might survive deletion of its optional Bluesky profile,
83
+ so that profile record is an alternate, not necessarily canonical. Likewise,
84
+ embedded comments are alternate records, not the page's authors. `me` is not the
85
+ signed-in viewer or every record author; do not assign it site-wide unless the
86
+ site actually belongs to that account. Ordinary discovery and creation pages
87
+ usually have no canonical record.
88
+
89
+ Apps own these semantic choices, record loading, identity resolution, visibility,
90
+ and error-page suppression. The component escapes attributes but does not validate
91
+ raw metadata or verify record existence. Use record URIs with collection and rkey,
92
+ and bare `at://<DID>` identity URIs without a trailing slash. Only the four standard
93
+ properties are supported; parsing/scraping pages and draft custom namespaces are
94
+ outside this API.
95
+
96
+ ## Events
97
+
98
+ The event UI formerly in `@atmo-dev/events-ui` lives in this package:
99
+
100
+ ```svelte
101
+ <script lang="ts">
102
+ import { EventEditor, EventView, EventCard } from '@atmo-dev/kit/events';
103
+ import type { EditorAdapter, EditorViewer } from '@atmo-dev/kit/events-data';
104
+
105
+ let { adapter, viewer, event, data, pageUrl } = $props();
106
+ </script>
107
+
108
+ <EventCard {event} />
109
+ <EventEditor {adapter} {viewer} eventData={event} actorDid={event.did} rkey={event.rkey} />
110
+ <EventView {adapter} {viewer} {data} {pageUrl} />
111
+ ```
112
+
113
+ - `@atmo-dev/kit/events`: event cards, views, editor, RSVP/attendee/comment UI, starter-pack actions, conference timetable, VOD player/transcript and date/time/image controls. Event backgrounds and editor sections stay internal.
114
+ - `@atmo-dev/kit/events-data`: structural event types, adapter contracts, conference/date helpers, URL helpers, theme values, linked-record types and thumbnail selection. This entry is safe in plain Node and server routes: no Svelte, CSS, maps or players are loaded. Browser helpers such as `compressImage` only access browser APIs when called.
115
+ - `@atmo-dev/kit/events/editor`: `buildEventRecord` and `buildThumbnailMedia` for hosts that need explicit record construction. This separate entry includes browser-oriented thumbnail code; it is not part of the pure data API.
116
+
117
+ Apps implement `EditorAdapter` for authentication prompts, repository IO, uploads, save/delete navigation, and optional linked-record transport/indexing notifications. Keep sessions, Cloudflare bindings, app routes, server endpoints, caching and moderation in the app. The editor/view never imports a consumer's app state. Existing `EventView` page-data shape and RSVP behavior are preserved in this migration.
118
+
119
+ Full event UI requires optional peers (ordinary Kit consumers do not):
120
+
121
+ ```sh
122
+ pnpm add maplibre-gl@^6.16.0 svelte-maplibre-gl@^2.2.1 plyr@^3.8.5
123
+ ```
124
+
125
+ Import `theme.css` as above; it scans the packaged event components too, so no separate Tailwind source path is needed. Event exports are intentionally absent from the main Kit entry.
126
+
127
+ The initial move preserves legacy defaults: `/p/:actor/e/:rkey` event and `/p/:actor` profile links, `/api/geocoding`, Bluesky's public comments API, the `?created` share flow and RSVP's `createdWith` identifier. Hosts currently need compatible routes/endpoints; `shareUrlOverride` changes outbound share links. These defaults are not a new universal routing contract. Further host-policy extraction can happen independently of the package move.
128
+
129
+ Event browser tests are in `src/tests/events`; run `pnpm test:unit --run --project events`. `pnpm test:package` checks the actual built server entry and its runtime import graph without Vite aliases.
130
+
12
131
  ## Photos, Grain albums and Streamplace
13
132
 
14
133
  `PhotoGrid` and `PhotoLightbox` work with provider-neutral `Photo[]` data (`src`, optional `thumbnail`, `alt`, dimensions and `contentWarning`). The grid preserves natural image proportions in responsive masonry columns, with a subtle 2% hover zoom disabled for reduced motion. It gates warned images before mounting them and opens a borderless fullscreen viewer. The viewer has a dimmed backdrop, previous/next arrows, close and a small photo-position counter; native horizontal scrolling with CSS scroll snap makes photos follow your finger or trackpad, with inertia rather than switching only on release. Mouse/pen drags also move the track directly. Swipes loop like the arrows; vertical pans do not navigate and controls are separate from the drag surface. Native pinch zoom, arrow keys, Escape, focus trapping and restoration are preserved; release animations respect reduced motion. `PhotoLightbox` also works independently with `bind:open` and `bind:index`; callers using it directly must first filter or reveal any warned photos. Apps own URL validation and moderation policy.
@@ -0,0 +1,18 @@
1
+ /** Standard AT Tags draft properties. Identity values are `at://<DID>` URIs. */
2
+ export interface AtTagMetadata {
3
+ canonical?: readonly string[];
4
+ alternate?: readonly string[];
5
+ author?: readonly string[];
6
+ me?: readonly string[];
7
+ }
8
+ /** A resolved record reference; no lexicon-specific fields are needed. */
9
+ export interface AtTagRecordRef {
10
+ uri: string;
11
+ }
12
+ /**
13
+ * Metadata for a page backed by one record, authored by its repository owner.
14
+ * Supply only auxiliary records represented on the page. Throws SyntaxError for
15
+ * non-record or non-DID-based URIs; resolve handles before calling this helper.
16
+ * For multiple canonical records or different authors, supply metadata directly.
17
+ */
18
+ export declare function recordAtTags(record: AtTagRecordRef, alternates?: readonly AtTagRecordRef[]): AtTagMetadata;
@@ -0,0 +1,18 @@
1
+ import { parseCanonicalResourceUri } from '@atcute/lexicons/syntax';
2
+ /**
3
+ * Metadata for a page backed by one record, authored by its repository owner.
4
+ * Supply only auxiliary records represented on the page. Throws SyntaxError for
5
+ * non-record or non-DID-based URIs; resolve handles before calling this helper.
6
+ * For multiple canonical records or different authors, supply metadata directly.
7
+ */
8
+ export function recordAtTags(record, alternates = []) {
9
+ const { repo } = parseCanonicalResourceUri(record.uri);
10
+ const alternate = [...new Set(alternates.map(({ uri }) => uri))].filter((uri) => uri !== record.uri);
11
+ for (const uri of alternate)
12
+ parseCanonicalResourceUri(uri);
13
+ return {
14
+ canonical: [record.uri],
15
+ author: [`at://${repo}`],
16
+ alternate
17
+ };
18
+ }
@@ -0,0 +1,17 @@
1
+ <script lang="ts">
2
+ import type { AtTagMetadata } from '../at-tags.js';
3
+
4
+ export interface AtTagsProps {
5
+ tags?: AtTagMetadata;
6
+ }
7
+
8
+ let { tags }: AtTagsProps = $props();
9
+ </script>
10
+
11
+ <svelte:head>
12
+ {#each ['canonical', 'alternate', 'author', 'me'] as property (property)}
13
+ {#each [...new Set(tags?.[property as keyof AtTagMetadata] ?? [])] as uri (uri)}
14
+ <meta name={`at:${property}`} content={uri} />
15
+ {/each}
16
+ {/each}
17
+ </svelte:head>
@@ -0,0 +1,7 @@
1
+ import type { AtTagMetadata } from '../at-tags.js';
2
+ export interface AtTagsProps {
3
+ tags?: AtTagMetadata;
4
+ }
5
+ declare const AtTags: import("svelte").Component<AtTagsProps, {}, "">;
6
+ type AtTags = ReturnType<typeof AtTags>;
7
+ export default AtTags;
@@ -0,0 +1,222 @@
1
+ <script lang="ts">
2
+ // @ts-nocheck
3
+ import { DatePicker } from 'bits-ui';
4
+ import { CalendarDate, type DateValue } from '@internationalized/date';
5
+ import { untrack } from 'svelte';
6
+ import { Icon } from '../index.js';
7
+ import { ChevronLeft, ChevronRight } from '@sketchyicons/svelte';
8
+
9
+ let {
10
+ value = $bindable(''),
11
+ required = false,
12
+ label = 'Date',
13
+ minValue = '',
14
+ locale = 'en',
15
+ onSelect
16
+ }: {
17
+ value: string;
18
+ required?: boolean;
19
+ label?: string;
20
+ minValue?: string;
21
+ locale?: string;
22
+ onSelect?: () => void;
23
+ } = $props();
24
+
25
+ let isOpen = $state(false);
26
+
27
+ const now = new Date();
28
+ const currentYear = now.getFullYear();
29
+ const yearRange = Array.from({ length: 7 }, (_, i) => currentYear - 1 + i);
30
+ const today = new Date();
31
+ const todayDay = today.getDate();
32
+ const todayMonth = today.getMonth() + 1;
33
+ const todayYear = today.getFullYear();
34
+
35
+ let internalValue: CalendarDate | undefined = $state(undefined);
36
+
37
+ function parseDateStr(str: string): CalendarDate | undefined {
38
+ if (!str) return undefined;
39
+ const [yearStr, monthStr, dayStr] = str.split('-');
40
+ const year = parseInt(yearStr, 10);
41
+ const month = parseInt(monthStr, 10);
42
+ const day = parseInt(dayStr, 10);
43
+ if (isNaN(year) || isNaN(month) || isNaN(day)) return undefined;
44
+ return new CalendarDate(year, month, day);
45
+ }
46
+
47
+ function formatDateStr(dt: CalendarDate): string {
48
+ const y = String(dt.year).padStart(4, '0');
49
+ const m = String(dt.month).padStart(2, '0');
50
+ const d = String(dt.day).padStart(2, '0');
51
+ return `${y}-${m}-${d}`;
52
+ }
53
+
54
+ let internalMinValue: CalendarDate | undefined = $derived.by(() => {
55
+ return parseDateStr(minValue);
56
+ });
57
+
58
+ $effect(() => {
59
+ const parsed = parseDateStr(value);
60
+ untrack(() => {
61
+ if (parsed) {
62
+ if (
63
+ !internalValue ||
64
+ parsed.year !== internalValue.year ||
65
+ parsed.month !== internalValue.month ||
66
+ parsed.day !== internalValue.day
67
+ ) {
68
+ internalValue = parsed;
69
+ previousValue = parsed;
70
+ }
71
+ } else {
72
+ internalValue = undefined;
73
+ }
74
+ });
75
+ });
76
+
77
+ let previousValue: CalendarDate | undefined = $state(undefined);
78
+
79
+ function handleValueChange(newVal: DateValue | undefined) {
80
+ if (newVal && newVal instanceof CalendarDate) {
81
+ previousValue = newVal;
82
+ internalValue = newVal;
83
+ value = formatDateStr(newVal);
84
+ } else if (!newVal && previousValue) {
85
+ // Prevent deselection — restore previous value
86
+ internalValue = previousValue;
87
+ }
88
+ }
89
+
90
+ function handleOpenChangeComplete(open: boolean) {
91
+ if (!open && internalValue) {
92
+ onSelect?.();
93
+ }
94
+ }
95
+
96
+ function formatDisplayDate(compact: boolean) {
97
+ if (!internalValue) return '';
98
+ const date = new Date(internalValue.year, internalValue.month - 1, internalValue.day);
99
+ const opts: Intl.DateTimeFormatOptions = {
100
+ ...(compact ? {} : { weekday: 'short' }),
101
+ month: 'short',
102
+ day: 'numeric'
103
+ };
104
+ if (internalValue.year !== currentYear) {
105
+ opts.year = 'numeric';
106
+ }
107
+ return date.toLocaleDateString(locale, opts);
108
+ }
109
+ let displayText = $derived(formatDisplayDate(false));
110
+ let compactText = $derived(formatDisplayDate(true));
111
+ </script>
112
+
113
+ <DatePicker.Root
114
+ bind:value={internalValue}
115
+ bind:open={isOpen}
116
+ onValueChange={handleValueChange}
117
+ onOpenChangeComplete={handleOpenChangeComplete}
118
+ minValue={internalMinValue}
119
+ granularity="day"
120
+ fixedWeeks={true}
121
+ weekdayFormat="short"
122
+ {locale}
123
+ {required}
124
+ >
125
+ <DatePicker.Trigger
126
+ aria-label={`${label}: ${displayText || 'Select date'}`}
127
+ class="flex h-11 min-w-0 flex-1 cursor-pointer items-center rounded-ui border border-line bg-recess px-2 text-xs text-ink shadow-(--ui-recess-shadow) transition-colors hover:border-control-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus min-[360px]:px-3 min-[360px]:text-sm"
128
+ >
129
+ <span class="truncate select-none">
130
+ {#if displayText}
131
+ <span class="min-[360px]:hidden">{compactText}</span>
132
+ <span class="hidden min-[360px]:inline">{displayText}</span>
133
+ {:else}
134
+ <span class="text-base-400 dark:text-base-500">Select date</span>
135
+ {/if}
136
+ </span>
137
+ </DatePicker.Trigger>
138
+
139
+ <DatePicker.Content
140
+ sideOffset={8}
141
+ class="z-50 max-w-[calc(100vw-2rem)] rounded-panel border border-line bg-page p-3 text-ink"
142
+ >
143
+ <DatePicker.Calendar>
144
+ {#snippet children({ months, weekdays })}
145
+ <DatePicker.Header class="flex items-center justify-between">
146
+ <DatePicker.PrevButton
147
+ class="inline-flex size-8 items-center justify-center rounded-lg text-base-500 hover:bg-base-200 dark:text-base-400 dark:hover:bg-base-700"
148
+ >
149
+ <Icon img={ChevronLeft} size={20} />
150
+ </DatePicker.PrevButton>
151
+
152
+ <div class="flex items-center gap-1.5">
153
+ <DatePicker.MonthSelect
154
+ monthFormat="long"
155
+ class="cursor-pointer border-0 bg-transparent text-sm font-medium text-base-900 outline-none hover:text-accent-500 focus:ring-0 focus:outline-none dark:text-base-100 dark:hover:text-accent-400"
156
+ />
157
+ <DatePicker.YearSelect
158
+ years={yearRange}
159
+ class="cursor-pointer border-0 bg-transparent text-sm font-medium text-base-900 outline-none hover:text-accent-500 focus:ring-0 focus:outline-none dark:text-base-100 dark:hover:text-accent-400"
160
+ />
161
+ </div>
162
+
163
+ <DatePicker.NextButton
164
+ class="inline-flex size-8 items-center justify-center rounded-lg text-base-500 hover:bg-base-200 dark:text-base-400 dark:hover:bg-base-700"
165
+ >
166
+ <Icon img={ChevronRight} size={20} />
167
+ </DatePicker.NextButton>
168
+ </DatePicker.Header>
169
+
170
+ {#each months as month (month.value.month)}
171
+ <DatePicker.Grid class="mt-3 w-full">
172
+ <DatePicker.GridHead>
173
+ <DatePicker.GridRow class="flex w-full">
174
+ {#each weekdays as weekday, i (i)}
175
+ <DatePicker.HeadCell
176
+ class="flex-1 text-center text-xs font-medium text-base-400 dark:text-base-500"
177
+ >
178
+ {weekday}
179
+ </DatePicker.HeadCell>
180
+ {/each}
181
+ </DatePicker.GridRow>
182
+ </DatePicker.GridHead>
183
+
184
+ <DatePicker.GridBody>
185
+ {#each month.weeks as week, weekIndex (weekIndex)}
186
+ <DatePicker.GridRow class="flex w-full">
187
+ {#each week as day (day.toString())}
188
+ <DatePicker.Cell date={day} month={month.value} class="flex-1 p-0.5">
189
+ <DatePicker.Day>
190
+ {#snippet children({ selected, disabled, day: dayText })}
191
+ <div
192
+ class="relative flex size-9 items-center justify-center rounded-lg text-sm
193
+ {selected
194
+ ? 'bg-accent-400 font-medium text-base-950'
195
+ : disabled
196
+ ? 'pointer-events-none text-base-300 dark:text-base-600'
197
+ : day.month !== month.value.month
198
+ ? 'text-base-300 dark:text-base-600'
199
+ : 'text-base-700 hover:bg-base-200 dark:text-base-300 dark:hover:bg-base-700'}"
200
+ >
201
+ {dayText}
202
+ {#if day.day === todayDay && day.month === todayMonth && day.year === todayYear}
203
+ <span
204
+ class="absolute bottom-1 left-1/2 size-1 -translate-x-1/2 rounded-full {selected
205
+ ? 'bg-base-950'
206
+ : 'bg-accent-500'}"
207
+ ></span>
208
+ {/if}
209
+ </div>
210
+ {/snippet}
211
+ </DatePicker.Day>
212
+ </DatePicker.Cell>
213
+ {/each}
214
+ </DatePicker.GridRow>
215
+ {/each}
216
+ </DatePicker.GridBody>
217
+ </DatePicker.Grid>
218
+ {/each}
219
+ {/snippet}
220
+ </DatePicker.Calendar>
221
+ </DatePicker.Content>
222
+ </DatePicker.Root>
@@ -0,0 +1,12 @@
1
+ import { DatePicker } from 'bits-ui';
2
+ type $$ComponentProps = {
3
+ value: string;
4
+ required?: boolean;
5
+ label?: string;
6
+ minValue?: string;
7
+ locale?: string;
8
+ onSelect?: () => void;
9
+ };
10
+ declare const DatePicker: import("svelte").Component<$$ComponentProps, {}, "value">;
11
+ type DatePicker = ReturnType<typeof DatePicker>;
12
+ export default DatePicker;
@@ -0,0 +1,112 @@
1
+ <script lang="ts">
2
+ // @ts-nocheck
3
+ import DatePickerField from './DatePicker.svelte';
4
+ import TimePicker from './TimePicker.svelte';
5
+ import { untrack } from 'svelte';
6
+ import { BROWSER as browser } from 'esm-env';
7
+
8
+ let {
9
+ value = $bindable(''),
10
+ required = false,
11
+ label = '',
12
+ minValue = '',
13
+ referenceTime = ''
14
+ }: {
15
+ value: string;
16
+ required?: boolean;
17
+ label?: string;
18
+ minValue?: string;
19
+ referenceTime?: string;
20
+ } = $props();
21
+
22
+ let datePart = $state('');
23
+ let timePart = $state('00:00');
24
+ let timeEl: HTMLDivElement | undefined = $state(undefined);
25
+
26
+ const locale = browser ? navigator.language || 'en' : 'en';
27
+ let minDatePart = $derived(minValue ? minValue.split('T')[0] || '' : '');
28
+ let refTimePart = $derived.by(() => {
29
+ if (!referenceTime) return '';
30
+ const [refDate, refTime] = referenceTime.split('T');
31
+ if (refDate && refDate === datePart && refTime) return refTime;
32
+ return '';
33
+ });
34
+
35
+ // Default to current date/time rounded up to the next hour when no initial value
36
+ if (browser && !value) {
37
+ const now = new Date();
38
+ // Local calculation only, not component state.
39
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity
40
+ const rounded = new Date(now);
41
+ rounded.setMinutes(0, 0, 0);
42
+ rounded.setHours(rounded.getHours() + 1);
43
+
44
+ const yyyy = rounded.getFullYear();
45
+ const mm = String(rounded.getMonth() + 1).padStart(2, '0');
46
+ const dd = String(rounded.getDate()).padStart(2, '0');
47
+ const hh = String(rounded.getHours()).padStart(2, '0');
48
+ const min = String(rounded.getMinutes()).padStart(2, '0');
49
+
50
+ const defaultDate = `${yyyy}-${mm}-${dd}`;
51
+ const defaultTime = `${hh}:${min}`;
52
+ datePart = defaultDate;
53
+ timePart = defaultTime;
54
+ value = `${defaultDate}T${defaultTime}`;
55
+ }
56
+
57
+ // Sync external value -> date/time parts
58
+ $effect(() => {
59
+ const v = value;
60
+ untrack(() => {
61
+ if (v) {
62
+ const [d, t] = v.split('T');
63
+ if (d && d !== datePart) datePart = d;
64
+ if (t && t !== timePart) timePart = t;
65
+ }
66
+ });
67
+ });
68
+
69
+ // Sync date/time parts -> external value
70
+ $effect(() => {
71
+ const d = datePart;
72
+ const t = timePart;
73
+ untrack(() => {
74
+ if (d) {
75
+ const newVal = `${d}T${t || '00:00'}`;
76
+ if (newVal !== value) value = newVal;
77
+ }
78
+ });
79
+ });
80
+
81
+ function focusTime() {
82
+ // Small delay to let the popover finish closing
83
+ setTimeout(() => {
84
+ if (timeEl) {
85
+ const segment = timeEl.querySelector('[data-segment]');
86
+ if (segment instanceof HTMLElement) {
87
+ segment.focus();
88
+ }
89
+ }
90
+ }, 50);
91
+ }
92
+ </script>
93
+
94
+ <div class="flex min-w-0 flex-1 items-center gap-2">
95
+ <DatePickerField
96
+ bind:value={datePart}
97
+ label={label ? `${label} date` : 'Date'}
98
+ {required}
99
+ minValue={minDatePart}
100
+ {locale}
101
+ onSelect={focusTime}
102
+ />
103
+ <div bind:this={timeEl} class="shrink-0">
104
+ <TimePicker
105
+ bind:value={timePart}
106
+ label={label ? `${label} time` : 'Time'}
107
+ {required}
108
+ {locale}
109
+ referenceTime={refTimePart}
110
+ />
111
+ </div>
112
+ </div>
@@ -0,0 +1,10 @@
1
+ type $$ComponentProps = {
2
+ value: string;
3
+ required?: boolean;
4
+ label?: string;
5
+ minValue?: string;
6
+ referenceTime?: string;
7
+ };
8
+ declare const DateTimePicker: import("svelte").Component<$$ComponentProps, {}, "value">;
9
+ type DateTimePicker = ReturnType<typeof DateTimePicker>;
10
+ export default DateTimePicker;