@octabits-io/nuxt-ui-kit 0.6.0 → 0.8.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.
@@ -10,6 +10,11 @@ interface Period {
10
10
  * Example: `{ start: "2025-01-01", end: "2025-01-03" }` → 3 days.
11
11
  */
12
12
  declare function calculateDays(period: Period): number;
13
+ /**
14
+ * Number of nights in a period whose `end` is **exclusive** (departure
15
+ * semantics). Example: `{ start: "2025-01-01", end: "2025-01-03" }` → 2 nights.
16
+ */
17
+ declare function calculateNights(period: Period): number;
13
18
  /** Shift an ISO date string by n days; `''` stays `''`. */
14
19
  declare function shiftIso(iso: string, days: number): string;
15
20
  /**
@@ -39,4 +44,4 @@ declare function createDateFormatter(options: DateFormatterOptions): {
39
44
  };
40
45
  type DateFormatter = ReturnType<typeof createDateFormatter>;
41
46
  //#endregion
42
- export { DateFormatter, DateFormatterOptions, Period, calculateDays, createDateFormatter, shiftIso, useDateRangeInput };
47
+ export { DateFormatter, DateFormatterOptions, Period, calculateDays, calculateNights, createDateFormatter, shiftIso, useDateRangeInput };
@@ -8,6 +8,13 @@ import { addDays, differenceInDays, eachDayOfInterval, format, parseISO } from "
8
8
  function calculateDays(period) {
9
9
  return differenceInDays(new Date(period.end), new Date(period.start)) + 1;
10
10
  }
11
+ /**
12
+ * Number of nights in a period whose `end` is **exclusive** (departure
13
+ * semantics). Example: `{ start: "2025-01-01", end: "2025-01-03" }` → 2 nights.
14
+ */
15
+ function calculateNights(period) {
16
+ return differenceInDays(new Date(period.end), new Date(period.start));
17
+ }
11
18
  /** Shift an ISO date string by n days; `''` stays `''`. */
12
19
  function shiftIso(iso, days) {
13
20
  if (!iso || days === 0) return iso;
@@ -105,4 +112,4 @@ function createDateFormatter(options) {
105
112
  };
106
113
  }
107
114
  //#endregion
108
- export { calculateDays, createDateFormatter, shiftIso, useDateRangeInput };
115
+ export { calculateDays, calculateNights, createDateFormatter, shiftIso, useDateRangeInput };
@@ -6,9 +6,9 @@
6
6
  * harness toasts. Deep-merge a fragment into the app's own messages per
7
7
  * locale; app-specific keys (e.g. sign-in branding) stay app-side.
8
8
  *
9
- * German ships in both registers: `de` addresses the reader informally (du),
10
- * `deFormal` formally (Sie). Pick whichever matches the app's voice both
11
- * merge under the app's `de` locale.
9
+ * Only English ships with the kit it doubles as the reference for the full
10
+ * key set. Apps provide their other locales themselves as `KitMessages`
11
+ * objects, which keeps every translation (and its register/voice) app-side.
12
12
  */
13
13
  interface KitMessages {
14
14
  errors: {
@@ -44,7 +44,5 @@ interface KitMessages {
44
44
  };
45
45
  }
46
46
  declare const kitMessagesEn: KitMessages;
47
- declare const kitMessagesDe: KitMessages;
48
- declare const kitMessagesDeFormal: KitMessages;
49
47
  //#endregion
50
- export { KitMessages, kitMessagesDe, kitMessagesDeFormal, kitMessagesEn };
48
+ export { KitMessages, kitMessagesEn };
@@ -32,51 +32,5 @@ const kitMessagesEn = {
32
32
  help: "Help"
33
33
  }
34
34
  };
35
- const kitMessagesDe = {
36
- errors: {
37
- internal_server_error: "Ein unerwarteter Fehler ist aufgetreten",
38
- not_found: "Ressource nicht gefunden",
39
- forbidden: "Zugriff verweigert",
40
- validation_error: "Validierungsfehler",
41
- unique_violation: "Dieser Wert existiert bereits",
42
- foreign_key_violation: "Der referenzierte Datensatz existiert nicht",
43
- exclusion_violation: "Dieser Eintrag überschneidet sich mit einem bestehenden",
44
- service_unavailable: "Dienst vorübergehend nicht verfügbar. Bitte versuche es später erneut."
45
- },
46
- auth: {
47
- sessionRenewFailedTitle: "Sitzung konnte nicht erneuert werden",
48
- sessionRenewFailedDescription: "Wir konnten deine Sitzung nicht erneuern. Eventuell musst du dich neu anmelden.",
49
- sessionExpiredTitle: "Sitzung abgelaufen",
50
- sessionExpiredDescription: "Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.",
51
- signingIn: "Anmeldung wird abgeschlossen..."
52
- },
53
- localeField: {
54
- translate: "Fehlende Sprachen mit KI übersetzen",
55
- translateDone: "Keine Übersetzungen eingefügt | {count} Übersetzung eingefügt | {count} Übersetzungen eingefügt",
56
- inheritsBaseLocale: "Übernimmt die Basissprache",
57
- translationStatus: {
58
- complete: "Alle Übersetzungen vollständig",
59
- missing: "Fehlende Übersetzungen — {details}"
60
- }
61
- },
62
- pageChrome: {
63
- back: "Zurück",
64
- moreActions: "Weitere Aktionen",
65
- help: "Hilfe"
66
- }
67
- };
68
- const kitMessagesDeFormal = {
69
- errors: {
70
- ...kitMessagesDe.errors,
71
- service_unavailable: "Dienst vorübergehend nicht verfügbar. Bitte versuchen Sie es später erneut."
72
- },
73
- auth: {
74
- ...kitMessagesDe.auth,
75
- sessionRenewFailedDescription: "Wir konnten Ihre Sitzung nicht erneuern. Eventuell müssen Sie sich neu anmelden.",
76
- sessionExpiredDescription: "Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut an."
77
- },
78
- localeField: kitMessagesDe.localeField,
79
- pageChrome: kitMessagesDe.pageChrome
80
- };
81
35
  //#endregion
82
- export { kitMessagesDe, kitMessagesDeFormal, kitMessagesEn };
36
+ export { kitMessagesEn };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@octabits-io/nuxt-ui-kit",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "Frontend kit for Nuxt/Vue admin SPAs: OIDC session harness (oidc-client-ts), Eden Treaty client factory, auth/org store cores, and a route-guard builder — factory-style seams the app wires into its own plugins, stores, and middleware",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -75,7 +75,7 @@
75
75
  "vitest": "^4.1.10",
76
76
  "vue": "^3.5.40",
77
77
  "zod": "^4.4.3",
78
- "@octabits-io/framework": "^0.4.0"
78
+ "@octabits-io/framework": "^0.7.0"
79
79
  },
80
80
  "peerDependencies": {
81
81
  "@elysiajs/eden": "^1.4.0",
@@ -0,0 +1,238 @@
1
+ <script setup lang="ts">
2
+ // Shipped as source: the consumer's Vite compiles this SFC. All imports are
3
+ // explicit — no reliance on the consumer's auto-import configuration.
4
+ //
5
+ // Flexible travel-period wish: a date *window* (earliest arrival → latest
6
+ // departure) plus a desired stay length in nights — "between Jun 1 and
7
+ // Jun 21 for 7 nights". Composes DateRangeInput (kind="travel") for the
8
+ // window and adds a nights input, cross-field validation, and a metadata
9
+ // line (window span, flexibility, example stay).
10
+ //
11
+ // i18n key contract: flexPeriod.* (earliestStart/latestEnd/nightsLabel/
12
+ // clear/windowSpan/flexibility/example/errors.*) and period.travel.nights,
13
+ // plus the composed DateRangeInput's dateRange.* keys.
14
+ import { computed, watch } from 'vue'
15
+ import { useI18n } from 'vue-i18n'
16
+ import UInputNumber from '@nuxt/ui/components/InputNumber.vue'
17
+ import UButton from '@nuxt/ui/components/Button.vue'
18
+ import DateRangeInput from './DateRangeInput.vue'
19
+ import {
20
+ calculateNights,
21
+ createDateFormatter,
22
+ shiftIso,
23
+ type Period,
24
+ } from '@octabits-io/nuxt-ui-kit/dates'
25
+
26
+ type InputSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl'
27
+
28
+ const props = withDefaults(defineProps<{
29
+ /**
30
+ * The wish window in travel semantics: `start` = earliest possible
31
+ * arrival, `end` = latest possible departure (ISO YYYY-MM-DD, `''` =
32
+ * unset). Unlike DateRangeInput's booking-space model, both bounds are
33
+ * literal calendar dates — conversion happens at the inner boundary.
34
+ */
35
+ modelValue: Period
36
+ /** Desired stay length in nights; `null` = unset. */
37
+ nights: number | null
38
+ minNights?: number
39
+ maxNights?: number
40
+ disabled?: boolean
41
+ size?: InputSize
42
+ /** Calendar trigger icon, passed through to the window inputs. */
43
+ icon?: string
44
+ startLabel?: string
45
+ endLabel?: string
46
+ /** Show the built-in clear (X) button when anything is set. */
47
+ clearable?: boolean
48
+ }>(), {
49
+ minNights: 1,
50
+ maxNights: 365,
51
+ disabled: false,
52
+ size: 'md',
53
+ icon: undefined,
54
+ startLabel: undefined,
55
+ endLabel: undefined,
56
+ clearable: true,
57
+ })
58
+
59
+ const emit = defineEmits<{
60
+ 'update:modelValue': [value: Period]
61
+ 'update:nights': [value: number | null]
62
+ 'change': [payload: { window: Period, nights: number | null, isValid: boolean }]
63
+ }>()
64
+
65
+ const { t, locale } = useI18n()
66
+ const { formatDateMedium } = createDateFormatter({ getLocale: () => locale.value })
67
+
68
+ const startAriaLabel = computed(() => props.startLabel || t('flexPeriod.earliestStart'))
69
+ const endAriaLabel = computed(() => props.endLabel || t('flexPeriod.latestEnd'))
70
+
71
+ // The inner DateRangeInput binds in booking semantics (end = last booked
72
+ // day) and displays departure via kind="travel". Our model's end IS the
73
+ // departure date, so convert ±1 day at this boundary only. shiftIso('')
74
+ // stays '' — half-filled pairs pass through literally (never auto-fixed).
75
+ const innerPeriod = computed<Period>({
76
+ get: () => ({
77
+ start: props.modelValue.start,
78
+ end: shiftIso(props.modelValue.end, -1),
79
+ }),
80
+ set: (v) => {
81
+ emit('update:modelValue', {
82
+ start: v.start,
83
+ end: shiftIso(v.end, 1),
84
+ })
85
+ },
86
+ })
87
+
88
+ const nightsValue = computed<number | null>({
89
+ get: () => props.nights,
90
+ set: v => emit('update:nights', v ?? null),
91
+ })
92
+
93
+ const hasAnyValue = computed(() =>
94
+ !!props.modelValue.start || !!props.modelValue.end || props.nights != null,
95
+ )
96
+
97
+ function clearAll() {
98
+ emit('update:modelValue', { start: '', end: '' })
99
+ emit('update:nights', null)
100
+ }
101
+
102
+ // --- Derived metadata ---
103
+
104
+ const windowComplete = computed(() =>
105
+ !!props.modelValue.start
106
+ && !!props.modelValue.end
107
+ && props.modelValue.end > props.modelValue.start,
108
+ )
109
+
110
+ /** Nights the full window spans (arrival → departure, exclusive end). */
111
+ const windowNights = computed<number | null>(() =>
112
+ windowComplete.value ? calculateNights(props.modelValue) : null,
113
+ )
114
+
115
+ /** Scheduling slack: how many nights the stay can shift inside the window. */
116
+ const flexNights = computed<number | null>(() => {
117
+ if (windowNights.value === null || props.nights == null) return null
118
+ const flex = windowNights.value - props.nights
119
+ return flex > 0 ? flex : null
120
+ })
121
+
122
+ /** Example stay anchored at the earliest arrival, when start + nights known. */
123
+ const exampleStay = computed(() => {
124
+ if (!props.modelValue.start || props.nights == null || props.nights < 1) return ''
125
+ // A stay that doesn't fit the window has no truthful example — the
126
+ // exceed-window error below explains the situation instead.
127
+ if (windowNights.value !== null && props.nights > windowNights.value) return ''
128
+ return t('flexPeriod.example', {
129
+ start: formatDateMedium(props.modelValue.start),
130
+ end: formatDateMedium(shiftIso(props.modelValue.start, props.nights)),
131
+ })
132
+ })
133
+
134
+ const metadataParts = computed<string[]>(() => {
135
+ const parts: string[] = []
136
+ if (windowNights.value !== null && windowNights.value > 0) {
137
+ parts.push(t('flexPeriod.windowSpan', { nights: t('period.travel.nights', windowNights.value) }))
138
+ }
139
+ if (flexNights.value !== null) {
140
+ parts.push(t('flexPeriod.flexibility', { nights: t('period.travel.nights', flexNights.value) }))
141
+ }
142
+ if (exampleStay.value) {
143
+ parts.push(exampleStay.value)
144
+ }
145
+ return parts
146
+ })
147
+
148
+ // --- Validation (nights axis only; window errors are the inner input's job) ---
149
+
150
+ const nightsError = computed<string | null>(() => {
151
+ if (props.nights == null) return null
152
+ if (props.nights < props.minNights) {
153
+ return t('flexPeriod.errors.minNights', { n: props.minNights })
154
+ }
155
+ if (props.nights > props.maxNights) {
156
+ return t('flexPeriod.errors.maxNights', { n: props.maxNights })
157
+ }
158
+ if (windowNights.value !== null && props.nights > windowNights.value) {
159
+ return t('flexPeriod.errors.nightsExceedWindow', {
160
+ nights: t('period.travel.nights', props.nights),
161
+ window: t('period.travel.nights', windowNights.value),
162
+ })
163
+ }
164
+ return null
165
+ })
166
+
167
+ const nightsInputColor = computed<'error' | undefined>(() =>
168
+ nightsError.value !== null ? 'error' : undefined,
169
+ )
170
+
171
+ // A wish is progressive capture: fully empty is valid; a window, once
172
+ // started, must be complete and ordered (in travel space, departure strictly
173
+ // after arrival = at least one night).
174
+ const windowValid = computed(() => {
175
+ const { start, end } = props.modelValue
176
+ if (!start && !end) return true
177
+ return windowComplete.value
178
+ })
179
+
180
+ const isValid = computed(() => windowValid.value && nightsError.value === null)
181
+
182
+ watch(
183
+ () => [props.modelValue.start, props.modelValue.end, props.nights] as const,
184
+ ([start, end, nights]) => {
185
+ emit('change', { window: { start, end }, nights, isValid: isValid.value })
186
+ },
187
+ )
188
+ </script>
189
+
190
+ <template>
191
+ <div class="flex flex-col gap-1">
192
+ <div class="flex items-start gap-2">
193
+ <DateRangeInput
194
+ v-model="innerPeriod"
195
+ kind="travel"
196
+ :disabled="disabled"
197
+ :size="size"
198
+ :icon="icon"
199
+ :start-label="startAriaLabel"
200
+ :end-label="endAriaLabel"
201
+ class="flex-1"
202
+ />
203
+ <UInputNumber
204
+ v-model="nightsValue"
205
+ :min="minNights"
206
+ :max="maxNights"
207
+ :size="size"
208
+ :disabled="disabled"
209
+ :color="nightsInputColor"
210
+ :aria-label="t('flexPeriod.nightsLabel')"
211
+ :placeholder="t('flexPeriod.nightsLabel')"
212
+ class="w-28 shrink-0"
213
+ />
214
+ <UButton
215
+ v-if="clearable && hasAnyValue"
216
+ icon="i-lucide-x"
217
+ color="neutral"
218
+ variant="ghost"
219
+ :size="size"
220
+ :disabled="disabled"
221
+ :aria-label="t('flexPeriod.clear')"
222
+ @click="clearAll"
223
+ />
224
+ </div>
225
+
226
+ <!-- Slot for a derived summary shown between the inputs and the hints,
227
+ matching DateRangeInput's slot for layout parity. -->
228
+ <slot name="summary" />
229
+
230
+ <p v-if="metadataParts.length" class="text-xs text-muted">
231
+ {{ metadataParts.join(' · ') }}
232
+ </p>
233
+
234
+ <p v-if="nightsError" class="text-sm text-error">
235
+ {{ nightsError }}
236
+ </p>
237
+ </div>
238
+ </template>