nuxt-ui-basekit 0.2.0 → 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/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 +36 -41
- 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
|
@@ -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"
|
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
3
|
+
* Stacked columns over a time axis — what accrued per day.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
5
|
+
* Stacked rather than several lines because the question is share of the
|
|
6
|
+
* whole, not the course of individual series. Columns rather than an area
|
|
7
|
+
* because daily values are counted events, not a continuous quantity: an area
|
|
8
|
+
* would interpolate between two days and claim something half-happened at
|
|
9
|
+
* midnight.
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* Segments are separated by a 2px gap in the card colour rather than a border.
|
|
12
|
+
* A border is ink that carries no data, and on thin segments it covers the
|
|
13
|
+
* value.
|
|
14
14
|
*
|
|
15
|
-
*
|
|
15
|
+
* Five series at most — see `useChartPalette`.
|
|
16
16
|
*/
|
|
17
17
|
import { computed, ref } from 'vue'
|
|
18
18
|
import { baseKitChartColor, useChartFormat } from '../../composables/useChartPalette'
|
|
@@ -25,13 +25,13 @@ interface ColumnSeries {
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
const props = withDefaults(defineProps<{
|
|
28
|
-
/**
|
|
28
|
+
/** Label per column, ISO dates for instance. */
|
|
29
29
|
categories: string[]
|
|
30
30
|
series: ColumnSeries[]
|
|
31
31
|
height?: number
|
|
32
|
-
/**
|
|
32
|
+
/** Short axis label derived from a category. */
|
|
33
33
|
formatCategory?: (value: string) => string
|
|
34
|
-
/**
|
|
34
|
+
/** The long form, for the tooltip. */
|
|
35
35
|
formatCategoryLong?: (value: string) => string
|
|
36
36
|
ariaLabel?: string
|
|
37
37
|
}>(), {
|
|
@@ -64,7 +64,7 @@ const plot = computed(() => {
|
|
|
64
64
|
}
|
|
65
65
|
})
|
|
66
66
|
|
|
67
|
-
/**
|
|
67
|
+
/** Daily totals — they set the scale and appear in the tooltip. */
|
|
68
68
|
const totals = computed(() =>
|
|
69
69
|
props.categories.map((_, index) =>
|
|
70
70
|
props.series.reduce((sum, s) => sum + (s.points[index] ?? 0), 0),
|
|
@@ -72,9 +72,8 @@ const totals = computed(() =>
|
|
|
72
72
|
)
|
|
73
73
|
|
|
74
74
|
/**
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
* ablesbare Auskunft mehr.
|
|
75
|
+
* Upper axis bound, rounded up to something even. Without it the labels sit on
|
|
76
|
+
* awkward values like 37 and the grid lines stop telling anyone anything.
|
|
78
77
|
*/
|
|
79
78
|
const scaleMax = computed(() => {
|
|
80
79
|
const peak = Math.max(0, ...totals.value)
|
|
@@ -100,7 +99,7 @@ function columnX(index: number): number {
|
|
|
100
99
|
return PADDING.left + plot.value.band * (index + 0.5) - plot.value.columnWidth / 2
|
|
101
100
|
}
|
|
102
101
|
|
|
103
|
-
/**
|
|
102
|
+
/** Rounded cap on top only — the column sits flat on the baseline. */
|
|
104
103
|
function cappedPath(x: number, y: number, w: number, h: number): string {
|
|
105
104
|
const r = Math.min(4, w / 2, h)
|
|
106
105
|
if (h <= 0) return ''
|
|
@@ -129,7 +128,7 @@ const columns = computed(() => {
|
|
|
129
128
|
const segments: Segment[] = []
|
|
130
129
|
let stacked = 0
|
|
131
130
|
|
|
132
|
-
//
|
|
131
|
+
// Stack bottom to top; the topmost series gets the cap.
|
|
133
132
|
const present = props.series
|
|
134
133
|
.map((s, order) => ({ s, order, value: s.points[index] ?? 0 }))
|
|
135
134
|
.filter(entry => entry.value > 0)
|
|
@@ -140,8 +139,8 @@ const columns = computed(() => {
|
|
|
140
139
|
const top = baseline - (stacked / max) * innerHeight
|
|
141
140
|
|
|
142
141
|
const isTop = position === present.length - 1
|
|
143
|
-
//
|
|
144
|
-
//
|
|
142
|
+
// A gap above, except on the topmost segment — there it would sit
|
|
143
|
+
// between the column and thin air and merely shorten it.
|
|
145
144
|
const height = Math.max(0, bottom - top - (isTop ? 0 : GAP))
|
|
146
145
|
if (height <= 0) return
|
|
147
146
|
|
|
@@ -159,9 +158,9 @@ const columns = computed(() => {
|
|
|
159
158
|
})
|
|
160
159
|
|
|
161
160
|
/**
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
161
|
+
* Thin the labels out until they stop colliding — by leaving them out, not by
|
|
162
|
+
* hiding them: hidden text stays in the tree and screen readers read it
|
|
163
|
+
* anyway.
|
|
165
164
|
*/
|
|
166
165
|
const axisLabels = computed(() => {
|
|
167
166
|
const perLabel = 56
|
|
@@ -194,7 +193,7 @@ const tooltip = computed(() => {
|
|
|
194
193
|
value: s.points[index] ?? 0,
|
|
195
194
|
}))
|
|
196
195
|
.filter(row => row.value > 0),
|
|
197
|
-
//
|
|
196
|
+
// Near the edge the box flips inward instead of running off the card.
|
|
198
197
|
anchor: PADDING.left + plot.value.band * (index + 0.5),
|
|
199
198
|
}
|
|
200
199
|
})
|
|
@@ -218,7 +217,7 @@ function readout(index: number): string {
|
|
|
218
217
|
:aria-label="props.ariaLabel"
|
|
219
218
|
class="block max-w-full overflow-visible"
|
|
220
219
|
>
|
|
221
|
-
<!--
|
|
220
|
+
<!-- Grid: solid hairlines, one step away from the surface. -->
|
|
222
221
|
<g>
|
|
223
222
|
<line
|
|
224
223
|
v-for="tick in ticks"
|
|
@@ -242,7 +241,7 @@ function readout(index: number): string {
|
|
|
242
241
|
>{{ tick.label }}</text>
|
|
243
242
|
</g>
|
|
244
243
|
|
|
245
|
-
<!--
|
|
244
|
+
<!-- Data marks -->
|
|
246
245
|
<g
|
|
247
246
|
v-for="column in columns"
|
|
248
247
|
:key="column.category"
|
|
@@ -257,8 +256,8 @@ function readout(index: number): string {
|
|
|
257
256
|
/>
|
|
258
257
|
</g>
|
|
259
258
|
|
|
260
|
-
<!--
|
|
261
|
-
|
|
259
|
+
<!-- Hit areas: full height and the whole band, so nobody has to hit a
|
|
260
|
+
14px column. -->
|
|
262
261
|
<g>
|
|
263
262
|
<rect
|
|
264
263
|
v-for="column in columns"
|
|
@@ -279,7 +278,7 @@ function readout(index: number): string {
|
|
|
279
278
|
/>
|
|
280
279
|
</g>
|
|
281
280
|
|
|
282
|
-
<!--
|
|
281
|
+
<!-- Axis labels -->
|
|
283
282
|
<text
|
|
284
283
|
v-for="label in axisLabels"
|
|
285
284
|
:key="`label-${label.category}`"
|
|
@@ -291,7 +290,7 @@ function readout(index: number): string {
|
|
|
291
290
|
>{{ label.text }}</text>
|
|
292
291
|
</svg>
|
|
293
292
|
|
|
294
|
-
<!-- Tooltip:
|
|
293
|
+
<!-- Tooltip: the value leads, the series name follows. -->
|
|
295
294
|
<div
|
|
296
295
|
v-if="tooltip"
|
|
297
296
|
class="pointer-events-none absolute top-0 z-10 min-w-40 -translate-x-1/2 rounded-md border border-default bg-default p-2 shadow-lg"
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
3
|
+
* A ring for part-of-whole across a few classes.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* Deliberately narrow in scope: a ring answers "roughly how does this split",
|
|
6
|
+
* not "which of these two is bigger". As soon as two segments sit close
|
|
7
|
+
* together, a bar is the more honest shape. Four segments at most, otherwise
|
|
8
|
+
* the small ones lose their labels.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
10
|
+
* The total goes in the middle — it is the number people look for first, and
|
|
11
|
+
* it fills the space a ring leaves empty anyway.
|
|
12
12
|
*/
|
|
13
13
|
import { computed, ref } from 'vue'
|
|
14
14
|
import { baseKitChartColor, useChartFormat } from '../../composables/useChartPalette'
|
|
@@ -21,7 +21,7 @@ interface DonutSlice {
|
|
|
21
21
|
|
|
22
22
|
const props = withDefaults(defineProps<{
|
|
23
23
|
slices: DonutSlice[]
|
|
24
|
-
/**
|
|
24
|
+
/** Label below the total in the centre. */
|
|
25
25
|
centerLabel?: string
|
|
26
26
|
size?: number
|
|
27
27
|
ariaLabel?: string
|
|
@@ -34,7 +34,7 @@ const props = withDefaults(defineProps<{
|
|
|
34
34
|
const { number, percent } = useChartFormat()
|
|
35
35
|
|
|
36
36
|
const THICKNESS = 18
|
|
37
|
-
/**
|
|
37
|
+
/** Gap between segments, in degrees — the counterpart to the 2px gap. */
|
|
38
38
|
const GAP_DEGREES = 2
|
|
39
39
|
|
|
40
40
|
const active = ref<string | null>(null)
|
|
@@ -61,8 +61,8 @@ const arcs = computed(() => {
|
|
|
61
61
|
const start = cursor
|
|
62
62
|
cursor += sweep
|
|
63
63
|
|
|
64
|
-
//
|
|
65
|
-
//
|
|
64
|
+
// A segment filling the whole circle would have identical start and end
|
|
65
|
+
// points and the arc would vanish. Draw the full ring instead.
|
|
66
66
|
const full = sweep >= 359.9
|
|
67
67
|
const gap = full ? 0 : Math.min(GAP_DEGREES, sweep / 3)
|
|
68
68
|
const from = start + gap / 2
|
|
@@ -95,10 +95,10 @@ const legend = computed(() =>
|
|
|
95
95
|
</script>
|
|
96
96
|
|
|
97
97
|
<template>
|
|
98
|
-
<!--
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
98
|
+
<!-- Legend below the ring, not beside it: the card often sits in a narrow
|
|
99
|
+
column, and side by side so little was left for the labels that a word
|
|
100
|
+
like "Discontinued" shrank to a "D". Stacked, every row has the full
|
|
101
|
+
width. -->
|
|
102
102
|
<div class="flex flex-col items-center gap-5">
|
|
103
103
|
<svg
|
|
104
104
|
:width="props.size"
|
|
@@ -108,8 +108,8 @@ const legend = computed(() =>
|
|
|
108
108
|
:aria-label="props.ariaLabel"
|
|
109
109
|
class="shrink-0"
|
|
110
110
|
>
|
|
111
|
-
<!--
|
|
112
|
-
|
|
111
|
+
<!-- An empty ring when there is nothing: the shape stays recognisable
|
|
112
|
+
instead of the card collapsing into a blank area. -->
|
|
113
113
|
<circle
|
|
114
114
|
v-if="!arcs.length"
|
|
115
115
|
:cx="center"
|
|
@@ -156,7 +156,7 @@ const legend = computed(() =>
|
|
|
156
156
|
>{{ props.centerLabel }}</text>
|
|
157
157
|
</svg>
|
|
158
158
|
|
|
159
|
-
<!--
|
|
159
|
+
<!-- The legend carries the identity, not the colour alone. -->
|
|
160
160
|
<ul class="w-full space-y-1.5">
|
|
161
161
|
<li
|
|
162
162
|
v-for="entry in legend"
|