nuxt-ui-basekit 0.1.1 → 0.2.1
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 +93 -79
- package/app/assets/css/basekit.css +23 -24
- package/app/components/BaseKitBackLink.vue +9 -9
- package/app/components/BaseKitChoiceCard.vue +19 -19
- package/app/components/BaseKitConfirmModal.vue +7 -7
- package/app/components/BaseKitDataTable.vue +30 -30
- package/app/components/BaseKitEmptyState.vue +7 -7
- package/app/components/BaseKitFileUpload.vue +5 -5
- package/app/components/BaseKitIconPicker.vue +8 -8
- package/app/components/BaseKitMarkdownEditor.vue +11 -9
- package/app/components/BaseKitPending.vue +15 -16
- package/app/components/BaseKitRecordPicker.vue +14 -14
- package/app/components/BaseKitSettingRow.vue +15 -14
- package/app/components/BaseKitStatTile.vue +8 -8
- package/app/components/BaseKitTabs.vue +33 -34
- package/app/components/BaseKitViewLink.vue +14 -14
- package/app/components/charts/BaseKitChartBars.vue +8 -9
- package/app/components/charts/BaseKitChartColumns.vue +30 -31
- package/app/components/charts/BaseKitChartDonut.vue +18 -18
- package/app/components/charts/BaseKitChartFigure.vue +10 -10
- package/app/components/charts/BaseKitChartMeter.vue +4 -4
- package/app/composables/useBaseKit.ts +68 -68
- package/app/composables/useChartPalette.ts +14 -14
- package/app/composables/useChartWidth.ts +10 -10
- package/app/composables/useConfirm.ts +3 -3
- package/app/stores/confirm.ts +16 -16
- package/app/utils/html-to-markdown.ts +14 -16
- package/app/utils/markdown.ts +8 -7
- package/package.json +1 -1
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
3
|
+
* One empty state for all of them: icon, an action-shaped heading, optional
|
|
4
|
+
* explanation, optional primary action in the default slot.
|
|
5
5
|
*
|
|
6
|
-
* `variant`
|
|
7
|
-
* - `empty`
|
|
8
|
-
* - `search`
|
|
6
|
+
* `variant` separates the two cases that look alike and mean different things:
|
|
7
|
+
* - `empty` — nothing has been created yet; leads to the first step.
|
|
8
|
+
* - `search` — the query ran and found nothing; confirms it, offers a fix.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
10
|
+
* The icon follows the variant and can be overridden through `icon`. The
|
|
11
|
+
* action (a `UButton`, say) goes into the default slot.
|
|
12
12
|
*/
|
|
13
13
|
withDefaults(defineProps<{
|
|
14
14
|
title: string
|
|
@@ -3,9 +3,9 @@ import { ref } from 'vue'
|
|
|
3
3
|
import { useBaseKitLabels } from '../composables/useBaseKit'
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* File selection by click or drag and drop. Hands the chosen files up through
|
|
7
|
+
* `select` and does NOT upload them itself — that stays with the caller, who
|
|
8
|
+
* knows whether this is an image or a video.
|
|
9
9
|
*/
|
|
10
10
|
const props = withDefaults(defineProps<{
|
|
11
11
|
accept?: string
|
|
@@ -31,7 +31,7 @@ function pick(): void {
|
|
|
31
31
|
function onChange(event: Event): void {
|
|
32
32
|
const files = Array.from((event.target as HTMLInputElement).files ?? [])
|
|
33
33
|
if (files.length) emit('select', files)
|
|
34
|
-
//
|
|
34
|
+
// Reset, so the same file can be picked again.
|
|
35
35
|
if (input.value) input.value.value = ''
|
|
36
36
|
}
|
|
37
37
|
|
|
@@ -84,7 +84,7 @@ function onDrop(event: DragEvent): void {
|
|
|
84
84
|
.basekit-upload__icon { font-size: 1.5rem; }
|
|
85
85
|
.basekit-upload__label { font-size: 0.8125rem; font-weight: 500; }
|
|
86
86
|
|
|
87
|
-
/* Dark
|
|
87
|
+
/* Dark mode: override the drop zone's light fallbacks. */
|
|
88
88
|
:where(.dark) .basekit-upload { border-color: #1f2937; background: #0f172a; color: #9ca3af; }
|
|
89
89
|
:where(.dark) .basekit-upload:hover, :where(.dark) .basekit-upload--drag { background: #1f2937; }
|
|
90
90
|
</style>
|
|
@@ -3,14 +3,14 @@ import { computed, ref } from 'vue'
|
|
|
3
3
|
import { useBaseKitLabels } from '../composables/useBaseKit'
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* A form control for `i-lucide-*` names — display plus picker, no free text.
|
|
7
|
+
* The trigger shows the chosen icon, or a placeholder; a click opens a popover
|
|
8
|
+
* with a search field and a grid of curated icons. `modelValue` is the icon
|
|
9
|
+
* name, an empty string meaning no icon.
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* Curated rather than the full Iconify set, on purpose: it keeps the bundle
|
|
12
|
+
* small and needs no extra data source. When an icon is missing, the list
|
|
13
|
+
* below grows by a line.
|
|
14
14
|
*/
|
|
15
15
|
const props = defineProps<{ modelValue: string }>()
|
|
16
16
|
const emit = defineEmits<{ 'update:modelValue': [string] }>()
|
|
@@ -20,7 +20,7 @@ const labels = useBaseKitLabels()
|
|
|
20
20
|
const open = ref(false)
|
|
21
21
|
const search = ref('')
|
|
22
22
|
|
|
23
|
-
//
|
|
23
|
+
// Curated Lucide icons, the ones that come up in navigation and content.
|
|
24
24
|
const ICONS: string[] = [
|
|
25
25
|
'i-lucide-home', 'i-lucide-layout-dashboard', 'i-lucide-layout-grid', 'i-lucide-list',
|
|
26
26
|
'i-lucide-file-text', 'i-lucide-file', 'i-lucide-files', 'i-lucide-folder',
|
|
@@ -8,14 +8,16 @@ import { renderMarkdown } from '../utils/markdown'
|
|
|
8
8
|
import { htmlToMarkdown, prepareHtmlToMarkdown } from '../utils/html-to-markdown'
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
|
-
* WYSIWYG
|
|
11
|
+
* A WYSIWYG editor whose v-model is a **Markdown string**.
|
|
12
12
|
*
|
|
13
|
-
* Tiptap
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* -
|
|
13
|
+
* Tiptap works in HTML internally. The bridge to Markdown runs through two
|
|
14
|
+
* established libraries rather than a Tiptap Markdown plugin, which survives
|
|
15
|
+
* version bumps better:
|
|
16
|
+
* - loading: Markdown to HTML through markdown-it (`renderMarkdown`)
|
|
17
|
+
* - saving: HTML to Markdown through turndown
|
|
17
18
|
*
|
|
18
|
-
*
|
|
19
|
+
* The editor is created `onMounted` — ProseMirror needs a DOM, so this stays
|
|
20
|
+
* SSR-safe.
|
|
19
21
|
*/
|
|
20
22
|
const props = defineProps<{ modelValue?: string | null }>()
|
|
21
23
|
const emit = defineEmits<{ 'update:modelValue': [string] }>()
|
|
@@ -29,7 +31,7 @@ function toMarkdown(html: string): string {
|
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
onMounted(() => {
|
|
32
|
-
// turndown
|
|
34
|
+
// Pull turndown in on the client — details in html-to-markdown.ts.
|
|
33
35
|
prepareHtmlToMarkdown()
|
|
34
36
|
editor.value = new Editor({
|
|
35
37
|
extensions: [
|
|
@@ -43,7 +45,7 @@ onMounted(() => {
|
|
|
43
45
|
|
|
44
46
|
onBeforeUnmount(() => editor.value?.destroy())
|
|
45
47
|
|
|
46
|
-
//
|
|
48
|
+
// Take an outside change without disturbing the cursor — only on a real diff.
|
|
47
49
|
watch(() => props.modelValue, (v) => {
|
|
48
50
|
if (!editor.value) return
|
|
49
51
|
if ((v ?? '') !== toMarkdown(editor.value.getHTML())) {
|
|
@@ -102,7 +104,7 @@ function isActive(name: string, attrs?: Record<string, unknown>): boolean {
|
|
|
102
104
|
.md-content :deep(.ProseMirror code) { font-family: ui-monospace, monospace; font-size: 0.85em; background: var(--basekit-surface-muted, #f4f6fa); padding: 0.1em 0.35em; border-radius: 4px; }
|
|
103
105
|
.md-content :deep(.ProseMirror a) { color: var(--basekit-accent, #2563eb); text-decoration: underline; }
|
|
104
106
|
|
|
105
|
-
/* Dark
|
|
107
|
+
/* Dark mode: override the hard-coded light fallbacks. */
|
|
106
108
|
:where(.dark) .md-editor { background: #101828; border-color: #1f2937; }
|
|
107
109
|
:where(.dark) .md-toolbar { background: #0b1220; border-color: #1f2937; }
|
|
108
110
|
:where(.dark) .md-toolbar button { color: #e5e7eb; }
|
|
@@ -1,35 +1,34 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
3
|
+
* A loading state for a page or a section of one.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* Pages that fetch in `onMounted` show their form immediately otherwise — with
|
|
6
|
+
* empty fields that fill themselves a blink later. Whoever types fast writes
|
|
7
|
+
* into a field that is about to be overwritten; whoever reads slowly takes the
|
|
8
|
+
* record for empty.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
10
|
+
* Hence: spinner first, content after. The spinner says "in a moment", the
|
|
11
|
+
* empty form says "nothing here" — and only one of those is true.
|
|
12
12
|
*
|
|
13
|
-
*
|
|
13
|
+
* Two ways, both valid:
|
|
14
14
|
*
|
|
15
15
|
* <BaseKitPending :pending="pending">
|
|
16
|
-
* <UForm …> <!--
|
|
16
|
+
* <UForm …> <!-- appears once the data is in -->
|
|
17
17
|
* </BaseKitPending>
|
|
18
18
|
*
|
|
19
19
|
* <BaseKitPending v-if="pending" />
|
|
20
20
|
*
|
|
21
|
-
* `label`
|
|
22
|
-
*
|
|
23
|
-
* ein Wort, das niemand liest.
|
|
21
|
+
* Set `label` only where the operation takes a while and has a name ("Searching").
|
|
22
|
+
* On an ordinary form the bare spinner is more honest than a word nobody reads.
|
|
24
23
|
*/
|
|
25
24
|
withDefaults(defineProps<{
|
|
26
|
-
/**
|
|
25
|
+
/** While true, the spinner stands in for the content. */
|
|
27
26
|
pending?: boolean
|
|
28
|
-
/**
|
|
27
|
+
/** Optional label below the spinner. */
|
|
29
28
|
label?: string
|
|
30
29
|
/**
|
|
31
|
-
*
|
|
32
|
-
*
|
|
30
|
+
* Height of the area. `page` for a whole page, `inline` for a section within
|
|
31
|
+
* one — otherwise the layout jumps when the content arrives.
|
|
33
32
|
*/
|
|
34
33
|
size?: 'page' | 'inline'
|
|
35
34
|
}>(), {
|
|
@@ -3,32 +3,32 @@ import { computed, ref } from 'vue'
|
|
|
3
3
|
import { useBaseKitLabels } from '../composables/useBaseKit'
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* Picks a record across one or several models. The choosing happens in a modal
|
|
7
|
+
* that opens through "Select" or "Change"; inside, the list filters by text
|
|
8
|
+
* AND by type.
|
|
9
9
|
*
|
|
10
|
-
* - **
|
|
11
|
-
* - **
|
|
12
|
-
*
|
|
10
|
+
* - **Nothing selected** — a "Select" button.
|
|
11
|
+
* - **Selected** — the title of the choice, linking to its edit form, plus a
|
|
12
|
+
* type badge and a "Change" button.
|
|
13
13
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
14
|
+
* Model-agnostic: the caller supplies `items`. `v-model` is the option's
|
|
15
|
+
* `value`, or `null`.
|
|
16
16
|
*/
|
|
17
17
|
export interface BaseKitRecordOption {
|
|
18
18
|
value: number
|
|
19
19
|
label: string
|
|
20
|
-
/**
|
|
20
|
+
/** Type badge — already in the caller's language. */
|
|
21
21
|
type?: string
|
|
22
|
-
/**
|
|
22
|
+
/** Extra detail on the right of the list, a slug for instance. */
|
|
23
23
|
hint?: string
|
|
24
|
-
/**
|
|
24
|
+
/** Internal link to the record's edit form. */
|
|
25
25
|
editHref?: string
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
const props = withDefaults(defineProps<{
|
|
29
29
|
modelValue?: number | null
|
|
30
30
|
items: BaseKitRecordOption[]
|
|
31
|
-
/**
|
|
31
|
+
/** Title of the picker modal. */
|
|
32
32
|
title?: string
|
|
33
33
|
}>(), {
|
|
34
34
|
modelValue: null,
|
|
@@ -72,7 +72,7 @@ function choose(item: BaseKitRecordOption): void {
|
|
|
72
72
|
|
|
73
73
|
<template>
|
|
74
74
|
<div>
|
|
75
|
-
<!--
|
|
75
|
+
<!-- Selected: title as an edit link, type badge, change button -->
|
|
76
76
|
<div v-if="selected" class="flex items-center gap-2">
|
|
77
77
|
<NuxtLink
|
|
78
78
|
v-if="selected.editHref"
|
|
@@ -93,7 +93,7 @@ function choose(item: BaseKitRecordOption): void {
|
|
|
93
93
|
</UButton>
|
|
94
94
|
</div>
|
|
95
95
|
|
|
96
|
-
<!--
|
|
96
|
+
<!-- Nothing selected: the select button -->
|
|
97
97
|
<UButton v-else color="neutral" variant="subtle" icon="i-lucide-list" @click="openModal">
|
|
98
98
|
{{ labels.select }}
|
|
99
99
|
</UButton>
|
|
@@ -1,35 +1,36 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
3
|
+
* One setting inside a settings card.
|
|
4
4
|
*
|
|
5
5
|
* ┌──────────────────────────────────────────────────────┐
|
|
6
|
-
* │
|
|
7
|
-
* │
|
|
6
|
+
* │ Title [Control] │
|
|
7
|
+
* │ Description (optional, may wrap) │
|
|
8
8
|
* └──────────────────────────────────────────────────────┘
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
10
|
+
* Title and control share a line, title left and control right. The
|
|
11
|
+
* description sits underneath, aligned with the title.
|
|
12
12
|
*
|
|
13
|
-
*
|
|
13
|
+
* Usage:
|
|
14
14
|
*
|
|
15
|
-
* <BaseKitSettingRow title="
|
|
15
|
+
* <BaseKitSettingRow title="Notifications" description="Receive email and push">
|
|
16
16
|
* <USwitch v-model="notifications" />
|
|
17
17
|
* </BaseKitSettingRow>
|
|
18
18
|
*
|
|
19
|
-
* <BaseKitSettingRow title="
|
|
19
|
+
* <BaseKitSettingRow title="Language" description="Applied on save">
|
|
20
20
|
* <USelect v-model="staged" :items="locales" />
|
|
21
21
|
* </BaseKitSettingRow>
|
|
22
22
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
23
|
+
* The `meta` slot takes an extra line below the description — a count, a
|
|
24
|
+
* reason something is locked, a hint. Kept apart from `description` on
|
|
25
|
+
* purpose: that field carries the one explaining sentence, and a number is not
|
|
26
|
+
* an explanation.
|
|
26
27
|
*
|
|
27
|
-
* <BaseKitSettingRow title="E-
|
|
28
|
+
* <BaseKitSettingRow title="E-learning" description="Courses and certificates.">
|
|
28
29
|
* <USwitch v-model="on" />
|
|
29
|
-
* <template #meta><span>4
|
|
30
|
+
* <template #meta><span>4 courses</span></template>
|
|
30
31
|
* </BaseKitSettingRow>
|
|
31
32
|
*
|
|
32
|
-
*
|
|
33
|
+
* Several rows in one card are separated with a Tailwind divider:
|
|
33
34
|
*
|
|
34
35
|
* <UCard>
|
|
35
36
|
* <div class="divide-y divide-default">
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
3
|
+
* A number with a label — the right shape for a single value.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* A bar chart with exactly one bar says no more than the number itself and
|
|
6
|
+
* costs four times the space. Which is why counts and to-do figures are tiles
|
|
7
|
+
* here and not charts.
|
|
8
8
|
*
|
|
9
|
-
* `tone`
|
|
10
|
-
*
|
|
9
|
+
* `tone` only colours when the number is above zero: "0 open items" is not a
|
|
10
|
+
* warning, it is the normal state.
|
|
11
11
|
*/
|
|
12
12
|
import { computed } from 'vue'
|
|
13
13
|
import { useChartFormat } from '../composables/useChartPalette'
|
|
@@ -33,8 +33,8 @@ const valueClass = computed(() => {
|
|
|
33
33
|
return props.tone === 'alert' ? 'text-error' : 'text-highlighted'
|
|
34
34
|
})
|
|
35
35
|
|
|
36
|
-
/**
|
|
37
|
-
*
|
|
36
|
+
/** The dot next to the number carries the same message as the colour — the
|
|
37
|
+
* colour alone would carry none under a red-green deficiency. */
|
|
38
38
|
const flagged = computed(() => props.value > 0 && props.tone === 'alert')
|
|
39
39
|
</script>
|
|
40
40
|
|
|
@@ -3,43 +3,43 @@ import { computed, onMounted, ref, watch } from 'vue'
|
|
|
3
3
|
import { useRoute, useRouter } from '#imports'
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
*
|
|
7
|
-
*
|
|
6
|
+
* Splits a page into sections — settings groups, say, and anything else that
|
|
7
|
+
* gets unwieldy once there are many fields.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
9
|
+
* One named slot per tab, keyed by its `key`. The active tab is optionally
|
|
10
|
+
* driven through `v-model`; without it the first one wins.
|
|
11
11
|
*
|
|
12
12
|
* <BaseKitTabs :items="[
|
|
13
|
-
* { key: 'general', label: '
|
|
14
|
-
* { key: 'access', label: '
|
|
13
|
+
* { key: 'general', label: 'General', icon: 'i-lucide-settings' },
|
|
14
|
+
* { key: 'access', label: 'Access', icon: 'i-lucide-lock' },
|
|
15
15
|
* ]">
|
|
16
16
|
* <template #general> … </template>
|
|
17
17
|
* <template #access> … </template>
|
|
18
18
|
* </BaseKitTabs>
|
|
19
19
|
*
|
|
20
|
-
* Deep
|
|
21
|
-
* (`…/
|
|
22
|
-
*
|
|
23
|
-
*
|
|
20
|
+
* Deep linking: with `hash-nav` the active tab becomes addressable through the
|
|
21
|
+
* URL fragment (`…/page#access`). On load a matching hash wins; switching tabs
|
|
22
|
+
* writes the hash through `router.replace`, so the history stays clean. With
|
|
23
|
+
* several tabsets on one page, run only ONE of them with `hash-nav`.
|
|
24
24
|
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
25
|
+
* The hash is applied `onMounted` on purpose. The server never sees it —
|
|
26
|
+
* browsers do not send it — and a first client render that differs from the
|
|
27
|
+
* server's tears the hydration apart: the marker sat on one tab while the
|
|
28
|
+
* content came from another. For the same reason it goes through `select()`,
|
|
29
|
+
* so a bound `v-model` learns about the tab from the hash.
|
|
30
30
|
*
|
|
31
|
-
*
|
|
31
|
+
* Accessibility: tablist/tab/tabpanel roles, left and right arrow keys switch.
|
|
32
32
|
*
|
|
33
|
-
* Layout
|
|
34
|
-
*
|
|
35
|
-
*
|
|
33
|
+
* Layout convention: do NOT wrap the tab bar in a `UCard` — tabs and content
|
|
34
|
+
* sit directly on the page. Finer structure per tab (groups, fieldsets,
|
|
35
|
+
* individual cards) belongs INSIDE the respective slot.
|
|
36
36
|
*/
|
|
37
37
|
const props = withDefaults(defineProps<{
|
|
38
|
-
/** Tab
|
|
38
|
+
/** Tab definitions; their order is the display order. */
|
|
39
39
|
items: Array<{ key: string, label: string, icon?: string }>
|
|
40
|
-
/**
|
|
40
|
+
/** Key of the active tab (v-model). Unbound, the first tab becomes active. */
|
|
41
41
|
modelValue?: string
|
|
42
|
-
/**
|
|
42
|
+
/** Make the active tab addressable through the URL fragment (`#<key>`). */
|
|
43
43
|
hashNav?: boolean
|
|
44
44
|
}>(), {
|
|
45
45
|
modelValue: undefined,
|
|
@@ -53,7 +53,7 @@ const emit = defineEmits<{
|
|
|
53
53
|
const route = useRoute()
|
|
54
54
|
const router = useRouter()
|
|
55
55
|
|
|
56
|
-
/**
|
|
56
|
+
/** A valid tab key from the current URL fragment, otherwise null. */
|
|
57
57
|
function keyFromHash(): string | null {
|
|
58
58
|
if (!props.hashNav) return null
|
|
59
59
|
const key = String(route.hash ?? '').replace(/^#/, '')
|
|
@@ -62,36 +62,35 @@ function keyFromHash(): string | null {
|
|
|
62
62
|
|
|
63
63
|
const active = ref<string>(props.modelValue ?? props.items[0]?.key ?? '')
|
|
64
64
|
|
|
65
|
-
/**
|
|
65
|
+
/** Did the user choose themselves? Then their choice beats the hash. */
|
|
66
66
|
const picked = ref(false)
|
|
67
67
|
|
|
68
|
-
//
|
|
68
|
+
// Apply the hash on the client only — see the comment at the top.
|
|
69
69
|
onMounted(() => applyHash())
|
|
70
70
|
|
|
71
|
-
// v-model
|
|
71
|
+
// v-model coming in: take the selection from outside.
|
|
72
72
|
watch(() => props.modelValue, (next) => {
|
|
73
73
|
if (next !== undefined && next !== active.value) active.value = next
|
|
74
74
|
})
|
|
75
75
|
|
|
76
|
-
//
|
|
76
|
+
// The hash changed — a direct link, back or forward — so activate that tab.
|
|
77
77
|
watch(() => route.hash, () => {
|
|
78
78
|
const key = keyFromHash()
|
|
79
79
|
if (key !== null && key !== active.value) active.value = key
|
|
80
80
|
})
|
|
81
81
|
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
//
|
|
82
|
+
// The items changed. Two cases: the tab named in the hash only appears now —
|
|
83
|
+
// permission-dependent tabs arrive late — in which case take it, as long as
|
|
84
|
+
// the user has not chosen themselves. Otherwise, if the active tab disappears,
|
|
85
|
+
// fall back to the first.
|
|
86
86
|
watch(() => props.items, (items) => {
|
|
87
87
|
if (!picked.value && applyHash()) return
|
|
88
88
|
if (!items.some(i => i.key === active.value)) active.value = items[0]?.key ?? ''
|
|
89
89
|
})
|
|
90
90
|
|
|
91
91
|
/**
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
* wurde.
|
|
92
|
+
* Take the tab from the hash if it matches an item and is not already active.
|
|
93
|
+
* Does not count as a user choice. Returns `true` when it switched.
|
|
95
94
|
*/
|
|
96
95
|
function applyHash(): boolean {
|
|
97
96
|
const key = keyFromHash()
|
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
3
|
+
* The way from an edit form to the public view of the same thing.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
* (
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* The opposite direction had existed for a long time: the view carries the
|
|
6
|
+
* context actions ("Edit", "Manage content"). The way back was missing —
|
|
7
|
+
* having edited a record, the only route to seeing the result was the detour
|
|
8
|
+
* through the overview and the front end.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* Sits next to the back link in the page header, in the same spot: first the
|
|
11
|
+
* view, then the way back to the list. The order follows how often each is
|
|
12
|
+
* needed.
|
|
13
13
|
*
|
|
14
|
-
* **
|
|
15
|
-
*
|
|
16
|
-
*
|
|
14
|
+
* **Only render it when the view exists.** A freshly created record has none,
|
|
15
|
+
* and a link into nowhere is worse than no link. That is why the `v-if`
|
|
16
|
+
* belongs at the call site, where it is known whether anything was saved.
|
|
17
17
|
*/
|
|
18
18
|
import { computed } from 'vue'
|
|
19
19
|
import { useBaseKitLabels } from '../composables/useBaseKit'
|
|
@@ -21,9 +21,9 @@ import { useBaseKitLabels } from '../composables/useBaseKit'
|
|
|
21
21
|
const props = withDefaults(defineProps<{
|
|
22
22
|
to: string
|
|
23
23
|
/**
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
24
|
+
* Opens the view in a new tab. On by default, because while editing you
|
|
25
|
+
* usually want to look and carry on — not switch away and lose the unsaved
|
|
26
|
+
* changes.
|
|
27
27
|
*/
|
|
28
28
|
newTab?: boolean
|
|
29
29
|
label?: string
|
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
3
|
+
* Horizontal bars for nominal categories — roles, types, sources.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* neue Auskunft zu geben.
|
|
5
|
+
* Horizontal because the labels are names, and names under a vertical column
|
|
6
|
+
* would have to be tilted. Every bar carries the **same** colour: the
|
|
7
|
+
* categories have no order of their own, and a gradient by size would tell the
|
|
8
|
+
* length a second time instead of adding anything.
|
|
10
9
|
*/
|
|
11
10
|
import { computed, ref } from 'vue'
|
|
12
11
|
import { baseKitChartColor, useChartFormat } from '../../composables/useChartPalette'
|
|
@@ -19,7 +18,7 @@ interface BarItem {
|
|
|
19
18
|
|
|
20
19
|
const props = withDefaults(defineProps<{
|
|
21
20
|
items: BarItem[]
|
|
22
|
-
/**
|
|
21
|
+
/** Rows shown; the rest is folded into a "more" row. */
|
|
23
22
|
limit?: number
|
|
24
23
|
moreLabel?: string
|
|
25
24
|
emptyLabel?: string
|
|
@@ -65,8 +64,8 @@ const color = baseKitChartColor(0)
|
|
|
65
64
|
>
|
|
66
65
|
<span class="truncate text-sm text-muted" :title="row.label">{{ row.label }}</span>
|
|
67
66
|
|
|
68
|
-
<!--
|
|
69
|
-
|
|
67
|
+
<!-- The track is the surface, the bar is the statement; which is why
|
|
68
|
+
the track only hints and carries no second colour. -->
|
|
70
69
|
<span class="h-2.5 w-full rounded-full bg-elevated">
|
|
71
70
|
<span
|
|
72
71
|
class="block h-2.5 rounded-full transition-opacity"
|