@frigear-nu/ui 0.0.9 → 0.1.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 +46 -2
- package/dist/module.mjs +12 -9
- package/dist/runtime/components/ConfirmDialog.d.vue.ts +1 -0
- package/dist/runtime/components/ConfirmDialog.vue +4 -2
- package/dist/runtime/components/ConfirmDialog.vue.d.ts +1 -0
- package/dist/runtime/components/global/DescriptionList.d.vue.ts +28 -0
- package/dist/runtime/components/global/DescriptionList.vue +51 -0
- package/dist/runtime/components/global/DescriptionList.vue.d.ts +28 -0
- package/dist/runtime/components/global/FormSlideover.d.vue.ts +46 -0
- package/dist/runtime/components/global/FormSlideover.vue +53 -0
- package/dist/runtime/components/global/FormSlideover.vue.d.ts +46 -0
- package/dist/runtime/components/global/ListCard.d.vue.ts +29 -0
- package/dist/runtime/components/global/ListCard.vue +74 -0
- package/dist/runtime/components/global/ListCard.vue.d.ts +29 -0
- package/dist/runtime/components/global/PageHeading.d.vue.ts +28 -0
- package/dist/runtime/components/global/PageHeading.vue +53 -0
- package/dist/runtime/components/global/PageHeading.vue.d.ts +28 -0
- package/dist/runtime/components/global/PageTabs.d.vue.ts +19 -0
- package/dist/runtime/components/global/PageTabs.vue +28 -0
- package/dist/runtime/components/global/PageTabs.vue.d.ts +19 -0
- package/dist/runtime/components/global/PageToolbar.d.vue.ts +15 -0
- package/dist/runtime/components/global/PageToolbar.vue +15 -0
- package/dist/runtime/components/global/PageToolbar.vue.d.ts +15 -0
- package/dist/runtime/components/global/RowMenu.d.vue.ts +19 -0
- package/dist/runtime/components/global/RowMenu.vue +31 -0
- package/dist/runtime/components/global/RowMenu.vue.d.ts +19 -0
- package/dist/runtime/components/global/SectionCard.d.vue.ts +31 -0
- package/dist/runtime/components/global/SectionCard.vue +68 -0
- package/dist/runtime/components/global/SectionCard.vue.d.ts +31 -0
- package/dist/runtime/components/global/StatCard.d.vue.ts +26 -0
- package/dist/runtime/components/global/StatCard.vue +62 -0
- package/dist/runtime/components/global/StatCard.vue.d.ts +26 -0
- package/dist/runtime/components/global/TableCard.d.vue.ts +31 -0
- package/dist/runtime/components/global/TableCard.vue +46 -0
- package/dist/runtime/components/global/TableCard.vue.d.ts +31 -0
- package/dist/runtime/components/global/TableIdentity.d.vue.ts +23 -0
- package/dist/runtime/components/global/TableIdentity.vue +28 -0
- package/dist/runtime/components/global/TableIdentity.vue.d.ts +23 -0
- package/dist/runtime/components/global/TablePagination.d.vue.ts +36 -0
- package/dist/runtime/components/global/TablePagination.vue +51 -0
- package/dist/runtime/components/global/TablePagination.vue.d.ts +36 -0
- package/dist/runtime/composables/index.d.ts +8 -0
- package/dist/runtime/composables/index.js +8 -0
- package/dist/runtime/composables/useAction.d.ts +14 -0
- package/dist/runtime/composables/useAction.js +20 -0
- package/dist/runtime/composables/useCopyToClipboard.d.ts +4 -0
- package/dist/runtime/composables/useCopyToClipboard.js +20 -0
- package/dist/runtime/composables/useErrorToast.d.ts +9 -0
- package/dist/runtime/composables/useErrorToast.js +9 -0
- package/dist/runtime/composables/useFormat.d.ts +23 -0
- package/dist/runtime/composables/useFormat.js +19 -0
- package/dist/runtime/composables/useLocaleLink.d.ts +13 -0
- package/dist/runtime/composables/useLocaleLink.js +19 -0
- package/dist/runtime/composables/useNavLinks.d.ts +13 -0
- package/dist/runtime/composables/useNavLinks.js +15 -0
- package/dist/runtime/composables/useTableQuery.d.ts +34 -0
- package/dist/runtime/composables/useTableQuery.js +70 -0
- package/dist/runtime/composables/useTableSort.d.ts +21 -0
- package/dist/runtime/composables/useTableSort.js +40 -0
- package/dist/runtime/i18n/lang/da.json +19 -0
- package/dist/runtime/i18n/lang/en.json +19 -0
- package/dist/runtime/types.d.ts +34 -0
- package/dist/runtime/types.js +0 -0
- package/dist/runtime/utils/errors.d.ts +9 -0
- package/dist/runtime/utils/errors.js +18 -0
- package/dist/runtime/utils/format.d.ts +12 -0
- package/dist/runtime/utils/format.js +26 -0
- package/dist/runtime/utils/index.d.ts +6 -0
- package/dist/runtime/utils/index.js +6 -0
- package/dist/runtime/utils/locale.d.ts +8 -0
- package/dist/runtime/utils/locale.js +10 -0
- package/dist/runtime/utils/status.d.ts +7 -0
- package/dist/runtime/utils/status.js +17 -0
- package/dist/runtime/utils/surface.d.ts +22 -0
- package/dist/runtime/utils/surface.js +10 -0
- package/dist/runtime/utils/table.d.ts +28 -0
- package/dist/runtime/utils/table.js +17 -0
- package/package.json +28 -14
- package/dist/runtime/components/App/AppPageHeader.d.vue.ts +0 -23
- package/dist/runtime/components/App/AppPageHeader.vue +0 -58
- package/dist/runtime/components/App/AppPageHeader.vue.d.ts +0 -23
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { tablePageRange } from "../../utils/table";
|
|
3
|
+
const props = defineProps({
|
|
4
|
+
total: { type: Number, required: true },
|
|
5
|
+
pageSizes: { type: Array, required: false, default: () => [20, 50, 100] }
|
|
6
|
+
});
|
|
7
|
+
const page = defineModel("page", { type: Number, ...{ default: 1 } });
|
|
8
|
+
const pageSize = defineModel("pageSize", { type: Number, ...{ default: 20 } });
|
|
9
|
+
const t = useUiT("patterns");
|
|
10
|
+
const range = computed(() => tablePageRange(page.value, pageSize.value, props.total));
|
|
11
|
+
const sizes = computed(() => [.../* @__PURE__ */ new Set([...props.pageSizes, pageSize.value])].sort((a, b) => a - b));
|
|
12
|
+
const paged = computed(() => props.total > Math.min(...sizes.value));
|
|
13
|
+
const pages = computed(() => props.total > pageSize.value || page.value > 1);
|
|
14
|
+
function setPageSize(value) {
|
|
15
|
+
pageSize.value = Number(value);
|
|
16
|
+
page.value = 1;
|
|
17
|
+
}
|
|
18
|
+
</script>
|
|
19
|
+
|
|
20
|
+
<template>
|
|
21
|
+
<div class="flex w-full flex-wrap items-center justify-between gap-3">
|
|
22
|
+
<p class="tabular-nums">
|
|
23
|
+
<slot :range="range">
|
|
24
|
+
{{ range.to ? t("range", "{from}\u2013{to} of {total}", { from: range.from, to: range.to, total }) : t("noRows", "No rows") }}
|
|
25
|
+
</slot>
|
|
26
|
+
</p>
|
|
27
|
+
<div class="flex flex-wrap items-center gap-3">
|
|
28
|
+
<label
|
|
29
|
+
v-if="paged"
|
|
30
|
+
class="flex items-center gap-2"
|
|
31
|
+
>
|
|
32
|
+
<span class="max-sm:sr-only">{{ t("rowsPerPage", "Rows per page") }}</span>
|
|
33
|
+
<USelect
|
|
34
|
+
:model-value="pageSize"
|
|
35
|
+
:items="sizes"
|
|
36
|
+
size="sm"
|
|
37
|
+
class="w-20"
|
|
38
|
+
@update:model-value="setPageSize"
|
|
39
|
+
/>
|
|
40
|
+
</label>
|
|
41
|
+
<UPagination
|
|
42
|
+
v-if="pages"
|
|
43
|
+
v-model:page="page"
|
|
44
|
+
:items-per-page="pageSize"
|
|
45
|
+
:total="total"
|
|
46
|
+
:sibling-count="1"
|
|
47
|
+
size="sm"
|
|
48
|
+
/>
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
</template>
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The footer of a paginated list, inside `TableCard #footer`: "21–40 of 132", rows per page and the pages. Pages
|
|
3
|
+
* and the page-size select hide while everything fits on one page. Changing the page size goes back to page 1.
|
|
4
|
+
*/
|
|
5
|
+
type __VLS_Props = {
|
|
6
|
+
total: number;
|
|
7
|
+
pageSizes?: number[];
|
|
8
|
+
};
|
|
9
|
+
type __VLS_ModelProps = {
|
|
10
|
+
'page'?: number;
|
|
11
|
+
'pageSize'?: number;
|
|
12
|
+
};
|
|
13
|
+
type __VLS_PublicProps = __VLS_Props & __VLS_ModelProps;
|
|
14
|
+
declare var __VLS_1: {
|
|
15
|
+
range: any;
|
|
16
|
+
};
|
|
17
|
+
type __VLS_Slots = {} & {
|
|
18
|
+
default?: (props: typeof __VLS_1) => any;
|
|
19
|
+
};
|
|
20
|
+
declare const __VLS_base: import("vue").DefineComponent<__VLS_PublicProps, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
|
|
21
|
+
"update:page": (value: number) => any;
|
|
22
|
+
"update:pageSize": (value: number) => any;
|
|
23
|
+
}, string, import("vue").PublicProps, Readonly<__VLS_PublicProps> & Readonly<{
|
|
24
|
+
"onUpdate:page"?: ((value: number) => any) | undefined;
|
|
25
|
+
"onUpdate:pageSize"?: ((value: number) => any) | undefined;
|
|
26
|
+
}>, {
|
|
27
|
+
pageSizes: number[];
|
|
28
|
+
}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
|
|
29
|
+
declare const __VLS_export: __VLS_WithSlots<typeof __VLS_base, __VLS_Slots>;
|
|
30
|
+
declare const _default: typeof __VLS_export;
|
|
31
|
+
export default _default;
|
|
32
|
+
type __VLS_WithSlots<T, S> = T & {
|
|
33
|
+
new (): {
|
|
34
|
+
$slots: S;
|
|
35
|
+
};
|
|
36
|
+
};
|
|
@@ -1,2 +1,10 @@
|
|
|
1
|
+
export * from './useAction.ts.js';
|
|
1
2
|
export * from './useConfirmDialog.ts.js';
|
|
3
|
+
export * from './useCopyToClipboard.ts.js';
|
|
4
|
+
export * from './useErrorToast.ts.js';
|
|
5
|
+
export * from './useFormat.ts.js';
|
|
6
|
+
export * from './useLocaleLink.ts.js';
|
|
7
|
+
export * from './useNavLinks.ts.js';
|
|
8
|
+
export * from './useTableQuery.ts.js';
|
|
9
|
+
export * from './useTableSort.ts.js';
|
|
2
10
|
export * from './useTranslatedLocales.ts.js';
|
|
@@ -1,2 +1,10 @@
|
|
|
1
|
+
export * from "./useAction.ts";
|
|
1
2
|
export * from "./useConfirmDialog.ts";
|
|
3
|
+
export * from "./useCopyToClipboard.ts";
|
|
4
|
+
export * from "./useErrorToast.ts";
|
|
5
|
+
export * from "./useFormat.ts";
|
|
6
|
+
export * from "./useLocaleLink.ts";
|
|
7
|
+
export * from "./useNavLinks.ts";
|
|
8
|
+
export * from "./useTableQuery.ts";
|
|
9
|
+
export * from "./useTableSort.ts";
|
|
2
10
|
export * from "./useTranslatedLocales.ts";
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { ActionOptions } from '../types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Runs an async action with a `busy` flag and toasts: `success` once it worked, the error (titled `error`) when it
|
|
4
|
+
* failed. Errors are swallowed: `run` then resolves with `undefined`.
|
|
5
|
+
*
|
|
6
|
+
* ```ts
|
|
7
|
+
* const { busy, run } = useAction()
|
|
8
|
+
* const saved = await run(() => $fetch('/api/events', { method: 'POST', body }), { success: 'Event created' })
|
|
9
|
+
* ```
|
|
10
|
+
*/
|
|
11
|
+
export declare function useAction(): {
|
|
12
|
+
busy: any;
|
|
13
|
+
run: <T>(fn: () => Promise<T>, options?: ActionOptions) => Promise<T | undefined>;
|
|
14
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { useErrorToast } from "./useErrorToast.js";
|
|
2
|
+
export function useAction() {
|
|
3
|
+
const busy = ref(false);
|
|
4
|
+
const toast = useToast();
|
|
5
|
+
const showError = useErrorToast();
|
|
6
|
+
async function run(fn, options = {}) {
|
|
7
|
+
busy.value = true;
|
|
8
|
+
try {
|
|
9
|
+
const result = await fn();
|
|
10
|
+
if (options.success) toast.add({ title: options.success, color: "success", icon: "i-lucide-check" });
|
|
11
|
+
return result;
|
|
12
|
+
} catch (e) {
|
|
13
|
+
showError(e, options.error);
|
|
14
|
+
return void 0;
|
|
15
|
+
} finally {
|
|
16
|
+
busy.value = false;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return { busy, run };
|
|
20
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { useClipboard } from "@vueuse/core";
|
|
2
|
+
import { useErrorToast } from "./useErrorToast.js";
|
|
3
|
+
import { useUiT } from "./useTranslatedLocales.js";
|
|
4
|
+
export function useCopyToClipboard() {
|
|
5
|
+
const { copy: write } = useClipboard({ legacy: true });
|
|
6
|
+
const toast = useToast();
|
|
7
|
+
const showError = useErrorToast();
|
|
8
|
+
const t = useUiT("patterns");
|
|
9
|
+
async function copy(text, message) {
|
|
10
|
+
try {
|
|
11
|
+
await write(text);
|
|
12
|
+
toast.add({ title: message ?? t("copied", "Copied"), color: "success", icon: "i-lucide-clipboard-check" });
|
|
13
|
+
return true;
|
|
14
|
+
} catch (e) {
|
|
15
|
+
showError(e);
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return { copy };
|
|
20
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shows an error as a red toast: `title` (default "Something went wrong.") with the error's message as description.
|
|
3
|
+
*
|
|
4
|
+
* ```ts
|
|
5
|
+
* const showError = useErrorToast()
|
|
6
|
+
* try { await save() } catch (e) { showError(e, 'Could not save the event') }
|
|
7
|
+
* ```
|
|
8
|
+
*/
|
|
9
|
+
export declare function useErrorToast(): (e: unknown, title?: string) => void;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { errorToToast } from "../utils/errors.js";
|
|
2
|
+
import { useUiT } from "./useTranslatedLocales.js";
|
|
3
|
+
export function useErrorToast() {
|
|
4
|
+
const toast = useToast();
|
|
5
|
+
const t = useUiT("patterns");
|
|
6
|
+
return (e, title) => {
|
|
7
|
+
toast.add(errorToToast(e, { title: title ?? t("genericError", "Something went wrong.") }));
|
|
8
|
+
};
|
|
9
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { DateValue } from '../types.js';
|
|
2
|
+
/**
|
|
3
|
+
* The date and money formatters bound to the active locale (`@nuxtjs/i18n`'s when installed, otherwise `en`) and to
|
|
4
|
+
* the app's time zone (`appConfig.frigearUi.timeZone`, e.g. `Europe/Copenhagen`), so server and client render the
|
|
5
|
+
* same text. Without a configured time zone the runtime's own is used, which differs between a UTC server and a
|
|
6
|
+
* visitor's browser: set it in the host app.
|
|
7
|
+
*
|
|
8
|
+
* ```ts
|
|
9
|
+
* const format = useFormat()
|
|
10
|
+
* format.date(event.startsAt) // "15 Oct 2026"
|
|
11
|
+
* format.money(15000, 'DKK') // "DKK 150.00"
|
|
12
|
+
* ```
|
|
13
|
+
*/
|
|
14
|
+
export declare function useFormat(): {
|
|
15
|
+
locale: any;
|
|
16
|
+
timeZone: string | undefined;
|
|
17
|
+
date: (value: DateValue | null | undefined, options?: Intl.DateTimeFormatOptions) => string;
|
|
18
|
+
dateTime: (value: DateValue | null | undefined, options?: Intl.DateTimeFormatOptions) => string;
|
|
19
|
+
time: (value: DateValue | null | undefined, options?: Intl.DateTimeFormatOptions) => string;
|
|
20
|
+
dateRange: (start: DateValue | null | undefined, end: DateValue | null | undefined, options?: Intl.DateTimeFormatOptions) => string;
|
|
21
|
+
/** An amount in minor units (cents, øre) of `currency`. */
|
|
22
|
+
money: (amount: number, currency: string) => string;
|
|
23
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { useAppConfig } from "#imports";
|
|
2
|
+
import { formatDate, formatDateRange, formatDateTime, formatTime } from "../utils/format.js";
|
|
3
|
+
import { formatMoney } from "../shared/money.js";
|
|
4
|
+
import { useTranslatedLocale } from "./useTranslatedLocales.js";
|
|
5
|
+
export function useFormat() {
|
|
6
|
+
const locale = useTranslatedLocale();
|
|
7
|
+
const timeZone = useAppConfig().frigearUi?.timeZone;
|
|
8
|
+
const withZone = (options) => timeZone ? { timeZone, ...options } : { ...options };
|
|
9
|
+
return {
|
|
10
|
+
locale,
|
|
11
|
+
timeZone,
|
|
12
|
+
date: (value, options) => formatDate(value, locale.value, withZone(options)),
|
|
13
|
+
dateTime: (value, options) => formatDateTime(value, locale.value, withZone(options)),
|
|
14
|
+
time: (value, options) => formatTime(value, locale.value, withZone(options)),
|
|
15
|
+
dateRange: (start, end, options) => formatDateRange(start, end, locale.value, withZone(options)),
|
|
16
|
+
/** An amount in minor units (cents, øre) of `currency`. */
|
|
17
|
+
money: (amount, currency) => formatMoney(amount, currency, locale.value)
|
|
18
|
+
};
|
|
19
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Locale-aware paths that work with and without `@nuxtjs/i18n`: `localize` adds the active locale's prefix
|
|
3
|
+
* (identity without i18n), `strip` removes one, `isActive` compares a target with the current route regardless of
|
|
4
|
+
* prefixes (`exact` for targets like a dashboard at an area's base path). Named so it does not clash with i18n's
|
|
5
|
+
* own `useLocalePath`.
|
|
6
|
+
*/
|
|
7
|
+
export declare function useLocaleLink(): {
|
|
8
|
+
localize: (path: string) => string;
|
|
9
|
+
strip: (path: string) => string;
|
|
10
|
+
isActive: (to: string | undefined, options?: {
|
|
11
|
+
exact?: boolean;
|
|
12
|
+
}) => boolean;
|
|
13
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { isExternalPath, stripLocale } from "../utils/locale.js";
|
|
2
|
+
export function useLocaleLink() {
|
|
3
|
+
const nuxtApp = useNuxtApp();
|
|
4
|
+
const route = useRoute();
|
|
5
|
+
const codes = () => (nuxtApp.$i18n?.locales?.value ?? []).map((locale) => typeof locale === "string" ? locale : locale.code);
|
|
6
|
+
const strip = (path) => stripLocale(path, codes());
|
|
7
|
+
const localize = (path) => {
|
|
8
|
+
if (!nuxtApp.$localePath || !path.startsWith("/") || isExternalPath(path)) return path;
|
|
9
|
+
return nuxtApp.$localePath(strip(path)) || path;
|
|
10
|
+
};
|
|
11
|
+
const isActive = (to, options = {}) => {
|
|
12
|
+
if (!to || isExternalPath(to)) return false;
|
|
13
|
+
const target = strip(to);
|
|
14
|
+
const current = strip(route.path);
|
|
15
|
+
if (options.exact || target === "/") return current === target;
|
|
16
|
+
return current === target || current.startsWith(`${target}/`);
|
|
17
|
+
};
|
|
18
|
+
return { localize, strip, isActive };
|
|
19
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { NavigationMenuItem } from '@nuxt/ui';
|
|
2
|
+
import type { ComputedRef, MaybeRefOrGetter } from 'vue';
|
|
3
|
+
import type { NavLinkSource, NavLinksOptions } from '../types.js';
|
|
4
|
+
/**
|
|
5
|
+
* Turns menu items (e.g. `useMenu('admin.nav')`) into `UNavigationMenu` links: `to` gets the active locale's prefix
|
|
6
|
+
* and `active` follows the current route (prefix-insensitive; targets listed in `exact` only match exactly).
|
|
7
|
+
*
|
|
8
|
+
* ```ts
|
|
9
|
+
* const { data } = await useMenu('account')
|
|
10
|
+
* const links = useNavLinks(() => data.value ?? [], { exact: ['/account'] })
|
|
11
|
+
* ```
|
|
12
|
+
*/
|
|
13
|
+
export declare function useNavLinks(items: MaybeRefOrGetter<NavLinkSource[] | null | undefined>, options?: NavLinksOptions): ComputedRef<NavigationMenuItem[]>;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { useLocaleLink } from "./useLocaleLink.js";
|
|
2
|
+
export function useNavLinks(items, options = {}) {
|
|
3
|
+
const { localize, isActive, strip } = useLocaleLink();
|
|
4
|
+
const exact = new Set((options.exact ?? []).map((path) => strip(path)));
|
|
5
|
+
return computed(() => (toValue(items) ?? []).map((item) => {
|
|
6
|
+
const link = { label: item.label, icon: item.icon };
|
|
7
|
+
if (item.badge !== void 0) link.badge = item.badge;
|
|
8
|
+
if (item.to) {
|
|
9
|
+
link.to = item.external ? item.to : localize(item.to);
|
|
10
|
+
link.external = item.external;
|
|
11
|
+
link.active = !item.external && isActive(item.to, { exact: exact.has(strip(item.to)) });
|
|
12
|
+
}
|
|
13
|
+
return link;
|
|
14
|
+
}));
|
|
15
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Ref } from 'vue';
|
|
2
|
+
import type { TableSortState } from '../utils/table.js';
|
|
3
|
+
export interface TableQueryOptions<F extends Record<string, string>> {
|
|
4
|
+
/** Rows per page when the URL has none. */
|
|
5
|
+
pageSize?: number;
|
|
6
|
+
/** The sort when the URL has none; `null` for unsorted. */
|
|
7
|
+
sort?: TableSortState | null;
|
|
8
|
+
/** Filters and their defaults; a filter at its default is left out of the URL. */
|
|
9
|
+
filters?: F;
|
|
10
|
+
/** How long typing in the search waits before `q` follows, in ms. */
|
|
11
|
+
debounce?: number;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* The state of a paginated list (page, page size, search, sort, filters), kept in the URL so a list can be linked,
|
|
15
|
+
* reloaded and navigated back to. `search` is what the input binds; `q` follows it after a short pause, so the API
|
|
16
|
+
* is not hit on every keystroke. Any change to the search, a filter, the sort or the page size goes back to page 1.
|
|
17
|
+
*
|
|
18
|
+
* ```ts
|
|
19
|
+
* const list = useTableQuery({ sort: { id: 'createdAt', desc: true }, filters: { role: 'all' } })
|
|
20
|
+
* const sort = useTableSort(null, list.sort)
|
|
21
|
+
* const { data } = await useFetch('/api/things', { query: list.query })
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
export declare function useTableQuery<F extends Record<string, string> = Record<string, never>>(options?: TableQueryOptions<F>): {
|
|
25
|
+
page: any;
|
|
26
|
+
pageSize: any;
|
|
27
|
+
search: any;
|
|
28
|
+
q: any;
|
|
29
|
+
sort: Ref<TableSortState | null, TableSortState | null>;
|
|
30
|
+
filters: F;
|
|
31
|
+
query: any;
|
|
32
|
+
filtered: any;
|
|
33
|
+
reset: () => void;
|
|
34
|
+
};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
const single = (value) => {
|
|
2
|
+
const first = Array.isArray(value) ? value[0] : value;
|
|
3
|
+
return typeof first === "string" && first !== "" ? first : void 0;
|
|
4
|
+
};
|
|
5
|
+
const positive = (value, fallback) => {
|
|
6
|
+
const number = Number(single(value));
|
|
7
|
+
return Number.isInteger(number) && number > 0 ? number : fallback;
|
|
8
|
+
};
|
|
9
|
+
export function useTableQuery(options = {}) {
|
|
10
|
+
const route = useRoute();
|
|
11
|
+
const router = useRouter();
|
|
12
|
+
const defaultPageSize = options.pageSize ?? 20;
|
|
13
|
+
const defaultSort = options.sort ?? null;
|
|
14
|
+
const defaultFilters = options.filters ?? {};
|
|
15
|
+
const readSort = () => {
|
|
16
|
+
const id = single(route.query.sort);
|
|
17
|
+
if (!id) return defaultSort;
|
|
18
|
+
if (id === "-") return null;
|
|
19
|
+
return { id, desc: single(route.query.order) === "desc" };
|
|
20
|
+
};
|
|
21
|
+
const page = ref(positive(route.query.page, 1));
|
|
22
|
+
const pageSize = ref(positive(route.query.pageSize, defaultPageSize));
|
|
23
|
+
const q = ref(single(route.query.q) ?? "");
|
|
24
|
+
const search = ref(q.value);
|
|
25
|
+
const sort = ref(readSort());
|
|
26
|
+
const filters = reactive(Object.fromEntries(
|
|
27
|
+
Object.entries(defaultFilters).map(([key, fallback]) => [key, single(route.query[key]) ?? fallback])
|
|
28
|
+
));
|
|
29
|
+
let timer;
|
|
30
|
+
watch(search, (value) => {
|
|
31
|
+
clearTimeout(timer);
|
|
32
|
+
timer = setTimeout(() => {
|
|
33
|
+
q.value = value.trim();
|
|
34
|
+
}, options.debounce ?? 300);
|
|
35
|
+
});
|
|
36
|
+
if (getCurrentInstance()) onBeforeUnmount(() => clearTimeout(timer));
|
|
37
|
+
watch([q, pageSize, sort, () => ({ ...filters })], () => {
|
|
38
|
+
page.value = 1;
|
|
39
|
+
}, { deep: true });
|
|
40
|
+
const urlQuery = computed(() => {
|
|
41
|
+
const sameSort = sort.value?.id === defaultSort?.id && sort.value?.desc === defaultSort?.desc;
|
|
42
|
+
return {
|
|
43
|
+
page: page.value > 1 ? String(page.value) : void 0,
|
|
44
|
+
pageSize: pageSize.value !== defaultPageSize ? String(pageSize.value) : void 0,
|
|
45
|
+
q: q.value || void 0,
|
|
46
|
+
sort: sameSort ? void 0 : sort.value?.id ?? "-",
|
|
47
|
+
order: sameSort || !sort.value ? void 0 : sort.value.desc ? "desc" : "asc",
|
|
48
|
+
...Object.fromEntries(Object.entries(filters).map(([key, value]) => [key, value === defaultFilters[key] ? void 0 : value]))
|
|
49
|
+
};
|
|
50
|
+
});
|
|
51
|
+
watch(urlQuery, (value) => {
|
|
52
|
+
const next = Object.entries({ ...route.query, ...value }).filter(([, entry]) => entry !== void 0);
|
|
53
|
+
router.replace({ query: Object.fromEntries(next) });
|
|
54
|
+
});
|
|
55
|
+
const query = computed(() => ({
|
|
56
|
+
page: page.value,
|
|
57
|
+
pageSize: pageSize.value,
|
|
58
|
+
q: q.value || void 0,
|
|
59
|
+
sort: sort.value?.id,
|
|
60
|
+
order: sort.value ? sort.value.desc ? "desc" : "asc" : void 0,
|
|
61
|
+
...Object.fromEntries(Object.entries(filters).map(([key, value]) => [key, value === defaultFilters[key] ? void 0 : value]))
|
|
62
|
+
}));
|
|
63
|
+
const filtered = computed(() => !!search.value || Object.entries(filters).some(([key, value]) => value !== defaultFilters[key]));
|
|
64
|
+
function reset() {
|
|
65
|
+
search.value = "";
|
|
66
|
+
q.value = "";
|
|
67
|
+
Object.assign(filters, defaultFilters);
|
|
68
|
+
}
|
|
69
|
+
return { page, pageSize, search, q, sort, filters, query, filtered, reset };
|
|
70
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Ref } from 'vue';
|
|
2
|
+
import { type TableSortState } from '../utils/table.js';
|
|
3
|
+
/**
|
|
4
|
+
* Sortable column headers that look and behave the same in every list: a ghost button that cycles ascending,
|
|
5
|
+
* descending, unsorted. `sorting` is in `UTable`'s shape, so a client-sorted list binds it with
|
|
6
|
+
* `v-model:sorting`; a server-sorted list sends `query` (`{ sort, order }`) instead.
|
|
7
|
+
*
|
|
8
|
+
* ```ts
|
|
9
|
+
* const sort = useTableSort({ id: 'createdAt', desc: true })
|
|
10
|
+
* const columns = [{ accessorKey: 'name', header: sort.header('name', t('name', 'Name')) }]
|
|
11
|
+
* ```
|
|
12
|
+
*
|
|
13
|
+
* Pass `state` to keep the sort somewhere else, such as the URL (see `useTableQuery`).
|
|
14
|
+
*/
|
|
15
|
+
export declare function useTableSort(initial?: TableSortState | null, state?: Ref<TableSortState | null>): {
|
|
16
|
+
sort: any;
|
|
17
|
+
sorting: any;
|
|
18
|
+
query: any;
|
|
19
|
+
toggle: (id: string) => void;
|
|
20
|
+
header: (id: string, label: string) => () => any;
|
|
21
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { UButton } from "#components";
|
|
2
|
+
import { nextTableSort, tableSortQuery } from "../utils/table.js";
|
|
3
|
+
import { useUiT } from "./useTranslatedLocales.js";
|
|
4
|
+
const SORT_ICONS = {
|
|
5
|
+
none: "i-lucide-arrow-up-down",
|
|
6
|
+
asc: "i-lucide-arrow-up-narrow-wide",
|
|
7
|
+
desc: "i-lucide-arrow-down-wide-narrow"
|
|
8
|
+
};
|
|
9
|
+
export function useTableSort(initial = null, state) {
|
|
10
|
+
const t = useUiT("patterns");
|
|
11
|
+
const current = state ?? ref(initial);
|
|
12
|
+
const sorting = computed({
|
|
13
|
+
get: () => current.value ? [current.value] : [],
|
|
14
|
+
set: (value) => {
|
|
15
|
+
current.value = value[0] ?? null;
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
const query = computed(() => tableSortQuery(current.value));
|
|
19
|
+
function toggle(id) {
|
|
20
|
+
current.value = nextTableSort(current.value, id);
|
|
21
|
+
}
|
|
22
|
+
function header(id, label) {
|
|
23
|
+
return () => {
|
|
24
|
+
const direction = current.value?.id === id ? current.value.desc ? "desc" : "asc" : "none";
|
|
25
|
+
return h(UButton, {
|
|
26
|
+
"label": label,
|
|
27
|
+
"color": "neutral",
|
|
28
|
+
"variant": "ghost",
|
|
29
|
+
"size": "xs",
|
|
30
|
+
"class": "-mx-2.5 text-xs font-normal text-muted data-[sorted=true]:text-highlighted",
|
|
31
|
+
"data-sorted": direction !== "none",
|
|
32
|
+
"icon": SORT_ICONS[direction],
|
|
33
|
+
"trailing": true,
|
|
34
|
+
"aria-label": direction === "none" ? t("sortBy", "Sort by {column}", { column: label }) : t(direction === "asc" ? "sortedAsc" : "sortedDesc", direction === "asc" ? "Sort by {column}, now ascending" : "Sort by {column}, now descending", { column: label }),
|
|
35
|
+
"onClick": () => toggle(id)
|
|
36
|
+
});
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
return { sort: current, sorting, query, toggle, header };
|
|
40
|
+
}
|
|
@@ -33,5 +33,24 @@
|
|
|
33
33
|
"write": "Skriv",
|
|
34
34
|
"preview": "Vis",
|
|
35
35
|
"empty": "Intet at vise"
|
|
36
|
+
},
|
|
37
|
+
"patterns": {
|
|
38
|
+
"notSet": "Ikke angivet",
|
|
39
|
+
"moreActions": "Flere handlinger",
|
|
40
|
+
"actionsFor": "Handlinger for {subject}",
|
|
41
|
+
"viewAll": "Se alle",
|
|
42
|
+
"copied": "Kopieret",
|
|
43
|
+
"genericError": "Noget gik galt.",
|
|
44
|
+
"open": "Åbn",
|
|
45
|
+
"edit": "Redigér",
|
|
46
|
+
"delete": "Slet",
|
|
47
|
+
"confirm": "Bekræft",
|
|
48
|
+
"breadcrumb": "Brødkrummesti",
|
|
49
|
+
"sortBy": "Sortér efter {column}",
|
|
50
|
+
"sortedAsc": "Sortér efter {column}, nu stigende",
|
|
51
|
+
"sortedDesc": "Sortér efter {column}, nu faldende",
|
|
52
|
+
"range": "{from}–{to} af {total}",
|
|
53
|
+
"noRows": "Ingen rækker",
|
|
54
|
+
"rowsPerPage": "Rækker pr. side"
|
|
36
55
|
}
|
|
37
56
|
}
|
|
@@ -33,5 +33,24 @@
|
|
|
33
33
|
"write": "Write",
|
|
34
34
|
"preview": "Preview",
|
|
35
35
|
"empty": "Nothing to preview"
|
|
36
|
+
},
|
|
37
|
+
"patterns": {
|
|
38
|
+
"notSet": "Not set",
|
|
39
|
+
"moreActions": "More actions",
|
|
40
|
+
"actionsFor": "Actions for {subject}",
|
|
41
|
+
"viewAll": "View all",
|
|
42
|
+
"copied": "Copied",
|
|
43
|
+
"genericError": "Something went wrong.",
|
|
44
|
+
"open": "Open",
|
|
45
|
+
"edit": "Edit",
|
|
46
|
+
"delete": "Delete",
|
|
47
|
+
"confirm": "Confirm",
|
|
48
|
+
"breadcrumb": "Breadcrumb",
|
|
49
|
+
"sortBy": "Sort by {column}",
|
|
50
|
+
"sortedAsc": "Sort by {column}, now ascending",
|
|
51
|
+
"sortedDesc": "Sort by {column}, now descending",
|
|
52
|
+
"range": "{from}–{to} of {total}",
|
|
53
|
+
"noRows": "No rows",
|
|
54
|
+
"rowsPerPage": "Rows per page"
|
|
36
55
|
}
|
|
37
56
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { NavigationMenuItem } from '@nuxt/ui';
|
|
2
|
+
/** A row of `DescriptionList`. Without `value` the slot named after `key` (or `label`) renders it. */
|
|
3
|
+
export interface DescriptionItem {
|
|
4
|
+
key?: string;
|
|
5
|
+
label: string;
|
|
6
|
+
value?: string | number | null;
|
|
7
|
+
}
|
|
8
|
+
/** An item of `PageTabs`: a link (`to`) in link mode, or a `value` in state mode (`v-model`). */
|
|
9
|
+
export type PageTabItem = NavigationMenuItem & {
|
|
10
|
+
value?: string;
|
|
11
|
+
};
|
|
12
|
+
/** The colours `statusColor` maps a status to (a subset of Nuxt UI's badge colours). */
|
|
13
|
+
export type StatusColor = 'success' | 'warning' | 'error' | 'neutral' | 'info' | 'primary';
|
|
14
|
+
/** Anything the date helpers accept: a `Date`, an ISO string or a timestamp. */
|
|
15
|
+
export type DateValue = string | number | Date;
|
|
16
|
+
/** The shape `useNavLinks` reads: `@frigear-nu/menu`'s `MenuItem` fits, as does any object with these fields. */
|
|
17
|
+
export interface NavLinkSource {
|
|
18
|
+
id?: string;
|
|
19
|
+
label: string;
|
|
20
|
+
icon?: string;
|
|
21
|
+
to?: string;
|
|
22
|
+
external?: boolean;
|
|
23
|
+
badge?: string | number;
|
|
24
|
+
}
|
|
25
|
+
export interface NavLinksOptions {
|
|
26
|
+
/** Targets that are only active on an exact match (e.g. a dashboard at the area's base path). */
|
|
27
|
+
exact?: string[];
|
|
28
|
+
}
|
|
29
|
+
export interface ActionOptions {
|
|
30
|
+
/** Toast title shown once the action succeeded; no toast without it. */
|
|
31
|
+
success?: string;
|
|
32
|
+
/** Toast title shown when the action failed; defaults to "Something went wrong.". */
|
|
33
|
+
error?: string;
|
|
34
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ToastProps } from '@nuxt/ui';
|
|
2
|
+
/**
|
|
3
|
+
* The most useful message of an error thrown by `$fetch`, `createError` or plain code: the server's message
|
|
4
|
+
* (`data.message`, `data.statusMessage`, `response._data.message`), then `statusMessage`, then `message`.
|
|
5
|
+
* `fallback` when none is set.
|
|
6
|
+
*/
|
|
7
|
+
export declare function errorMessage(e: unknown, fallback: string): string;
|
|
8
|
+
/** A Nuxt UI toast for an error: red, with the error's message as description. `overrides` win. */
|
|
9
|
+
export declare function errorToToast(e: unknown, overrides?: Partial<ToastProps>): Partial<ToastProps>;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
const text = (value) => typeof value === "string" && value.trim() ? value.trim() : void 0;
|
|
2
|
+
const field = (value, key) => value && typeof value === "object" ? text(value[key]) : void 0;
|
|
3
|
+
export function errorMessage(e, fallback) {
|
|
4
|
+
if (typeof e === "string") return text(e) ?? fallback;
|
|
5
|
+
if (!e || typeof e !== "object") return fallback;
|
|
6
|
+
const error = e;
|
|
7
|
+
return field(error.data, "message") ?? field(error.data, "statusMessage") ?? field(error.response?._data, "message") ?? text(error.statusMessage) ?? text(error.message) ?? fallback;
|
|
8
|
+
}
|
|
9
|
+
export function errorToToast(e, overrides = {}) {
|
|
10
|
+
const description = errorMessage(e, "");
|
|
11
|
+
return {
|
|
12
|
+
title: "Something went wrong.",
|
|
13
|
+
...description ? { description } : {},
|
|
14
|
+
color: "error",
|
|
15
|
+
icon: "i-lucide-circle-alert",
|
|
16
|
+
...overrides
|
|
17
|
+
};
|
|
18
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { DateValue } from '../types.js';
|
|
2
|
+
/** "15 Oct 2026" */
|
|
3
|
+
export declare function formatDate(value: DateValue | null | undefined, locale?: string, options?: Intl.DateTimeFormatOptions): string;
|
|
4
|
+
/** "15 Oct 2026, 10:00" */
|
|
5
|
+
export declare function formatDateTime(value: DateValue | null | undefined, locale?: string, options?: Intl.DateTimeFormatOptions): string;
|
|
6
|
+
/** "10:00" */
|
|
7
|
+
export declare function formatTime(value: DateValue | null | undefined, locale?: string, options?: Intl.DateTimeFormatOptions): string;
|
|
8
|
+
/**
|
|
9
|
+
* "15–17 Oct 2026", collapsing the parts both ends share. Without `end` (or with an invalid one) it is
|
|
10
|
+
* `formatDate(start)`. Pass `{ dateStyle: 'medium', timeStyle: 'short' }` to include times.
|
|
11
|
+
*/
|
|
12
|
+
export declare function formatDateRange(start: DateValue | null | undefined, end: DateValue | null | undefined, locale?: string, options?: Intl.DateTimeFormatOptions): string;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
const asDate = (value) => {
|
|
2
|
+
if (value === null || value === void 0 || value === "") return void 0;
|
|
3
|
+
const date = value instanceof Date ? value : new Date(value);
|
|
4
|
+
return Number.isNaN(date.getTime()) ? void 0 : date;
|
|
5
|
+
};
|
|
6
|
+
const format = (value, locale, options) => {
|
|
7
|
+
const date = asDate(value);
|
|
8
|
+
return date ? new Intl.DateTimeFormat(locale, options).format(date) : "";
|
|
9
|
+
};
|
|
10
|
+
export function formatDate(value, locale, options = {}) {
|
|
11
|
+
return format(value, locale, { dateStyle: "medium", ...options });
|
|
12
|
+
}
|
|
13
|
+
export function formatDateTime(value, locale, options = {}) {
|
|
14
|
+
return format(value, locale, { dateStyle: "medium", timeStyle: "short", ...options });
|
|
15
|
+
}
|
|
16
|
+
export function formatTime(value, locale, options = {}) {
|
|
17
|
+
return format(value, locale, { timeStyle: "short", ...options });
|
|
18
|
+
}
|
|
19
|
+
export function formatDateRange(start, end, locale, options = {}) {
|
|
20
|
+
const from = asDate(start);
|
|
21
|
+
const to = asDate(end);
|
|
22
|
+
const resolved = { dateStyle: "medium", ...options };
|
|
23
|
+
if (!from) return to ? format(to, locale, resolved) : "";
|
|
24
|
+
if (!to || to.getTime() < from.getTime()) return format(from, locale, resolved);
|
|
25
|
+
return new Intl.DateTimeFormat(locale, resolved).formatRange(from, to);
|
|
26
|
+
}
|