@octabits-io/nuxt-ui-kit 0.12.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -0
- package/dist/events/index.d.ts +101 -0
- package/dist/events/index.js +245 -0
- package/package.json +7 -2
- package/src/components/DateRangeInput.vue +117 -101
- package/src/components/FlexiblePeriodInput.vue +39 -24
package/README.md
CHANGED
|
@@ -50,6 +50,15 @@ itself, so it has no Nuxt dependency, only `vue`.
|
|
|
50
50
|
an app-side `useDateFormat`), plus source-shipped `./components/DateInput.vue`,
|
|
51
51
|
`DateRangeInput.vue` (travel/booking end-date semantics, blocked dates via
|
|
52
52
|
props, injected `availabilityCheck`), and `PeriodDisplay.vue`
|
|
53
|
+
- **`./events`** — the browser side of `@octabits-io/framework/events`:
|
|
54
|
+
`createEventStreamClient`, a fetch-based SSE reader (the stream is
|
|
55
|
+
authenticated with an `Authorization` header, which native `EventSource`
|
|
56
|
+
cannot set — so reconnect, `Last-Event-ID` replay, and full-jitter backoff
|
|
57
|
+
live here), with a durable-only watermark, bounded seen-id dedupe, a
|
|
58
|
+
`degraded` state for honest fallback-polling UX, and a content-type guard
|
|
59
|
+
(a 200 `text/html` SPA fallback is a failure, not a stream);
|
|
60
|
+
`createSseFrameParser`; `useEventStream` (reactive connection state +
|
|
61
|
+
scope-bound lifecycle)
|
|
53
62
|
- **`./ai`** — frontend AI-workflow engine: `useAiWorkflow` /
|
|
54
63
|
`useAiWorkflowGuard` (poll-driven state over injected transport),
|
|
55
64
|
`createAiProgressCore` (cross-page tracking + completion/applied signals —
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { Ref } from "vue";
|
|
2
|
+
//#region src/events/sseParser.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* Incremental SSE frame parser — pure logic, no I/O, exhaustively unit
|
|
5
|
+
* tested. Feed it decoded text chunks as they arrive; it returns completed
|
|
6
|
+
* frames (an empty line terminates a frame, per the SSE spec).
|
|
7
|
+
*
|
|
8
|
+
* Only the fields the event stream uses are surfaced (`id`, `event`, `data`,
|
|
9
|
+
* `retry`); comment lines (`: hb` heartbeats) and unknown fields are
|
|
10
|
+
* discarded. CRLF and bare-CR line endings are normalized.
|
|
11
|
+
*/
|
|
12
|
+
interface SseFrame {
|
|
13
|
+
/** The `id:` field — present only on durable events (the watermark rule). */
|
|
14
|
+
id?: string;
|
|
15
|
+
/** The `event:` field (the envelope type, informational). */
|
|
16
|
+
event?: string;
|
|
17
|
+
/** The `data:` field(s), newline-joined. */
|
|
18
|
+
data: string;
|
|
19
|
+
/** A `retry:` field, parsed to ms. */
|
|
20
|
+
retry?: number;
|
|
21
|
+
}
|
|
22
|
+
interface SseFrameParser {
|
|
23
|
+
/** Consume a chunk; returns every frame completed by it. */
|
|
24
|
+
push(chunk: string): SseFrame[];
|
|
25
|
+
/** Discard any partial frame state (call on reconnect). */
|
|
26
|
+
reset(): void;
|
|
27
|
+
}
|
|
28
|
+
declare function createSseFrameParser(): SseFrameParser;
|
|
29
|
+
//#endregion
|
|
30
|
+
//#region src/events/client.d.ts
|
|
31
|
+
/**
|
|
32
|
+
* Structural duplicate of the framework's `EventEnvelope` — the kit has no
|
|
33
|
+
* dependency on `@octabits-io/framework`, and the wire format is the
|
|
34
|
+
* contract, not the type.
|
|
35
|
+
*/
|
|
36
|
+
interface StreamedEvent<T = unknown> {
|
|
37
|
+
id: string;
|
|
38
|
+
seq?: number;
|
|
39
|
+
type: string;
|
|
40
|
+
scopeKey: string;
|
|
41
|
+
at: string;
|
|
42
|
+
lane: 'durable' | 'ephemeral';
|
|
43
|
+
data: T;
|
|
44
|
+
actor?: {
|
|
45
|
+
type: string;
|
|
46
|
+
id?: string;
|
|
47
|
+
name?: string;
|
|
48
|
+
};
|
|
49
|
+
resources?: string[];
|
|
50
|
+
}
|
|
51
|
+
type EventStreamState = 'idle' | 'connecting' | 'connected' | 'reconnecting' | 'degraded' | 'stopped';
|
|
52
|
+
interface EventStreamRequest {
|
|
53
|
+
url: string;
|
|
54
|
+
/** Extra headers — put your `Authorization` here, fresh per attempt. */
|
|
55
|
+
headers?: Record<string, string>;
|
|
56
|
+
}
|
|
57
|
+
interface EventStreamClientOptions {
|
|
58
|
+
/**
|
|
59
|
+
* Build the request for each (re)connect attempt. Called every attempt so
|
|
60
|
+
* the auth token is always fresh. May be async (token refresh).
|
|
61
|
+
*/
|
|
62
|
+
buildRequest: () => EventStreamRequest | Promise<EventStreamRequest>;
|
|
63
|
+
/** Deduped envelope delivery, both lanes. */
|
|
64
|
+
onEvent: (event: StreamedEvent) => void;
|
|
65
|
+
onStateChange?: (state: EventStreamState) => void;
|
|
66
|
+
/** Injected fetch (default `globalThis.fetch`). */
|
|
67
|
+
fetchImpl?: typeof fetch;
|
|
68
|
+
/** Base reconnect delay, overridden by the server's `retry:` hint (default 3 000 ms). */
|
|
69
|
+
retryMs?: number;
|
|
70
|
+
/** Reconnect delay ceiling under sustained failure (default 30 000 ms). */
|
|
71
|
+
maxRetryMs?: number;
|
|
72
|
+
/** Continuous failure duration before state turns `degraded` (default 60 000 ms). */
|
|
73
|
+
degradedAfterMs?: number;
|
|
74
|
+
/** Seen-id dedupe set bound (default 2 000). */
|
|
75
|
+
maxSeenIds?: number;
|
|
76
|
+
/** Resume watermark persisted from a previous session, if any. */
|
|
77
|
+
initialLastEventId?: string | null;
|
|
78
|
+
}
|
|
79
|
+
interface EventStreamClient {
|
|
80
|
+
start(): void;
|
|
81
|
+
stop(): void;
|
|
82
|
+
state(): EventStreamState;
|
|
83
|
+
/** The current watermark (last durable SSE id seen). */
|
|
84
|
+
lastEventId(): string | null;
|
|
85
|
+
}
|
|
86
|
+
declare function createEventStreamClient(options: EventStreamClientOptions): EventStreamClient;
|
|
87
|
+
//#endregion
|
|
88
|
+
//#region src/events/useEventStream.d.ts
|
|
89
|
+
interface UseEventStreamReturn {
|
|
90
|
+
/** Reactive connection state — drive fallback-polling and UI hints off this. */
|
|
91
|
+
state: Readonly<Ref<EventStreamState>>;
|
|
92
|
+
/** Reactive count of events delivered (deduped) this session. */
|
|
93
|
+
received: Readonly<Ref<number>>;
|
|
94
|
+
start(): void;
|
|
95
|
+
stop(): void;
|
|
96
|
+
/** Current watermark (persist it to resume replay across page loads). */
|
|
97
|
+
lastEventId(): string | null;
|
|
98
|
+
}
|
|
99
|
+
declare function useEventStream(options: EventStreamClientOptions): UseEventStreamReturn;
|
|
100
|
+
//#endregion
|
|
101
|
+
export { type EventStreamClient, type EventStreamClientOptions, type EventStreamRequest, type EventStreamState, type SseFrame, type SseFrameParser, type StreamedEvent, type UseEventStreamReturn, createEventStreamClient, createSseFrameParser, useEventStream };
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { onScopeDispose, readonly, ref } from "vue";
|
|
2
|
+
//#region src/events/sseParser.ts
|
|
3
|
+
function createSseFrameParser() {
|
|
4
|
+
let buffer = "";
|
|
5
|
+
let id;
|
|
6
|
+
let event;
|
|
7
|
+
let retry;
|
|
8
|
+
let dataLines = [];
|
|
9
|
+
function resetFrame() {
|
|
10
|
+
id = void 0;
|
|
11
|
+
event = void 0;
|
|
12
|
+
retry = void 0;
|
|
13
|
+
dataLines = [];
|
|
14
|
+
}
|
|
15
|
+
function processLine(line, frames) {
|
|
16
|
+
if (line === "") {
|
|
17
|
+
if (dataLines.length > 0 || id !== void 0 || event !== void 0 || retry !== void 0) frames.push({
|
|
18
|
+
...id !== void 0 ? { id } : {},
|
|
19
|
+
...event !== void 0 ? { event } : {},
|
|
20
|
+
...retry !== void 0 ? { retry } : {},
|
|
21
|
+
data: dataLines.join("\n")
|
|
22
|
+
});
|
|
23
|
+
resetFrame();
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
if (line.startsWith(":")) return;
|
|
27
|
+
const colon = line.indexOf(":");
|
|
28
|
+
const field = colon === -1 ? line : line.slice(0, colon);
|
|
29
|
+
let value = colon === -1 ? "" : line.slice(colon + 1);
|
|
30
|
+
if (value.startsWith(" ")) value = value.slice(1);
|
|
31
|
+
switch (field) {
|
|
32
|
+
case "id":
|
|
33
|
+
if (!value.includes("\0")) id = value;
|
|
34
|
+
break;
|
|
35
|
+
case "event":
|
|
36
|
+
event = value;
|
|
37
|
+
break;
|
|
38
|
+
case "data":
|
|
39
|
+
dataLines.push(value);
|
|
40
|
+
break;
|
|
41
|
+
case "retry": {
|
|
42
|
+
const parsed = Number.parseInt(value, 10);
|
|
43
|
+
if (Number.isInteger(parsed) && parsed >= 0) retry = parsed;
|
|
44
|
+
break;
|
|
45
|
+
}
|
|
46
|
+
default: break;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function push(chunk) {
|
|
50
|
+
buffer += chunk.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
51
|
+
const frames = [];
|
|
52
|
+
let newline = buffer.indexOf("\n");
|
|
53
|
+
while (newline !== -1) {
|
|
54
|
+
const line = buffer.slice(0, newline);
|
|
55
|
+
buffer = buffer.slice(newline + 1);
|
|
56
|
+
processLine(line, frames);
|
|
57
|
+
newline = buffer.indexOf("\n");
|
|
58
|
+
}
|
|
59
|
+
return frames;
|
|
60
|
+
}
|
|
61
|
+
function reset() {
|
|
62
|
+
buffer = "";
|
|
63
|
+
resetFrame();
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
push,
|
|
67
|
+
reset
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
//#endregion
|
|
71
|
+
//#region src/events/client.ts
|
|
72
|
+
/**
|
|
73
|
+
* The fetch-based SSE event-stream client. A hand-rolled reader rather than
|
|
74
|
+
* native `EventSource` because the stream is authenticated with an
|
|
75
|
+
* `Authorization` header, which `new EventSource(url)` cannot set — so
|
|
76
|
+
* reconnect, `Last-Event-ID`, and backoff are implemented here, once.
|
|
77
|
+
*
|
|
78
|
+
* Semantics baked in (mirroring the server contract in
|
|
79
|
+
* `@octabits-io/framework/events`):
|
|
80
|
+
*
|
|
81
|
+
* - **Watermark**: only frames carrying an SSE `id:` advance the persisted
|
|
82
|
+
* watermark (the server sets `id:` on durable events only) — sent back as
|
|
83
|
+
* the `Last-Event-ID` header on every reconnect for replay.
|
|
84
|
+
* - **Dedupe**: replay overlaps and at-least-once delivery mean duplicates
|
|
85
|
+
* are normal; a bounded seen-id set (envelope `id`, not `seq`) filters
|
|
86
|
+
* them before `onEvent`.
|
|
87
|
+
* - **Reconnect is routine, not an error**: the server caps connection age
|
|
88
|
+
* (~5 min) so auth is re-evaluated; a server-side close re-connects after
|
|
89
|
+
* the server's `retry:` hint with **full jitter**. Only sustained failure
|
|
90
|
+
* moves the state to `degraded` (UI hint to resume fallback polling).
|
|
91
|
+
*/
|
|
92
|
+
function createEventStreamClient(options) {
|
|
93
|
+
const { buildRequest, onEvent, onStateChange, fetchImpl = globalThis.fetch.bind(globalThis), retryMs = 3e3, maxRetryMs = 3e4, degradedAfterMs = 6e4, maxSeenIds = 2e3, initialLastEventId = null } = options;
|
|
94
|
+
let state = "idle";
|
|
95
|
+
let lastEventId = initialLastEventId;
|
|
96
|
+
let serverRetryMs = null;
|
|
97
|
+
let abort = null;
|
|
98
|
+
let running = false;
|
|
99
|
+
let attempt = 0;
|
|
100
|
+
let failingSince = null;
|
|
101
|
+
let retryTimer;
|
|
102
|
+
const seen = /* @__PURE__ */ new Set();
|
|
103
|
+
const seenOrder = [];
|
|
104
|
+
function setState(next) {
|
|
105
|
+
if (state === next) return;
|
|
106
|
+
state = next;
|
|
107
|
+
onStateChange?.(next);
|
|
108
|
+
}
|
|
109
|
+
function markSeen(id) {
|
|
110
|
+
if (seen.has(id)) return false;
|
|
111
|
+
seen.add(id);
|
|
112
|
+
seenOrder.push(id);
|
|
113
|
+
if (seenOrder.length > maxSeenIds) {
|
|
114
|
+
const evicted = seenOrder.shift();
|
|
115
|
+
if (evicted !== void 0) seen.delete(evicted);
|
|
116
|
+
}
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
function scheduleReconnect() {
|
|
120
|
+
if (!running) return;
|
|
121
|
+
attempt += 1;
|
|
122
|
+
if (failingSince === null) failingSince = Date.now();
|
|
123
|
+
setState(Date.now() - failingSince >= degradedAfterMs ? "degraded" : "reconnecting");
|
|
124
|
+
const cap = Math.min(maxRetryMs, (serverRetryMs ?? retryMs) * 2 ** Math.min(attempt - 1, 8));
|
|
125
|
+
const delay = Math.random() * cap;
|
|
126
|
+
retryTimer = setTimeout(() => void connect(), delay);
|
|
127
|
+
}
|
|
128
|
+
function handleFrame(frame) {
|
|
129
|
+
if (frame.retry !== void 0) serverRetryMs = frame.retry;
|
|
130
|
+
if (frame.id !== void 0 && frame.id !== "") lastEventId = frame.id;
|
|
131
|
+
if (frame.data === "") return;
|
|
132
|
+
let envelope;
|
|
133
|
+
try {
|
|
134
|
+
envelope = JSON.parse(frame.data);
|
|
135
|
+
} catch {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (typeof envelope !== "object" || envelope === null || typeof envelope.id !== "string") return;
|
|
139
|
+
if (!markSeen(envelope.id)) return;
|
|
140
|
+
onEvent(envelope);
|
|
141
|
+
}
|
|
142
|
+
async function connect() {
|
|
143
|
+
if (!running) return;
|
|
144
|
+
if (state === "idle" || state === "stopped") setState("connecting");
|
|
145
|
+
abort = new AbortController();
|
|
146
|
+
const parser = createSseFrameParser();
|
|
147
|
+
try {
|
|
148
|
+
const request = await buildRequest();
|
|
149
|
+
const response = await fetchImpl(request.url, {
|
|
150
|
+
headers: {
|
|
151
|
+
accept: "text/event-stream",
|
|
152
|
+
...lastEventId !== null ? { "last-event-id": lastEventId } : {},
|
|
153
|
+
...request.headers
|
|
154
|
+
},
|
|
155
|
+
signal: abort.signal
|
|
156
|
+
});
|
|
157
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
158
|
+
if (!response.ok || !response.body || !contentType.includes("text/event-stream")) {
|
|
159
|
+
scheduleReconnect();
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
setState("connected");
|
|
163
|
+
attempt = 0;
|
|
164
|
+
failingSince = null;
|
|
165
|
+
const reader = response.body.getReader();
|
|
166
|
+
const decoder = new TextDecoder();
|
|
167
|
+
for (;;) {
|
|
168
|
+
const { value, done } = await reader.read();
|
|
169
|
+
if (done) break;
|
|
170
|
+
for (const frame of parser.push(decoder.decode(value, { stream: true }))) handleFrame(frame);
|
|
171
|
+
}
|
|
172
|
+
if (running) {
|
|
173
|
+
setState("reconnecting");
|
|
174
|
+
retryTimer = setTimeout(() => void connect(), Math.random() * (serverRetryMs ?? retryMs));
|
|
175
|
+
}
|
|
176
|
+
} catch (error) {
|
|
177
|
+
if (!running || error instanceof DOMException && error.name === "AbortError") return;
|
|
178
|
+
scheduleReconnect();
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
function start() {
|
|
182
|
+
if (running) return;
|
|
183
|
+
running = true;
|
|
184
|
+
connect();
|
|
185
|
+
}
|
|
186
|
+
function stop() {
|
|
187
|
+
running = false;
|
|
188
|
+
if (retryTimer) clearTimeout(retryTimer);
|
|
189
|
+
abort?.abort();
|
|
190
|
+
abort = null;
|
|
191
|
+
setState("stopped");
|
|
192
|
+
}
|
|
193
|
+
return {
|
|
194
|
+
start,
|
|
195
|
+
stop,
|
|
196
|
+
state: () => state,
|
|
197
|
+
lastEventId: () => lastEventId
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
//#endregion
|
|
201
|
+
//#region src/events/useEventStream.ts
|
|
202
|
+
/**
|
|
203
|
+
* Vue composable over {@link createEventStreamClient}: reactive connection
|
|
204
|
+
* state, scope-bound lifecycle, and a typed per-event-type handler registry.
|
|
205
|
+
*
|
|
206
|
+
* The app owns *what to do* with events (invalidation registry, toasts, …);
|
|
207
|
+
* this composable owns the connection. Typical wiring, once, in the app
|
|
208
|
+
* shell:
|
|
209
|
+
*
|
|
210
|
+
* ```ts
|
|
211
|
+
* const stream = useEventStream({
|
|
212
|
+
* buildRequest: () => ({
|
|
213
|
+
* url: `${apiBase}/events`,
|
|
214
|
+
* headers: { authorization: `Bearer ${auth.accessToken}` },
|
|
215
|
+
* }),
|
|
216
|
+
* onEvent: (event) => invalidation.dispatch(event),
|
|
217
|
+
* });
|
|
218
|
+
* watch(tenantReady, (ready) => (ready ? stream.start() : stream.stop()));
|
|
219
|
+
* ```
|
|
220
|
+
*/
|
|
221
|
+
function useEventStream(options) {
|
|
222
|
+
const state = ref("idle");
|
|
223
|
+
const received = ref(0);
|
|
224
|
+
const client = createEventStreamClient({
|
|
225
|
+
...options,
|
|
226
|
+
onEvent: (event) => {
|
|
227
|
+
received.value += 1;
|
|
228
|
+
options.onEvent(event);
|
|
229
|
+
},
|
|
230
|
+
onStateChange: (next) => {
|
|
231
|
+
state.value = next;
|
|
232
|
+
options.onStateChange?.(next);
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
onScopeDispose(() => client.stop());
|
|
236
|
+
return {
|
|
237
|
+
state: readonly(state),
|
|
238
|
+
received: readonly(received),
|
|
239
|
+
start: client.start,
|
|
240
|
+
stop: client.stop,
|
|
241
|
+
lastEventId: client.lastEventId
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
//#endregion
|
|
245
|
+
export { createEventStreamClient, createSseFrameParser, useEventStream };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@octabits-io/nuxt-ui-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.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",
|
|
@@ -45,6 +45,11 @@
|
|
|
45
45
|
"import": "./dist/ai/index.js",
|
|
46
46
|
"default": "./dist/ai/index.js"
|
|
47
47
|
},
|
|
48
|
+
"./events": {
|
|
49
|
+
"types": "./dist/events/index.d.ts",
|
|
50
|
+
"import": "./dist/events/index.js",
|
|
51
|
+
"default": "./dist/events/index.js"
|
|
52
|
+
},
|
|
48
53
|
"./styles.css": "./src/styles.css",
|
|
49
54
|
"./components/*": "./src/components/*"
|
|
50
55
|
},
|
|
@@ -75,7 +80,7 @@
|
|
|
75
80
|
"vitest": "^4.1.10",
|
|
76
81
|
"vue": "^3.5.40",
|
|
77
82
|
"zod": "^4.4.3",
|
|
78
|
-
"@octabits-io/framework": "^0.
|
|
83
|
+
"@octabits-io/framework": "^0.19.1"
|
|
79
84
|
},
|
|
80
85
|
"peerDependencies": {
|
|
81
86
|
"@elysiajs/eden": "^1.4.0",
|
|
@@ -3,6 +3,11 @@
|
|
|
3
3
|
// explicit — no reliance on the consumer's auto-import configuration.
|
|
4
4
|
// i18n key contract: dateRange.* (checkIn/checkOut/errors.*/availability*/
|
|
5
5
|
// atTime/nextDay/checking) and period.travel.nights / period.booking.days.
|
|
6
|
+
//
|
|
7
|
+
// Sizing contract: the root is an inline-size @container (the inputs stack
|
|
8
|
+
// below 320px of own width), so its intrinsic width is 0. Parents must give
|
|
9
|
+
// it a definite width — block/grid context, `flex-1`, or an explicit
|
|
10
|
+
// `w-*`/`basis-*` — never shrink-to-fit.
|
|
6
11
|
import { computed, ref, watch } from 'vue'
|
|
7
12
|
import { useI18n } from 'vue-i18n'
|
|
8
13
|
import { CalendarDate } from '@internationalized/date'
|
|
@@ -510,109 +515,120 @@ const timeHintParts = computed(() =>
|
|
|
510
515
|
</script>
|
|
511
516
|
|
|
512
517
|
<template>
|
|
513
|
-
<div class="flex flex-col gap-1">
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
:aria-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
518
|
+
<div class="@container flex flex-col gap-1">
|
|
519
|
+
<!-- Container-responsive: side-by-side from 320px of *own* width, stacked
|
|
520
|
+
below. Own-width queries keep the behavior correct inside modals,
|
|
521
|
+
narrow panels, and next to the assistant dock alike. When stacked,
|
|
522
|
+
the arrow disappears and each input gets a compact label instead
|
|
523
|
+
(the popover titles), so the two identical inputs stay tellable. -->
|
|
524
|
+
<div class="flex flex-col gap-2 @xs:flex-row @xs:items-center">
|
|
525
|
+
<div class="flex w-full min-w-0 flex-col gap-1 @xs:flex-1">
|
|
526
|
+
<span class="text-xs text-muted @xs:hidden" aria-hidden="true">{{ startPopoverTitle }}</span>
|
|
527
|
+
<UInputDate
|
|
528
|
+
v-model="startDate"
|
|
529
|
+
:is-date-disabled="isDateDisabled"
|
|
530
|
+
:size="size"
|
|
531
|
+
:disabled="disabled"
|
|
532
|
+
:color="inputColor"
|
|
533
|
+
:aria-label="startLabel"
|
|
534
|
+
class="w-full"
|
|
535
|
+
>
|
|
536
|
+
<template #trailing>
|
|
537
|
+
<UPopover v-model:open="startPopoverOpen">
|
|
538
|
+
<UButton
|
|
539
|
+
color="neutral"
|
|
540
|
+
variant="link"
|
|
541
|
+
size="sm"
|
|
542
|
+
:icon="icon"
|
|
543
|
+
:aria-label="startLabel"
|
|
544
|
+
:disabled="disabled"
|
|
545
|
+
class="px-0"
|
|
546
|
+
/>
|
|
547
|
+
<template #content>
|
|
548
|
+
<div class="flex flex-col" @pointerleave="hoveredDay = null">
|
|
549
|
+
<p class="flex items-baseline justify-between gap-2 px-3 pt-2 pb-1">
|
|
550
|
+
<span class="text-xs font-medium text-muted uppercase tracking-wide">
|
|
551
|
+
{{ startPopoverTitle }}
|
|
552
|
+
</span>
|
|
553
|
+
<span v-if="spanLabel('start')" class="text-xs font-medium text-primary">
|
|
554
|
+
{{ spanLabel('start') }}
|
|
555
|
+
</span>
|
|
556
|
+
</p>
|
|
557
|
+
<UCalendar
|
|
558
|
+
:model-value="startDate"
|
|
559
|
+
:placeholder="startDate ?? endDate"
|
|
560
|
+
:is-date-disabled="isDateDisabled"
|
|
561
|
+
:ui="calendarUi"
|
|
562
|
+
class="p-2"
|
|
563
|
+
@update:model-value="onStartCalendarSelect"
|
|
564
|
+
>
|
|
565
|
+
<template #day="{ day }">
|
|
566
|
+
<span :class="dayPillClass(day, 'start')" @pointerenter="hoveredDay = day.toString()">
|
|
567
|
+
{{ day.day }}
|
|
568
|
+
</span>
|
|
569
|
+
</template>
|
|
570
|
+
</UCalendar>
|
|
571
|
+
</div>
|
|
572
|
+
</template>
|
|
573
|
+
</UPopover>
|
|
574
|
+
</template>
|
|
575
|
+
</UInputDate>
|
|
576
|
+
</div>
|
|
577
|
+
|
|
578
|
+
<span class="hidden shrink-0 text-muted @xs:inline" aria-hidden="true">→</span>
|
|
579
|
+
|
|
580
|
+
<div class="flex w-full min-w-0 flex-col gap-1 @xs:flex-1">
|
|
581
|
+
<span class="text-xs text-muted @xs:hidden" aria-hidden="true">{{ endPopoverTitle }}</span>
|
|
582
|
+
<UInputDate
|
|
583
|
+
v-model="endDate"
|
|
584
|
+
:is-date-disabled="isEndDateDisabled"
|
|
585
|
+
:size="size"
|
|
586
|
+
:disabled="disabled"
|
|
587
|
+
:color="inputColor"
|
|
588
|
+
:aria-label="endLabel"
|
|
589
|
+
class="w-full"
|
|
590
|
+
>
|
|
591
|
+
<template #trailing>
|
|
592
|
+
<UPopover v-model:open="endPopoverOpen">
|
|
593
|
+
<UButton
|
|
594
|
+
color="neutral"
|
|
595
|
+
variant="link"
|
|
596
|
+
size="sm"
|
|
597
|
+
:icon="icon"
|
|
598
|
+
:aria-label="endLabel"
|
|
599
|
+
:disabled="disabled"
|
|
600
|
+
class="px-0"
|
|
601
|
+
/>
|
|
602
|
+
<template #content>
|
|
603
|
+
<div class="flex flex-col" @pointerleave="hoveredDay = null">
|
|
604
|
+
<p class="flex items-baseline justify-between gap-2 px-3 pt-2 pb-1">
|
|
605
|
+
<span class="text-xs font-medium text-muted uppercase tracking-wide">
|
|
606
|
+
{{ endPopoverTitle }}
|
|
556
607
|
</span>
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
</div>
|
|
560
|
-
</template>
|
|
561
|
-
</UPopover>
|
|
562
|
-
</template>
|
|
563
|
-
</UInputDate>
|
|
564
|
-
|
|
565
|
-
<span class="text-muted shrink-0" aria-hidden="true">→</span>
|
|
566
|
-
|
|
567
|
-
<UInputDate
|
|
568
|
-
v-model="endDate"
|
|
569
|
-
:is-date-disabled="isEndDateDisabled"
|
|
570
|
-
:size="size"
|
|
571
|
-
:disabled="disabled"
|
|
572
|
-
:color="inputColor"
|
|
573
|
-
:aria-label="endLabel"
|
|
574
|
-
class="flex-1"
|
|
575
|
-
>
|
|
576
|
-
<template #trailing>
|
|
577
|
-
<UPopover v-model:open="endPopoverOpen">
|
|
578
|
-
<UButton
|
|
579
|
-
color="neutral"
|
|
580
|
-
variant="link"
|
|
581
|
-
size="sm"
|
|
582
|
-
:icon="icon"
|
|
583
|
-
:aria-label="endLabel"
|
|
584
|
-
:disabled="disabled"
|
|
585
|
-
class="px-0"
|
|
586
|
-
/>
|
|
587
|
-
<template #content>
|
|
588
|
-
<div class="flex flex-col" @pointerleave="hoveredDay = null">
|
|
589
|
-
<p class="flex items-baseline justify-between gap-2 px-3 pt-2 pb-1">
|
|
590
|
-
<span class="text-xs font-medium text-muted uppercase tracking-wide">
|
|
591
|
-
{{ endPopoverTitle }}
|
|
592
|
-
</span>
|
|
593
|
-
<span v-if="spanLabel('end')" class="text-xs font-medium text-primary">
|
|
594
|
-
{{ spanLabel('end') }}
|
|
595
|
-
</span>
|
|
596
|
-
</p>
|
|
597
|
-
<UCalendar
|
|
598
|
-
:model-value="endDate"
|
|
599
|
-
:placeholder="endDate ?? startDate"
|
|
600
|
-
:is-date-disabled="isEndDateDisabled"
|
|
601
|
-
:ui="calendarUi"
|
|
602
|
-
class="p-2"
|
|
603
|
-
@update:model-value="onEndCalendarSelect"
|
|
604
|
-
>
|
|
605
|
-
<template #day="{ day }">
|
|
606
|
-
<span :class="dayPillClass(day, 'end')" @pointerenter="hoveredDay = day.toString()">
|
|
607
|
-
{{ day.day }}
|
|
608
|
+
<span v-if="spanLabel('end')" class="text-xs font-medium text-primary">
|
|
609
|
+
{{ spanLabel('end') }}
|
|
608
610
|
</span>
|
|
609
|
-
</
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
611
|
+
</p>
|
|
612
|
+
<UCalendar
|
|
613
|
+
:model-value="endDate"
|
|
614
|
+
:placeholder="endDate ?? startDate"
|
|
615
|
+
:is-date-disabled="isEndDateDisabled"
|
|
616
|
+
:ui="calendarUi"
|
|
617
|
+
class="p-2"
|
|
618
|
+
@update:model-value="onEndCalendarSelect"
|
|
619
|
+
>
|
|
620
|
+
<template #day="{ day }">
|
|
621
|
+
<span :class="dayPillClass(day, 'end')" @pointerenter="hoveredDay = day.toString()">
|
|
622
|
+
{{ day.day }}
|
|
623
|
+
</span>
|
|
624
|
+
</template>
|
|
625
|
+
</UCalendar>
|
|
626
|
+
</div>
|
|
627
|
+
</template>
|
|
628
|
+
</UPopover>
|
|
629
|
+
</template>
|
|
630
|
+
</UInputDate>
|
|
631
|
+
</div>
|
|
616
632
|
</div>
|
|
617
633
|
|
|
618
634
|
<!-- Slot for a derived summary (e.g. the resulting travel period) shown
|
|
@@ -11,6 +11,11 @@
|
|
|
11
11
|
// i18n key contract: flexPeriod.* (earliestStart/latestEnd/nightsLabel/
|
|
12
12
|
// clear/windowSpan/flexibility/example/errors.*) and period.travel.nights,
|
|
13
13
|
// plus the composed DateRangeInput's dateRange.* keys.
|
|
14
|
+
//
|
|
15
|
+
// Sizing contract: the root is an inline-size @container (single row from
|
|
16
|
+
// 512px of own width, stacked below), so its intrinsic width is 0. Parents
|
|
17
|
+
// must give it a definite width — block/grid context, `flex-1`, or an
|
|
18
|
+
// explicit `w-*`/`basis-*` — never shrink-to-fit.
|
|
14
19
|
import { computed, watch } from 'vue'
|
|
15
20
|
import { useI18n } from 'vue-i18n'
|
|
16
21
|
import UInputNumber from '@nuxt/ui/components/InputNumber.vue'
|
|
@@ -188,8 +193,13 @@ watch(
|
|
|
188
193
|
</script>
|
|
189
194
|
|
|
190
195
|
<template>
|
|
191
|
-
<div class="flex flex-col gap-1">
|
|
192
|
-
|
|
196
|
+
<div class="@container flex flex-col gap-1">
|
|
197
|
+
<!-- Container-responsive: one row (window + nights + clear) from 512px of
|
|
198
|
+
*own* width; below that the window goes full-width and nights + clear
|
|
199
|
+
drop to their own line (the nights input gains a compact label, since
|
|
200
|
+
its placeholder vanishes once a value is set). The inner
|
|
201
|
+
DateRangeInput additionally stacks its two dates below 320px. -->
|
|
202
|
+
<div class="flex flex-col gap-2 @lg:flex-row @lg:items-start">
|
|
193
203
|
<DateRangeInput
|
|
194
204
|
v-model="innerPeriod"
|
|
195
205
|
kind="travel"
|
|
@@ -198,29 +208,34 @@ watch(
|
|
|
198
208
|
:icon="icon"
|
|
199
209
|
:start-label="startAriaLabel"
|
|
200
210
|
: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"
|
|
211
|
+
class="w-full @lg:flex-1"
|
|
223
212
|
/>
|
|
213
|
+
<div class="flex items-end gap-2 @lg:shrink-0">
|
|
214
|
+
<div class="flex w-full flex-col gap-1 @lg:w-28">
|
|
215
|
+
<span class="text-xs text-muted @lg:hidden" aria-hidden="true">{{ t('flexPeriod.nightsLabel') }}</span>
|
|
216
|
+
<UInputNumber
|
|
217
|
+
v-model="nightsValue"
|
|
218
|
+
:min="minNights"
|
|
219
|
+
:max="maxNights"
|
|
220
|
+
:size="size"
|
|
221
|
+
:disabled="disabled"
|
|
222
|
+
:color="nightsInputColor"
|
|
223
|
+
:aria-label="t('flexPeriod.nightsLabel')"
|
|
224
|
+
:placeholder="t('flexPeriod.nightsLabel')"
|
|
225
|
+
class="w-full"
|
|
226
|
+
/>
|
|
227
|
+
</div>
|
|
228
|
+
<UButton
|
|
229
|
+
v-if="clearable && hasAnyValue"
|
|
230
|
+
icon="i-lucide-x"
|
|
231
|
+
color="neutral"
|
|
232
|
+
variant="ghost"
|
|
233
|
+
:size="size"
|
|
234
|
+
:disabled="disabled"
|
|
235
|
+
:aria-label="t('flexPeriod.clear')"
|
|
236
|
+
@click="clearAll"
|
|
237
|
+
/>
|
|
238
|
+
</div>
|
|
224
239
|
</div>
|
|
225
240
|
|
|
226
241
|
<!-- Slot for a derived summary shown between the inputs and the hints,
|