@frigear-nu/ui 0.1.0 → 0.1.2
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 +1 -0
- package/dist/runtime/components/global/PageSectionSwitcher.d.vue.ts +14 -0
- package/dist/runtime/components/global/PageSectionSwitcher.vue +41 -0
- package/dist/runtime/components/global/PageSectionSwitcher.vue.d.ts +14 -0
- package/dist/runtime/composables/index.d.ts +2 -0
- package/dist/runtime/composables/index.js +2 -0
- package/dist/runtime/composables/useBootstrap.d.ts +33 -0
- package/dist/runtime/composables/useBootstrap.js +45 -0
- package/dist/runtime/composables/useCachedFetch.d.ts +76 -0
- package/dist/runtime/composables/useCachedFetch.js +61 -0
- package/dist/runtime/i18n/lang/da.json +1 -0
- package/dist/runtime/i18n/lang/en.json +1 -0
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -12,6 +12,7 @@ English fallbacks; every one of them can be overridden by a prop.
|
|
|
12
12
|
| Component | Use |
|
|
13
13
|
|---|---|
|
|
14
14
|
| `PageHeading` | Title block of an app page: breadcrumb (array) or eyebrow line (string), `#badge`, description, meta row, `#actions` |
|
|
15
|
+
| `PageSectionSwitcher` | Full-width button naming the current section, with a menu of all sections (mobile section navigation) |
|
|
15
16
|
| `PageTabs` | Tabs under a heading: links (`to`, route decides) or state (`v-model` + `value`) |
|
|
16
17
|
| `SectionCard` | A titled card for one section (form, key facts); `flush` for tables and lists |
|
|
17
18
|
| `TableCard` | The surface a `UTable` sits in (`:ui="tableUi()"`), with `#header` and `#footer` (total, pagination) |
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { NavigationMenuItem } from '@nuxt/ui';
|
|
2
|
+
/**
|
|
3
|
+
* A full-width button naming the current section, with a menu of all sections. The narrow-screen counterpart of a
|
|
4
|
+
* vertical section navigation (`UNavigationMenu`), which does not fit beside the content on a phone. Items are links
|
|
5
|
+
* (`to`), and the one with `active` is the current section; `PageTabs` stays for tabs a page owns.
|
|
6
|
+
*/
|
|
7
|
+
type __VLS_Props = {
|
|
8
|
+
items: NavigationMenuItem[];
|
|
9
|
+
/** Overrides the accessible name of the button. */
|
|
10
|
+
label?: string;
|
|
11
|
+
};
|
|
12
|
+
declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
|
|
13
|
+
declare const _default: typeof __VLS_export;
|
|
14
|
+
export default _default;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
const props = defineProps({
|
|
3
|
+
items: { type: Array, required: true },
|
|
4
|
+
label: { type: String, required: false }
|
|
5
|
+
});
|
|
6
|
+
const t = useUiT("patterns");
|
|
7
|
+
const current = computed(() => props.items.find((item) => item.active) ?? props.items[0]);
|
|
8
|
+
const menu = computed(() => props.items.map((item) => ({
|
|
9
|
+
label: item.label,
|
|
10
|
+
icon: item.icon,
|
|
11
|
+
to: item.to,
|
|
12
|
+
target: item.target,
|
|
13
|
+
external: item.external,
|
|
14
|
+
active: item.active,
|
|
15
|
+
trailingIcon: item.active ? "i-lucide-check" : void 0
|
|
16
|
+
})));
|
|
17
|
+
const ariaLabel = computed(() => {
|
|
18
|
+
const name = props.label ?? t("sections", "Sections");
|
|
19
|
+
return current.value?.label ? `${name}: ${current.value.label}` : name;
|
|
20
|
+
});
|
|
21
|
+
</script>
|
|
22
|
+
|
|
23
|
+
<template>
|
|
24
|
+
<UDropdownMenu
|
|
25
|
+
v-if="items.length"
|
|
26
|
+
:items="menu"
|
|
27
|
+
:content="{ align: 'start' }"
|
|
28
|
+
:ui="{ content: 'w-(--reka-dropdown-menu-trigger-width)' }"
|
|
29
|
+
>
|
|
30
|
+
<UButton
|
|
31
|
+
color="neutral"
|
|
32
|
+
variant="outline"
|
|
33
|
+
block
|
|
34
|
+
:icon="current?.icon"
|
|
35
|
+
trailing-icon="i-lucide-chevrons-up-down"
|
|
36
|
+
:label="current?.label"
|
|
37
|
+
:aria-label="ariaLabel"
|
|
38
|
+
:ui="{ trailingIcon: 'ms-auto text-dimmed' }"
|
|
39
|
+
/>
|
|
40
|
+
</UDropdownMenu>
|
|
41
|
+
</template>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { NavigationMenuItem } from '@nuxt/ui';
|
|
2
|
+
/**
|
|
3
|
+
* A full-width button naming the current section, with a menu of all sections. The narrow-screen counterpart of a
|
|
4
|
+
* vertical section navigation (`UNavigationMenu`), which does not fit beside the content on a phone. Items are links
|
|
5
|
+
* (`to`), and the one with `active` is the current section; `PageTabs` stays for tabs a page owns.
|
|
6
|
+
*/
|
|
7
|
+
type __VLS_Props = {
|
|
8
|
+
items: NavigationMenuItem[];
|
|
9
|
+
/** Overrides the accessible name of the button. */
|
|
10
|
+
label?: string;
|
|
11
|
+
};
|
|
12
|
+
declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
|
|
13
|
+
declare const _default: typeof __VLS_export;
|
|
14
|
+
export default _default;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** What the app knows about an area's last bootstrap: when it loaded and which keys it primed. */
|
|
2
|
+
export interface BootstrapState {
|
|
3
|
+
at: number;
|
|
4
|
+
keys: string[];
|
|
5
|
+
}
|
|
6
|
+
export interface LoadBootstrapOptions {
|
|
7
|
+
/**
|
|
8
|
+
* How long (ms) a bootstrap counts as fresh: entering the area again within it makes no request. Keep it at or
|
|
9
|
+
* below the `ttl` of the composables it primes, so they always find their entry fresh. Default one minute.
|
|
10
|
+
*/
|
|
11
|
+
ttl?: number;
|
|
12
|
+
}
|
|
13
|
+
export declare const BOOTSTRAP_TTL_MS: number;
|
|
14
|
+
/** The last bootstrap of `scope` (`null` before the first, after `resetBootstrap` or when it failed). */
|
|
15
|
+
export declare const useBootstrapState: (scope: string) => any;
|
|
16
|
+
/**
|
|
17
|
+
* Load an area's user-scoped data in one request (`GET /api/_bootstrap?scope=<scope>`, served by `@frigear-nu/auth`)
|
|
18
|
+
* and prime the `useCachedFetch` entry of every key it returns, so the modules' composables (`useMyTickets()`,
|
|
19
|
+
* `useSubscription()`, ...) render without a request of their own. Call it from a route middleware of the area: it
|
|
20
|
+
* runs before the page's components set up, on the server and on every client navigation.
|
|
21
|
+
*
|
|
22
|
+
* - Server: the data goes into the payload, used by the server render and by hydration.
|
|
23
|
+
* - Hydration: the payload entries are remembered for later navigation; nothing is fetched.
|
|
24
|
+
* - Client navigation: fetches again only when the last bootstrap is older than `ttl`.
|
|
25
|
+
*
|
|
26
|
+
* Never throws: when the request fails (or leaves a key out) the composables fetch from their own endpoints.
|
|
27
|
+
*/
|
|
28
|
+
export declare function loadBootstrap(scope: string, options?: LoadBootstrapOptions): Promise<void>;
|
|
29
|
+
/**
|
|
30
|
+
* Forget an area's bootstrap and the cache entries it primed, so the next visit loads them again. Call it when the
|
|
31
|
+
* data no longer belongs to the visitor (signing in or out).
|
|
32
|
+
*/
|
|
33
|
+
export declare function resetBootstrap(scope: string): void;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { clearCachedFetch, primeCachedFetch } from "./useCachedFetch.js";
|
|
2
|
+
export const BOOTSTRAP_TTL_MS = 60 * 1e3;
|
|
3
|
+
const stateKey = (scope) => `ui:bootstrap:${scope}`;
|
|
4
|
+
const inFlight = /* @__PURE__ */ new Map();
|
|
5
|
+
export const useBootstrapState = (scope) => useState(stateKey(scope), () => null);
|
|
6
|
+
export async function loadBootstrap(scope, options = {}) {
|
|
7
|
+
const nuxtApp = useNuxtApp();
|
|
8
|
+
const state = useBootstrapState(scope);
|
|
9
|
+
const ttl = options.ttl ?? BOOTSTRAP_TTL_MS;
|
|
10
|
+
if (import.meta.client) {
|
|
11
|
+
if (nuxtApp.isHydrating && nuxtApp.payload.serverRendered) {
|
|
12
|
+
if (!state.value) return;
|
|
13
|
+
for (const key of state.value.keys) {
|
|
14
|
+
const data = nuxtApp.payload.data[key];
|
|
15
|
+
if (data !== void 0) primeCachedFetch(key, data);
|
|
16
|
+
}
|
|
17
|
+
state.value = { ...state.value, at: Date.now() };
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
if (state.value && Date.now() - state.value.at < ttl) return;
|
|
21
|
+
const pending = inFlight.get(scope);
|
|
22
|
+
if (pending) return pending;
|
|
23
|
+
}
|
|
24
|
+
const run = async () => {
|
|
25
|
+
try {
|
|
26
|
+
const fetcher = import.meta.server ? useRequestFetch() : $fetch;
|
|
27
|
+
const data = await fetcher("/api/_bootstrap", { query: { scope } });
|
|
28
|
+
nuxtApp.runWithContext(() => {
|
|
29
|
+
for (const [key, value] of Object.entries(data)) primeCachedFetch(key, value);
|
|
30
|
+
});
|
|
31
|
+
state.value = { at: Date.now(), keys: Object.keys(data) };
|
|
32
|
+
} catch (error) {
|
|
33
|
+
if (import.meta.dev) console.warn(`[ui] bootstrap "${scope}" failed, the pages load their own data:`, error);
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
if (import.meta.server) return run();
|
|
37
|
+
const promise = run().finally(() => inFlight.delete(scope));
|
|
38
|
+
inFlight.set(scope, promise);
|
|
39
|
+
return promise;
|
|
40
|
+
}
|
|
41
|
+
export function resetBootstrap(scope) {
|
|
42
|
+
const state = useBootstrapState(scope);
|
|
43
|
+
if (state.value?.keys.length) clearCachedFetch(state.value.keys);
|
|
44
|
+
state.value = null;
|
|
45
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { ComputedRef, MaybeRefOrGetter, Ref } from 'vue';
|
|
2
|
+
import type { AsyncData, NuxtApp, NuxtError, UseFetchOptions } from 'nuxt/app';
|
|
3
|
+
/** One remembered response: what `useFetch` produced (after `transform`) and when. */
|
|
4
|
+
export interface CachedFetchEntry<T = unknown> {
|
|
5
|
+
at: number;
|
|
6
|
+
data: T;
|
|
7
|
+
}
|
|
8
|
+
export type CachedFetchStore = Record<string, CachedFetchEntry>;
|
|
9
|
+
export type CachedFetchOptions<ResT, DataT = ResT> = Omit<UseFetchOptions<ResT, DataT>, 'key' | 'default' | 'getCachedData'> & {
|
|
10
|
+
/** The fetch key. Required: it names the cache entry, so composables that share data must share the key. */
|
|
11
|
+
key: MaybeRefOrGetter<string>;
|
|
12
|
+
/**
|
|
13
|
+
* How long (ms) a remembered response is reused when a component mounts again (client navigation) without asking
|
|
14
|
+
* the server. Leave it out to reuse it until `refresh()`; `0` always refetches. Either way the remembered response
|
|
15
|
+
* stays on screen while the new one loads.
|
|
16
|
+
*/
|
|
17
|
+
ttl?: number;
|
|
18
|
+
};
|
|
19
|
+
export type CachedFetchResult<DataT> = Omit<Awaited<AsyncData<DataT | undefined, NuxtError | undefined>>, 'pending'> & {
|
|
20
|
+
/**
|
|
21
|
+
* `true` only while there is nothing to show yet: the first load of this key. Background refreshes (a stale cache,
|
|
22
|
+
* `refresh()`) keep the last data on screen and leave it `false`. Named `loading` so it does not shadow Nuxt's
|
|
23
|
+
* deprecated `pending` (which is `status === 'pending'`).
|
|
24
|
+
*/
|
|
25
|
+
loading: ComputedRef<boolean>;
|
|
26
|
+
};
|
|
27
|
+
/** The shared, per-app cache behind `useCachedFetch`. Only the client writes to it, so it adds nothing to the payload. */
|
|
28
|
+
export declare const useCachedFetchStore: () => Ref<CachedFetchStore>;
|
|
29
|
+
/**
|
|
30
|
+
* Remember a response for `key` as if `useCachedFetch` had fetched it, e.g. data loaded ahead of time (a warm-up, an
|
|
31
|
+
* aggregated request such as `loadBootstrap`). `data` must have the shape the composable's `transform` produces.
|
|
32
|
+
*
|
|
33
|
+
* On the server it goes into this request's payload under `key`, where `useCachedFetch` finds it during server
|
|
34
|
+
* rendering (no request of its own) and the client finds it while hydrating. Prime before the components using
|
|
35
|
+
* `key` set up (a route middleware), so they see it.
|
|
36
|
+
*/
|
|
37
|
+
export declare function primeCachedFetch<T>(key: string, data: T): void;
|
|
38
|
+
/**
|
|
39
|
+
* Forget remembered responses so the next mount fetches again: one key, the keys a predicate accepts, or all of them.
|
|
40
|
+
* Call it when the data no longer belongs to the visitor (signing out).
|
|
41
|
+
*/
|
|
42
|
+
export declare function clearCachedFetch(keys?: string | string[] | ((key: string) => boolean)): void;
|
|
43
|
+
/**
|
|
44
|
+
* Where `useCachedFetch` gets data without a request, in this order:
|
|
45
|
+
* 1. while hydrating, the server's response from the payload (and it is remembered for later mounts); on the server,
|
|
46
|
+
* data primed for this request (`primeCachedFetch`);
|
|
47
|
+
* 2. on a mount (`cause: 'initial'`), the remembered response while it is younger than `ttl`;
|
|
48
|
+
* 3. otherwise nothing, so `useFetch` fetches. Refreshes (`refresh()`, `refreshNuxtData`, watchers) always fetch.
|
|
49
|
+
*
|
|
50
|
+
* Exported for tests; use `useCachedFetch`.
|
|
51
|
+
*/
|
|
52
|
+
export declare function cachedFetchData(key: string, nuxtApp: Pick<NuxtApp, 'isHydrating' | 'payload'>, cause: string, cache: CachedFetchStore, ttl: number | undefined, now?: number, server?: boolean): {
|
|
53
|
+
data: unknown;
|
|
54
|
+
remember: boolean;
|
|
55
|
+
} | undefined;
|
|
56
|
+
/**
|
|
57
|
+
* `useFetch` whose data survives client navigation and that is awaitable in pages.
|
|
58
|
+
*
|
|
59
|
+
* Nuxt drops `useFetch` data once the last component using a key unmounts, so going back to a page blanked it and
|
|
60
|
+
* showed the empty state until a refetch landed. `useCachedFetch` remembers each response by `key` (see
|
|
61
|
+
* `cachedFetchData` for the order) and shows the remembered one at once, refetching in the background when it is
|
|
62
|
+
* older than `ttl`.
|
|
63
|
+
*
|
|
64
|
+
* - `data` is `undefined` until the first response; there is no `default`, so a failed request never looks like an
|
|
65
|
+
* empty result. Check `error` (and `status`) and derive lists with `?? []`.
|
|
66
|
+
* - `loading` is `true` only while nothing is on screen yet: render a skeleton for it, never the empty state.
|
|
67
|
+
* - `await` it in pages so SSR renders with data; in a widget, use it without `await` and render from `loading`.
|
|
68
|
+
*
|
|
69
|
+
* ```ts
|
|
70
|
+
* const { data, error, loading, refresh } = await useCachedFetch<TicketDto[]>('/api/events/tickets/me', {
|
|
71
|
+
* key: 'events:tickets:mine',
|
|
72
|
+
* ttl: 60_000,
|
|
73
|
+
* })
|
|
74
|
+
* ```
|
|
75
|
+
*/
|
|
76
|
+
export declare function useCachedFetch<ResT, DataT = ResT>(request: string | Ref<string> | (() => string), options: CachedFetchOptions<ResT, DataT>): CachedFetchResult<DataT> & Promise<CachedFetchResult<DataT>>;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
const STATE_KEY = "ui:cached-fetch";
|
|
2
|
+
export const useCachedFetchStore = () => useState(STATE_KEY, () => ({}));
|
|
3
|
+
export function primeCachedFetch(key, data) {
|
|
4
|
+
if (import.meta.server) {
|
|
5
|
+
useNuxtApp().payload.data[key] = data;
|
|
6
|
+
return;
|
|
7
|
+
}
|
|
8
|
+
useCachedFetchStore().value[key] = markRaw({ at: Date.now(), data });
|
|
9
|
+
}
|
|
10
|
+
export function clearCachedFetch(keys) {
|
|
11
|
+
const cache = useCachedFetchStore();
|
|
12
|
+
const drops = keys === void 0 ? () => true : typeof keys === "function" ? keys : (key) => (Array.isArray(keys) ? keys : [keys]).includes(key);
|
|
13
|
+
cache.value = Object.fromEntries(Object.entries(cache.value).filter(([key]) => !drops(key)));
|
|
14
|
+
}
|
|
15
|
+
export function cachedFetchData(key, nuxtApp, cause, cache, ttl, now = Date.now(), server = !!import.meta.server) {
|
|
16
|
+
if ((nuxtApp.isHydrating || server && cause === "initial") && nuxtApp.payload.data[key] !== void 0) {
|
|
17
|
+
return { data: nuxtApp.payload.data[key], remember: true };
|
|
18
|
+
}
|
|
19
|
+
if (cause !== "initial") return void 0;
|
|
20
|
+
const entry = cache[key];
|
|
21
|
+
if (!entry) return void 0;
|
|
22
|
+
if (ttl !== void 0 && now - entry.at >= ttl) return void 0;
|
|
23
|
+
return { data: entry.data, remember: false };
|
|
24
|
+
}
|
|
25
|
+
export function useCachedFetch(request, options) {
|
|
26
|
+
const cache = useCachedFetchStore();
|
|
27
|
+
const { key, ttl, transform, ...rest } = options;
|
|
28
|
+
const remember = (name, data) => {
|
|
29
|
+
if (!import.meta.server) cache.value[name] = markRaw({ at: Date.now(), data });
|
|
30
|
+
};
|
|
31
|
+
const fetched = useFetch(request, {
|
|
32
|
+
...rest,
|
|
33
|
+
key,
|
|
34
|
+
// No caller default: the remembered (possibly stale) response, so a refetch never blanks the screen, or nothing.
|
|
35
|
+
default: () => cache.value[toValue(key)]?.data,
|
|
36
|
+
getCachedData: (name, nuxtApp, ctx) => {
|
|
37
|
+
const hit = cachedFetchData(name, nuxtApp, ctx.cause, cache.value, ttl);
|
|
38
|
+
if (hit?.remember) remember(name, hit.data);
|
|
39
|
+
return hit?.data;
|
|
40
|
+
},
|
|
41
|
+
transform: async (input) => {
|
|
42
|
+
const output = transform ? await transform(input) : input;
|
|
43
|
+
remember(toValue(key), output);
|
|
44
|
+
return output;
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
const result = {
|
|
48
|
+
data: fetched.data,
|
|
49
|
+
status: fetched.status,
|
|
50
|
+
error: fetched.error,
|
|
51
|
+
refresh: fetched.refresh,
|
|
52
|
+
execute: fetched.execute,
|
|
53
|
+
clear: () => {
|
|
54
|
+
const name = toValue(key);
|
|
55
|
+
cache.value = Object.fromEntries(Object.entries(cache.value).filter(([entry]) => entry !== name));
|
|
56
|
+
fetched.clear();
|
|
57
|
+
},
|
|
58
|
+
loading: computed(() => fetched.status.value === "pending" && fetched.data.value === void 0)
|
|
59
|
+
};
|
|
60
|
+
return Object.assign(Promise.resolve(fetched).then(() => result), result);
|
|
61
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frigear-nu/ui",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Shared UI components and composables for Frigear Nuxt applications, built on Nuxt UI.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://docs.frigear.dev/modules/ui",
|
|
@@ -34,17 +34,17 @@
|
|
|
34
34
|
"dist"
|
|
35
35
|
],
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@frigear-nu/module-kit": "1.1.
|
|
37
|
+
"@frigear-nu/module-kit": "1.1.2",
|
|
38
38
|
"@nuxt/kit": "^4.6.1",
|
|
39
39
|
"@vueuse/core": "^13.5.0",
|
|
40
40
|
"defu": "^6.1.4"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
|
-
"@frigear-nu/eslint-config": "0.1.
|
|
43
|
+
"@frigear-nu/eslint-config": "0.1.2",
|
|
44
44
|
"@nuxt/module-builder": "^1.0.3",
|
|
45
45
|
"@nuxt/schema": "^4.6.1",
|
|
46
46
|
"@nuxt/test-utils": "^4.3.2",
|
|
47
|
-
"@nuxt/ui": "^4.
|
|
47
|
+
"@nuxt/ui": "^4.12.0",
|
|
48
48
|
"eslint": "^10.12.0",
|
|
49
49
|
"monaco-editor": "^0.56.0",
|
|
50
50
|
"nuxt": "^4.6.1",
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
"vue-tsc": "^3.3.11"
|
|
56
56
|
},
|
|
57
57
|
"peerDependencies": {
|
|
58
|
-
"@nuxt/ui": "^4.
|
|
58
|
+
"@nuxt/ui": "^4.12.0",
|
|
59
59
|
"nuxt": "^4.6.1",
|
|
60
60
|
"vue": "3.x",
|
|
61
61
|
"monaco-editor": "^0.56.0",
|