@octabits-io/nuxt-ui-kit 0.7.0 → 0.9.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/dist/dates/index.d.ts
CHANGED
|
@@ -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 };
|
package/dist/dates/index.js
CHANGED
|
@@ -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 };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@octabits-io/nuxt-ui-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.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.
|
|
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>
|
|
@@ -30,25 +30,38 @@ const props = withDefaults(defineProps<{
|
|
|
30
30
|
showLabel?: boolean
|
|
31
31
|
/** Whether the tooltip should respect global disabled state. */
|
|
32
32
|
tooltipDisabled?: boolean
|
|
33
|
+
/**
|
|
34
|
+
* Why the action is currently unavailable. When set, the button renders
|
|
35
|
+
* disabled and the tooltip shows "label — reason", keeping the action's
|
|
36
|
+
* purpose visible alongside the blocker. Hover on the disabled button works
|
|
37
|
+
* via an internal span wrapper (disabled buttons don't dispatch pointer
|
|
38
|
+
* events).
|
|
39
|
+
*/
|
|
40
|
+
disabledReason?: string | null
|
|
33
41
|
}>(), {
|
|
34
42
|
tone: 'neutral',
|
|
35
43
|
loading: false,
|
|
36
44
|
disabled: false,
|
|
37
45
|
showLabel: false,
|
|
38
46
|
tooltipDisabled: false,
|
|
47
|
+
disabledReason: null,
|
|
39
48
|
})
|
|
40
49
|
|
|
41
50
|
const slots = useSlots()
|
|
42
51
|
|
|
43
52
|
const hasLabelSlot = computed(() => Boolean(slots.default))
|
|
44
53
|
const isIconOnly = computed(() => !hasLabelSlot.value && !props.showLabel)
|
|
54
|
+
const isBlocked = computed(() => Boolean(props.disabledReason))
|
|
55
|
+
const tooltipText = computed(() =>
|
|
56
|
+
props.disabledReason ? `${props.label} — ${props.disabledReason}` : props.label,
|
|
57
|
+
)
|
|
45
58
|
|
|
46
59
|
const buttonProps = computed<Partial<ButtonProps>>(() => {
|
|
47
60
|
const base: Partial<ButtonProps> = {
|
|
48
61
|
icon: props.icon,
|
|
49
62
|
size: 'sm',
|
|
50
63
|
loading: props.loading,
|
|
51
|
-
disabled: props.disabled || props.loading,
|
|
64
|
+
disabled: props.disabled || props.loading || isBlocked.value,
|
|
52
65
|
to: props.to,
|
|
53
66
|
target: props.target,
|
|
54
67
|
}
|
|
@@ -72,8 +85,25 @@ if (import.meta.dev && !props.label) {
|
|
|
72
85
|
</script>
|
|
73
86
|
|
|
74
87
|
<template>
|
|
75
|
-
<UTooltip
|
|
88
|
+
<UTooltip
|
|
89
|
+
v-if="isIconOnly || isBlocked"
|
|
90
|
+
:text="tooltipText"
|
|
91
|
+
:disabled="!isBlocked && tooltipDisabled"
|
|
92
|
+
>
|
|
93
|
+
<!-- Disabled buttons don't dispatch pointer events: when blocked, the span
|
|
94
|
+
is the hover target and the button opts out of pointer events. -->
|
|
95
|
+
<span v-if="isBlocked" class="inline-flex">
|
|
96
|
+
<UButton
|
|
97
|
+
v-bind="buttonProps"
|
|
98
|
+
class="pointer-events-none"
|
|
99
|
+
:aria-label="tooltipText"
|
|
100
|
+
:label="!isIconOnly && showLabel ? label : undefined"
|
|
101
|
+
>
|
|
102
|
+
<slot v-if="!isIconOnly" />
|
|
103
|
+
</UButton>
|
|
104
|
+
</span>
|
|
76
105
|
<UButton
|
|
106
|
+
v-else
|
|
77
107
|
v-bind="buttonProps"
|
|
78
108
|
:aria-label="label"
|
|
79
109
|
/>
|
|
@@ -15,7 +15,10 @@ import PageUtilityActions from './PageUtilityActions.vue'
|
|
|
15
15
|
* Standard page header.
|
|
16
16
|
*
|
|
17
17
|
* Conventions enforced by this component and its siblings:
|
|
18
|
-
* -
|
|
18
|
+
* - State-changing decision actions (confirm, publish, cancel, …) render
|
|
19
|
+
* labeled via `show-label`; generic reversible tools (edit, preview,
|
|
20
|
+
* download, add) stay icon-only with tooltips.
|
|
21
|
+
* - Max 3 inline actions visible per state in `#actions`; more go in `overflowItems`.
|
|
19
22
|
* - Destructive actions are ALWAYS placed inside the overflow menu, never inline.
|
|
20
23
|
* - Header height, spacing, and tooltip behavior are normalized via `PageAction`.
|
|
21
24
|
* - Utility triggers (Help, AI history, …) live in `#utility` (default =
|