@thespielplatz/tsp-tools-theme 0.2.0 → 0.3.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/CHANGELOG.md +114 -0
- package/README.md +147 -32
- package/assets/css/theme.css +86 -6
- package/components/TspBanner.vue +97 -0
- package/components/TspBrandTile.vue +46 -0
- package/components/TspCodeBlock.vue +68 -0
- package/components/TspContainer.vue +10 -2
- package/components/TspCopyButton.vue +40 -0
- package/components/TspCopyField.vue +102 -0
- package/components/TspFooterRow.vue +56 -0
- package/components/TspLanguageToggle.vue +75 -0
- package/components/TspNavBadge.vue +36 -0
- package/components/TspNavGroup.vue +58 -0
- package/components/TspNavItem.vue +68 -12
- package/components/TspNavLabel.vue +56 -0
- package/components/TspNavSection.vue +33 -0
- package/components/TspNavSubItem.vue +35 -0
- package/components/TspPageTabs.vue +46 -0
- package/components/TspReleaseNotes.vue +143 -0
- package/components/TspSectionCard.vue +52 -0
- package/components/TspSidebar.vue +96 -3
- package/components/TspSidebarFooter.vue +332 -37
- package/components/TspSiteFooter.vue +47 -16
- package/components/TspSiteHeader.vue +18 -2
- package/components/TspThemeToggle.vue +24 -5
- package/components/TspToolOf.vue +42 -0
- package/components/TspTopBar.vue +69 -0
- package/components/TspUserCard.vue +127 -0
- package/components/TspVersionBadge.vue +49 -0
- package/components/TspWordmark.vue +36 -4
- package/composables/useTspCopy.ts +72 -0
- package/composables/useTspNavDrawer.ts +14 -0
- package/composables/useTspReveal.ts +18 -0
- package/docs/api.md +394 -0
- package/docs/design-system.md +297 -0
- package/nuxt.config.ts +11 -0
- package/package.json +24 -11
- package/page-meta.d.ts +11 -0
- package/plugins/01.tspColorMode.ts +12 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
A block of configuration to copy — a JSON snippet, a shell line.
|
|
3
|
+
|
|
4
|
+
<TspCodeBlock :code="config" />
|
|
5
|
+
<TspCodeBlock :code="config" copy-label="Copy with API key" />
|
|
6
|
+
|
|
7
|
+
Bordered on the page surface rather than filled again: it lives inside a
|
|
8
|
+
TspSectionCard, and a filled box inside a filled card is the cards-inside-
|
|
9
|
+
cards ADR 018 rules out.
|
|
10
|
+
|
|
11
|
+
The copy button is a real labelled button, not an icon in the corner, because
|
|
12
|
+
what it copies is often not exactly what is shown — a config with the key
|
|
13
|
+
redacted on screen and filled in on copy. An icon cannot say that; a label can.
|
|
14
|
+
-->
|
|
15
|
+
<template>
|
|
16
|
+
<div>
|
|
17
|
+
<pre
|
|
18
|
+
data-testid="tsp-code-block"
|
|
19
|
+
class="overflow-x-auto rounded-md border border-default bg-default p-4 font-mono text-xs leading-relaxed text-muted tsp-scroll-quiet"
|
|
20
|
+
><code>{{ code }}</code></pre>
|
|
21
|
+
|
|
22
|
+
<div
|
|
23
|
+
v-if="copyValue || code"
|
|
24
|
+
class="mt-3 flex flex-wrap items-center gap-x-3 gap-y-2"
|
|
25
|
+
>
|
|
26
|
+
<!-- shrink-0 + nowrap: in a flex row beside the help text the button was
|
|
27
|
+
being squeezed until its own label wrapped onto two lines. The help
|
|
28
|
+
text wraps instead — it is prose, the label is not. -->
|
|
29
|
+
<UButton
|
|
30
|
+
color="primary"
|
|
31
|
+
icon="i-tabler-copy"
|
|
32
|
+
class="shrink-0 whitespace-nowrap"
|
|
33
|
+
@click="copyAll"
|
|
34
|
+
>
|
|
35
|
+
{{ copied ? copiedLabel : copyLabel }}
|
|
36
|
+
</UButton>
|
|
37
|
+
<p
|
|
38
|
+
v-if="help"
|
|
39
|
+
class="min-w-0 flex-1 text-xs text-dimmed"
|
|
40
|
+
>
|
|
41
|
+
{{ help }}
|
|
42
|
+
</p>
|
|
43
|
+
</div>
|
|
44
|
+
</div>
|
|
45
|
+
</template>
|
|
46
|
+
|
|
47
|
+
<script setup lang="ts">
|
|
48
|
+
const props = withDefaults(defineProps<{
|
|
49
|
+
/** What is SHOWN. */
|
|
50
|
+
code: string
|
|
51
|
+
/** What is COPIED, when that differs — e.g. the same config with the real key. */
|
|
52
|
+
copyValue?: string
|
|
53
|
+
/** Button label at rest. */
|
|
54
|
+
copyLabel?: string
|
|
55
|
+
/** Button label for the moment after a successful copy. */
|
|
56
|
+
copiedLabel?: string
|
|
57
|
+
/** One muted line under the block — e.g. what the placeholder stands for. */
|
|
58
|
+
help?: string
|
|
59
|
+
}>(), {
|
|
60
|
+
copyValue: undefined,
|
|
61
|
+
copyLabel: 'Copy',
|
|
62
|
+
copiedLabel: 'Copied',
|
|
63
|
+
help: undefined,
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
const { copied, copy } = useTspCopy()
|
|
67
|
+
const copyAll = () => copy(props.copyValue ?? props.code)
|
|
68
|
+
</script>
|
|
@@ -2,7 +2,13 @@
|
|
|
2
2
|
Centered content column.
|
|
3
3
|
|
|
4
4
|
`width` picks the measure (ADR 008, finding 3):
|
|
5
|
-
app — max-w-
|
|
5
|
+
app — max-w-6xl (1152px), the logged-in app/admin measure
|
|
6
|
+
(design-system.md → Layout). Default. It was max-w-4xl (896px),
|
|
7
|
+
inherited from tsp.tools' local container and never actually
|
|
8
|
+
measured: beside the 224px sidebar that left ~160px of dead page on
|
|
9
|
+
each side at 1440px and ~400px at 1920px, squeezing exactly the
|
|
10
|
+
tables and card grids that need the room. Prose is capped per page
|
|
11
|
+
instead, where the paragraph is.
|
|
6
12
|
site — 1240px, the marketing measure from the approved storefront mockup.
|
|
7
13
|
|
|
8
14
|
`padded` controls the vertical rhythm (pt-10 pb-16). It suits a page body, so it
|
|
@@ -17,7 +23,9 @@
|
|
|
17
23
|
|
|
18
24
|
<script setup lang="ts">
|
|
19
25
|
const props = withDefaults(defineProps<{
|
|
26
|
+
/** `app` = 1152px, the logged-in measure. `site` = 1240px, the marketing one. */
|
|
20
27
|
width?: 'app' | 'site'
|
|
28
|
+
/** Vertical rhythm (`pt-10 pb-16`). Off for header/footer frames. */
|
|
21
29
|
padded?: boolean
|
|
22
30
|
}>(), {
|
|
23
31
|
width: 'app',
|
|
@@ -26,7 +34,7 @@ const props = withDefaults(defineProps<{
|
|
|
26
34
|
|
|
27
35
|
const classes = computed(() => [
|
|
28
36
|
'mx-auto',
|
|
29
|
-
props.width === 'site' ? 'max-w-[1240px] px-6 md:px-10' : 'max-w-
|
|
37
|
+
props.width === 'site' ? 'max-w-[1240px] px-6 md:px-10' : 'max-w-6xl px-6 sm:px-10',
|
|
30
38
|
props.padded ? 'pt-10 pb-16' : '',
|
|
31
39
|
])
|
|
32
40
|
</script>
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
Copy-to-clipboard button. Confirms by swapping its own icon for a tick for a
|
|
3
|
+
moment — a copy that gives no feedback leaves you pressing it twice.
|
|
4
|
+
|
|
5
|
+
The copying itself is useTspCopy(), shared with TspCodeBlock: the two had the
|
|
6
|
+
same secure-context fallback written out twice, and had already drifted (only
|
|
7
|
+
one of them set `readonly` on the fallback textarea).
|
|
8
|
+
-->
|
|
9
|
+
<template>
|
|
10
|
+
<button
|
|
11
|
+
type="button"
|
|
12
|
+
data-testid="tsp-copy-button"
|
|
13
|
+
:aria-label="copied ? copiedLabel : label"
|
|
14
|
+
:title="copied ? copiedLabel : label"
|
|
15
|
+
class="flex size-8 shrink-0 items-center justify-center rounded-md text-muted hover:text-highlighted hover:bg-default"
|
|
16
|
+
@click="copy"
|
|
17
|
+
>
|
|
18
|
+
<UIcon
|
|
19
|
+
:name="copied ? 'i-tabler-check' : 'i-tabler-copy'"
|
|
20
|
+
:class="['text-lg', copied ? 'tsp-text-brand' : '']"
|
|
21
|
+
/>
|
|
22
|
+
</button>
|
|
23
|
+
</template>
|
|
24
|
+
|
|
25
|
+
<script setup lang="ts">
|
|
26
|
+
const props = withDefaults(defineProps<{
|
|
27
|
+
/** The string put on the clipboard. */
|
|
28
|
+
value: string
|
|
29
|
+
/** Button label at rest. */
|
|
30
|
+
label?: string
|
|
31
|
+
/** Button label for the moment after a successful copy. */
|
|
32
|
+
copiedLabel?: string
|
|
33
|
+
}>(), {
|
|
34
|
+
label: 'Copy',
|
|
35
|
+
copiedLabel: 'Copied',
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
const { copied, copy: run } = useTspCopy()
|
|
39
|
+
const copy = () => run(props.value)
|
|
40
|
+
</script>
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
A read-only value with a copy button — an endpoint, a server URL, an API key.
|
|
3
|
+
|
|
4
|
+
<TspCopyField label="Endpoint" value="https://trips.tsp.tools/mcp" />
|
|
5
|
+
<TspCopyField label="API key" :value="key" secret help="Treat this like a password." />
|
|
6
|
+
|
|
7
|
+
`secret` masks the value behind a reveal, the same rule TspUserCard follows:
|
|
8
|
+
WHILE HIDDEN THE VALUE IS NOT RENDERED. Covering it with CSS leaves it in the
|
|
9
|
+
page source, copyable and readable by anything walking the DOM — which is not
|
|
10
|
+
masking, it only looks like it. Copy still copies the real value: the point is
|
|
11
|
+
that it is not on screen, not that it is unreachable.
|
|
12
|
+
|
|
13
|
+
Monospace, because these are values you compare character by character.
|
|
14
|
+
-->
|
|
15
|
+
<template>
|
|
16
|
+
<div>
|
|
17
|
+
<!-- `for` pointed at a <code>, which is not a labelable element, so the
|
|
18
|
+
association did nothing. aria-labelledby names the value directly. -->
|
|
19
|
+
<p
|
|
20
|
+
v-if="label"
|
|
21
|
+
:id="`${id}-label`"
|
|
22
|
+
class="block mb-1 text-xs font-bold text-toned"
|
|
23
|
+
>{{ label }}</p>
|
|
24
|
+
|
|
25
|
+
<div class="flex items-center gap-1 rounded-md border border-default bg-default pl-3 pr-1 py-1">
|
|
26
|
+
<code
|
|
27
|
+
:id="id"
|
|
28
|
+
:aria-labelledby="label ? `${id}-label` : undefined"
|
|
29
|
+
data-testid="tsp-copy-field-value"
|
|
30
|
+
class="flex-1 min-w-0 truncate font-mono text-sm text-muted"
|
|
31
|
+
>{{ secret && !revealed ? mask : value }}</code>
|
|
32
|
+
|
|
33
|
+
<button
|
|
34
|
+
v-if="secret"
|
|
35
|
+
type="button"
|
|
36
|
+
data-testid="tsp-copy-field-reveal"
|
|
37
|
+
:aria-pressed="revealed"
|
|
38
|
+
:aria-label="revealed ? hideLabel : revealLabel"
|
|
39
|
+
:title="revealed ? hideLabel : revealLabel"
|
|
40
|
+
class="flex size-8 shrink-0 items-center justify-center rounded-md text-muted hover:text-highlighted hover:bg-elevated"
|
|
41
|
+
@click="toggle"
|
|
42
|
+
>
|
|
43
|
+
<UIcon
|
|
44
|
+
:name="revealed ? 'i-tabler-eye-off' : 'i-tabler-eye'"
|
|
45
|
+
class="text-lg"
|
|
46
|
+
/>
|
|
47
|
+
</button>
|
|
48
|
+
|
|
49
|
+
<TspCopyButton
|
|
50
|
+
:value="value"
|
|
51
|
+
:label="copyLabel"
|
|
52
|
+
:copied-label="copiedLabel"
|
|
53
|
+
/>
|
|
54
|
+
</div>
|
|
55
|
+
|
|
56
|
+
<div
|
|
57
|
+
v-if="help || $slots.help"
|
|
58
|
+
class="mt-1.5 flex items-start gap-3 text-xs text-dimmed"
|
|
59
|
+
>
|
|
60
|
+
<span class="flex-1">{{ help }}</span>
|
|
61
|
+
<slot name="help" />
|
|
62
|
+
</div>
|
|
63
|
+
</div>
|
|
64
|
+
</template>
|
|
65
|
+
|
|
66
|
+
<script setup lang="ts">
|
|
67
|
+
const props = withDefaults(defineProps<{
|
|
68
|
+
/** What is shown and copied. Not rendered at all while masked. */
|
|
69
|
+
value: string
|
|
70
|
+
/** Field label above the value. */
|
|
71
|
+
label?: string
|
|
72
|
+
/** One muted line under the field. */
|
|
73
|
+
help?: string
|
|
74
|
+
/** Mask the value behind a reveal. */
|
|
75
|
+
secret?: boolean
|
|
76
|
+
/** Accessible name for the eye while the value is hidden. */
|
|
77
|
+
revealLabel?: string
|
|
78
|
+
/** Accessible name for the eye while the value is shown. */
|
|
79
|
+
hideLabel?: string
|
|
80
|
+
/** Accessible name for the copy button at rest. */
|
|
81
|
+
copyLabel?: string
|
|
82
|
+
/** Accessible name for it just after a successful copy. */
|
|
83
|
+
copiedLabel?: string
|
|
84
|
+
}>(), {
|
|
85
|
+
label: undefined,
|
|
86
|
+
help: undefined,
|
|
87
|
+
secret: false,
|
|
88
|
+
revealLabel: 'Show',
|
|
89
|
+
hideLabel: 'Hide',
|
|
90
|
+
copyLabel: 'Copy',
|
|
91
|
+
copiedLabel: 'Copied',
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
const id = useId()
|
|
95
|
+
const { revealed, toggle, mask } = useTspReveal(28)
|
|
96
|
+
|
|
97
|
+
// A rotated secret must start masked. The "Regenerate" action this component is
|
|
98
|
+
// designed to sit beside swaps `value` on the SAME instance, so without this
|
|
99
|
+
// the brand-new key appears in the clear with no fresh disclosure — at the one
|
|
100
|
+
// moment masking matters most.
|
|
101
|
+
watch(() => props.value, () => { revealed.value = false })
|
|
102
|
+
</script>
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
One row of the sidebar footer nav — icon + label, styled exactly like a
|
|
3
|
+
TspNavItem at rest. This exists because gage, pogo and trips each carried the
|
|
4
|
+
same `footerRowClass` string in their own layout; no consumer should write it
|
|
5
|
+
again (theme 0.3.0).
|
|
6
|
+
|
|
7
|
+
Fixed 32px height rather than vertical padding, so it matches the other rows
|
|
8
|
+
in TspSidebarFooter even when one of them holds a control (spec 03).
|
|
9
|
+
|
|
10
|
+
With `to`, the row goes amber on its own route, like TspNavItem — a footer row
|
|
11
|
+
that is a real destination (the user page) has to be able to say "you are
|
|
12
|
+
here". `href` and `button` rows have no route to match, so they never do.
|
|
13
|
+
|
|
14
|
+
Renders a <NuxtLink> with `to`, an <a> with `href`, or a <button> with
|
|
15
|
+
neither:
|
|
16
|
+
|
|
17
|
+
<TspFooterRow href="/sign-out" icon="i-tabler-logout">Logout</TspFooterRow>
|
|
18
|
+
-->
|
|
19
|
+
<template>
|
|
20
|
+
<component
|
|
21
|
+
:is="tag"
|
|
22
|
+
v-bind="linkProps"
|
|
23
|
+
data-testid="tsp-footer-row"
|
|
24
|
+
class="flex items-center gap-2.5 h-8 px-3 rounded-md text-sm font-normal text-muted hover:text-highlighted hover:bg-default"
|
|
25
|
+
:active-class="to ? 'tsp-text-brand' : undefined"
|
|
26
|
+
>
|
|
27
|
+
<UIcon
|
|
28
|
+
v-if="icon"
|
|
29
|
+
:name="icon"
|
|
30
|
+
class="text-lg"
|
|
31
|
+
/>
|
|
32
|
+
<slot />
|
|
33
|
+
</component>
|
|
34
|
+
</template>
|
|
35
|
+
|
|
36
|
+
<script setup lang="ts">
|
|
37
|
+
const props = defineProps<{
|
|
38
|
+
/** Internal route — renders a NuxtLink. */
|
|
39
|
+
to?: string
|
|
40
|
+
/** External or non-router href — renders an <a>. */
|
|
41
|
+
href?: string
|
|
42
|
+
/** Tabler icon name. Every footer row carries one. */
|
|
43
|
+
icon?: string
|
|
44
|
+
}>()
|
|
45
|
+
|
|
46
|
+
const tag = computed(() => {
|
|
47
|
+
if (props.to) return resolveComponent('NuxtLink')
|
|
48
|
+
return props.href ? 'a' : 'button'
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
const linkProps = computed(() => {
|
|
52
|
+
if (props.to) return { to: props.to }
|
|
53
|
+
if (props.href) return { href: props.href }
|
|
54
|
+
return { type: 'button' as const }
|
|
55
|
+
})
|
|
56
|
+
</script>
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
DE/EN segmented control — the component gage, trips, pogo and tsp-tools each
|
|
3
|
+
built by hand (theme 0.3.0).
|
|
4
|
+
|
|
5
|
+
It owns APPEARANCE ONLY. Which locales exist, and how a choice is persisted,
|
|
6
|
+
stay with the app: pass the codes in, bind v-model, react in the app.
|
|
7
|
+
|
|
8
|
+
Every string is a prop with an English default (ADR 008, finding 4 — the same
|
|
9
|
+
mistake TspThemeToggle had to be fixed for in 0.2.0). `labels` are the
|
|
10
|
+
accessible names; the visible text is the code itself, uppercased, unless the
|
|
11
|
+
default slot says otherwise.
|
|
12
|
+
|
|
13
|
+
<TspLanguageToggle
|
|
14
|
+
v-model="locale"
|
|
15
|
+
:locales="['de', 'en']"
|
|
16
|
+
:group-label="t('shell.language')"
|
|
17
|
+
:labels="{ de: t('shell.german'), en: t('shell.english') }"
|
|
18
|
+
/>
|
|
19
|
+
|
|
20
|
+
The selected segment is amber TEXT on a raised surface, not an amber fill:
|
|
21
|
+
ADR 018 leaves the fill to primary buttons, and this control usually sits in
|
|
22
|
+
the sidebar footer.
|
|
23
|
+
-->
|
|
24
|
+
<template>
|
|
25
|
+
<div
|
|
26
|
+
data-testid="tsp-language-toggle"
|
|
27
|
+
role="group"
|
|
28
|
+
:aria-label="groupLabel"
|
|
29
|
+
class="inline-flex items-center gap-0.5 rounded-md border border-default p-0.5"
|
|
30
|
+
>
|
|
31
|
+
<button
|
|
32
|
+
v-for="code in locales"
|
|
33
|
+
:key="code"
|
|
34
|
+
type="button"
|
|
35
|
+
:data-testid="`tsp-lang-${code}`"
|
|
36
|
+
:aria-pressed="code === modelValue"
|
|
37
|
+
:aria-label="labelFor(code)"
|
|
38
|
+
:title="labelFor(code)"
|
|
39
|
+
:lang="code"
|
|
40
|
+
class="rounded-sm px-2 py-0.5 text-xs font-bold uppercase transition-colors"
|
|
41
|
+
:class="code === modelValue
|
|
42
|
+
? 'bg-accented tsp-text-brand'
|
|
43
|
+
: 'text-muted hover:text-highlighted'"
|
|
44
|
+
@click="select(code)"
|
|
45
|
+
>
|
|
46
|
+
<slot
|
|
47
|
+
:locale="code"
|
|
48
|
+
:label="labelFor(code)"
|
|
49
|
+
>{{ code }}</slot>
|
|
50
|
+
</button>
|
|
51
|
+
</div>
|
|
52
|
+
</template>
|
|
53
|
+
|
|
54
|
+
<script setup lang="ts">
|
|
55
|
+
const props = withDefaults(defineProps<{
|
|
56
|
+
/** The active locale code. */
|
|
57
|
+
modelValue?: string
|
|
58
|
+
/** The codes to offer, in display order. */
|
|
59
|
+
locales?: string[]
|
|
60
|
+
/** aria-label for the group. */
|
|
61
|
+
groupLabel?: string
|
|
62
|
+
/** Accessible name per code (aria-label + title). Falls back to the code. */
|
|
63
|
+
labels?: Record<string, string>
|
|
64
|
+
}>(), {
|
|
65
|
+
modelValue: undefined,
|
|
66
|
+
locales: () => ['de', 'en'],
|
|
67
|
+
groupLabel: 'Language',
|
|
68
|
+
labels: () => ({ de: 'German', en: 'English' }),
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
const emit = defineEmits<{ 'update:modelValue': [code: string] }>()
|
|
72
|
+
|
|
73
|
+
const labelFor = (code: string) => props.labels[code] ?? code.toUpperCase()
|
|
74
|
+
const select = (code: string) => emit('update:modelValue', code)
|
|
75
|
+
</script>
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
The small pill on a nav row — stash's OWNER / MEMBER (spec 05).
|
|
3
|
+
|
|
4
|
+
TONES ARE PRESENTATIONAL, NOT SEMANTIC (`primary` | `neutral`). The layer has
|
|
5
|
+
no idea what roles a service has, and naming them `owner`/`member` here would
|
|
6
|
+
bake one app's permission model into eight. The app decides which tone means
|
|
7
|
+
what.
|
|
8
|
+
|
|
9
|
+
BOTH TONES ARE OUTLINES. ADR 018 gives the amber fill to primary buttons
|
|
10
|
+
alone, and the sidebar is swept for amber fills at rest — an amber pill here
|
|
11
|
+
would be exactly the "one small filled block competing with the primary
|
|
12
|
+
button" that ADR was written about.
|
|
13
|
+
-->
|
|
14
|
+
<template>
|
|
15
|
+
<span
|
|
16
|
+
data-testid="tsp-nav-badge"
|
|
17
|
+
:data-tone="tone"
|
|
18
|
+
class="shrink-0 rounded-full border px-1.5 py-px text-[10px] font-bold uppercase tracking-wide"
|
|
19
|
+
:class="tone === 'primary'
|
|
20
|
+
? 'tsp-text-brand border-current'
|
|
21
|
+
: 'text-muted border-default'"
|
|
22
|
+
>
|
|
23
|
+
<slot />
|
|
24
|
+
</span>
|
|
25
|
+
</template>
|
|
26
|
+
|
|
27
|
+
<script setup lang="ts">
|
|
28
|
+
withDefaults(defineProps<{
|
|
29
|
+
/** `primary` = amber outline, `neutral` = grey. Outline only — a fill in the sidebar would
|
|
30
|
+
* read as a primary button (ADR 018).
|
|
31
|
+
*/
|
|
32
|
+
tone?: 'primary' | 'neutral'
|
|
33
|
+
}>(), {
|
|
34
|
+
tone: 'neutral',
|
|
35
|
+
})
|
|
36
|
+
</script>
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
A nav item that owns sub-items (spec 04, variant A).
|
|
3
|
+
|
|
4
|
+
<TspNavGroup to="/fahrten" icon="i-tabler-car" label="Fahrten">
|
|
5
|
+
<TspNavSubItem to="/fahrten/alle">Alle Fahrten</TspNavSubItem>
|
|
6
|
+
</TspNavGroup>
|
|
7
|
+
|
|
8
|
+
Children are ALWAYS VISIBLE. The collapsible variant was rejected: it needs an
|
|
9
|
+
open/closed value that has to survive navigation and then be remembered per
|
|
10
|
+
user, which is a persistence question this layer has no business answering.
|
|
11
|
+
|
|
12
|
+
The parent is an ordinary TspNavItem and needs no special colour — under
|
|
13
|
+
reading 2 every top-level row is already white.
|
|
14
|
+
|
|
15
|
+
The indent is measured, not guessed: `ml-[1.375rem]` puts the hairline under
|
|
16
|
+
the centre of the parent's icon (px-3 = 12px, plus half of the 18px icon), so
|
|
17
|
+
the spine hangs from the icon rather than floating between columns.
|
|
18
|
+
-->
|
|
19
|
+
<template>
|
|
20
|
+
<div
|
|
21
|
+
data-testid="tsp-nav-group"
|
|
22
|
+
class="flex shrink-0 flex-col gap-0.5"
|
|
23
|
+
>
|
|
24
|
+
<TspNavItem
|
|
25
|
+
:to="to"
|
|
26
|
+
:icon="icon"
|
|
27
|
+
:badge="badge"
|
|
28
|
+
:badge-tone="badgeTone"
|
|
29
|
+
>
|
|
30
|
+
{{ label }}
|
|
31
|
+
</TspNavItem>
|
|
32
|
+
<div
|
|
33
|
+
data-testid="tsp-nav-group-children"
|
|
34
|
+
class="ml-[1.375rem] pl-3 border-l border-default flex flex-col gap-0.5"
|
|
35
|
+
>
|
|
36
|
+
<slot />
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
39
|
+
</template>
|
|
40
|
+
|
|
41
|
+
<script setup lang="ts">
|
|
42
|
+
withDefaults(defineProps<{
|
|
43
|
+
/** Route of the parent row itself — the group is a destination, not just a heading. */
|
|
44
|
+
to: string
|
|
45
|
+
/** Tabler icon name for the parent row. */
|
|
46
|
+
icon?: string
|
|
47
|
+
/** Text of the parent row. */
|
|
48
|
+
label: string
|
|
49
|
+
/** Optional pill on the right, e.g. a role. */
|
|
50
|
+
badge?: string
|
|
51
|
+
/** Tone of that pill; see TspNavBadge. */
|
|
52
|
+
badgeTone?: 'primary' | 'neutral'
|
|
53
|
+
}>(), {
|
|
54
|
+
icon: undefined,
|
|
55
|
+
badge: undefined,
|
|
56
|
+
badgeTone: 'neutral',
|
|
57
|
+
})
|
|
58
|
+
</script>
|
|
@@ -1,25 +1,81 @@
|
|
|
1
1
|
<!--
|
|
2
|
-
Sidebar nav link:
|
|
3
|
-
icon name (`i-tabler-*`) and the route.
|
|
2
|
+
Sidebar nav link: WHITE at rest, amber TEXT on a transparent background when
|
|
3
|
+
active. Pass a Tabler icon name (`i-tabler-*`) and the route.
|
|
4
|
+
|
|
5
|
+
White at rest is spec 04 (reading 2) REVISING ADR 018, which had these rows
|
|
6
|
+
muted for a quieter shell. What survives from that ADR: no amber fill at rest
|
|
7
|
+
anywhere, rows stay weight 400, and amber means "you are here". Sub-items
|
|
8
|
+
inside a TspNavGroup stay muted, so the nav reads white → muted → amber.
|
|
9
|
+
|
|
10
|
+
Row metrics are stash's: 32 px tall, 6 px radius, weight 400. The same row
|
|
11
|
+
style is used by TspFooterRow — keep the two class strings in step.
|
|
4
12
|
-->
|
|
5
13
|
<template>
|
|
6
|
-
<
|
|
7
|
-
:
|
|
8
|
-
|
|
9
|
-
|
|
14
|
+
<component
|
|
15
|
+
:is="tag"
|
|
16
|
+
v-bind="linkProps"
|
|
17
|
+
data-testid="tsp-nav-item"
|
|
18
|
+
class="group relative flex shrink-0 items-center gap-2.5 h-8 px-3 rounded-md text-sm font-normal text-highlighted hover:bg-default"
|
|
19
|
+
:active-class="to ? 'tsp-text-brand' : undefined"
|
|
10
20
|
>
|
|
11
21
|
<UIcon
|
|
12
22
|
v-if="icon"
|
|
13
23
|
:name="icon"
|
|
14
|
-
class="text-lg"
|
|
24
|
+
class="text-lg shrink-0"
|
|
15
25
|
/>
|
|
16
|
-
|
|
17
|
-
|
|
26
|
+
<!-- The LABEL gives way, not the badge: rows are a fixed 32px, so letting
|
|
27
|
+
a long name push the badge onto a second line (as stash does) would
|
|
28
|
+
break the row rhythm. TspNavLabel gives the truncated text back on
|
|
29
|
+
hover. -->
|
|
30
|
+
<TspNavLabel><slot /></TspNavLabel>
|
|
31
|
+
<TspNavBadge
|
|
32
|
+
v-if="badge"
|
|
33
|
+
:tone="badgeTone"
|
|
34
|
+
class="ml-auto"
|
|
35
|
+
>{{ badge }}</TspNavBadge>
|
|
36
|
+
</component>
|
|
18
37
|
</template>
|
|
19
38
|
|
|
20
39
|
<script setup lang="ts">
|
|
21
|
-
defineProps<{
|
|
22
|
-
|
|
40
|
+
const props = withDefaults(defineProps<{
|
|
41
|
+
/** Internal route — renders a NuxtLink. */
|
|
42
|
+
to?: string
|
|
43
|
+
/** External or non-router destination — renders an <a>. A section labelled
|
|
44
|
+
"External" whose rows cannot leave the app is not much of a section. */
|
|
45
|
+
href?: string
|
|
46
|
+
/** Only with `href`. Any target that leaves this context also gets `rel="noopener
|
|
47
|
+
* noreferrer"`.
|
|
48
|
+
*/
|
|
49
|
+
target?: string
|
|
50
|
+
/** Tabler icon name. Every nav row carries one. */
|
|
23
51
|
icon?: string
|
|
24
|
-
|
|
52
|
+
/** Optional pill on the right of the row, e.g. a role. */
|
|
53
|
+
badge?: string
|
|
54
|
+
/** Tone of the badge; see TspNavBadge. */
|
|
55
|
+
badgeTone?: 'primary' | 'neutral'
|
|
56
|
+
}>(), {
|
|
57
|
+
to: undefined,
|
|
58
|
+
href: undefined,
|
|
59
|
+
target: undefined,
|
|
60
|
+
icon: undefined,
|
|
61
|
+
badge: undefined,
|
|
62
|
+
badgeTone: 'neutral',
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
// resolveComponent must run in setup, not in a template expression: there it
|
|
66
|
+
// returns the NAME and Vue renders a literal <NuxtLink> element instead of an
|
|
67
|
+
// anchor. lint, typecheck and the build all stay green while the whole nav is
|
|
68
|
+
// broken — only the e2e caught it. Same shape as TspFooterRow, deliberately.
|
|
69
|
+
const tag = computed(() => (props.href ? 'a' : resolveComponent('NuxtLink')))
|
|
70
|
+
|
|
71
|
+
const linkProps = computed(() => (props.href
|
|
72
|
+
? {
|
|
73
|
+
href: props.href,
|
|
74
|
+
target: props.target,
|
|
75
|
+
// Any target that leaves this browsing context, not just _blank: a
|
|
76
|
+
// NAMED target (`target="docs"`) also hands the opened document a live
|
|
77
|
+
// window.opener, and the browser's implicit noopener covers _blank only.
|
|
78
|
+
rel: props.target && props.target !== '_self' ? 'noopener noreferrer' : undefined,
|
|
79
|
+
}
|
|
80
|
+
: { to: props.to }))
|
|
25
81
|
</script>
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
A nav row's label: truncated to the sidebar, with the full text revealed on
|
|
3
|
+
hover after a short pause.
|
|
4
|
+
|
|
5
|
+
NOT a tooltip bubble — it is the label itself, in the row's own surface,
|
|
6
|
+
allowed to run past the sidebar edge. A 224px sidebar will always be too
|
|
7
|
+
narrow for some project names, and truncating with no way to read the rest
|
|
8
|
+
makes those rows useless.
|
|
9
|
+
|
|
10
|
+
The reveal only exists when the text ACTUALLY overflows, measured rather than
|
|
11
|
+
guessed: rendering it for every row would put an invisible overlay on labels
|
|
12
|
+
that do not need one.
|
|
13
|
+
|
|
14
|
+
It is aria-hidden. CSS truncation does not remove text, so a screen reader
|
|
15
|
+
already reads the full label from the element above — the reveal is purely
|
|
16
|
+
visual, and announcing it would read every long row twice.
|
|
17
|
+
|
|
18
|
+
Appears after 500ms, disappears instantly: `delay-500` applies only under
|
|
19
|
+
group-hover, so the delay is on the way in and not on the way out.
|
|
20
|
+
-->
|
|
21
|
+
<template>
|
|
22
|
+
<span
|
|
23
|
+
class="relative min-w-0 flex-1"
|
|
24
|
+
@mouseenter="measure"
|
|
25
|
+
>
|
|
26
|
+
<span
|
|
27
|
+
ref="labelEl"
|
|
28
|
+
data-testid="tsp-nav-label"
|
|
29
|
+
class="block truncate"
|
|
30
|
+
>
|
|
31
|
+
<slot />
|
|
32
|
+
</span>
|
|
33
|
+
<span
|
|
34
|
+
v-if="overflowing"
|
|
35
|
+
data-testid="tsp-nav-label-full"
|
|
36
|
+
aria-hidden="true"
|
|
37
|
+
class="pointer-events-none absolute left-0 top-1/2 z-30 w-max max-w-64 -translate-y-1/2 rounded-md border border-default bg-elevated px-2 py-1 whitespace-nowrap opacity-0 shadow-lg transition-opacity duration-150 delay-0 group-hover:opacity-100 group-hover:delay-500"
|
|
38
|
+
>
|
|
39
|
+
<slot />
|
|
40
|
+
</span>
|
|
41
|
+
</span>
|
|
42
|
+
</template>
|
|
43
|
+
|
|
44
|
+
<script setup lang="ts">
|
|
45
|
+
const labelEl = ref<HTMLElement | null>(null)
|
|
46
|
+
const overflowing = ref(false)
|
|
47
|
+
|
|
48
|
+
// Measured, not assumed. Re-measured on hover as well as on mount, because the
|
|
49
|
+
// sidebar's width and the row's content can both change after first render.
|
|
50
|
+
const measure = () => {
|
|
51
|
+
const el = labelEl.value
|
|
52
|
+
if (el) overflowing.value = el.scrollWidth > el.clientWidth
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
onMounted(measure)
|
|
56
|
+
</script>
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
A break in the nav: a rule, optionally followed by a small label.
|
|
3
|
+
|
|
4
|
+
<TspNavSection label="External" /> rule + label
|
|
5
|
+
<TspNavSection /> bare divider
|
|
6
|
+
|
|
7
|
+
The label deliberately shares the sidebar footer's `User` treatment — 10px,
|
|
8
|
+
uppercase, muted — so a section header reads the same wherever it appears
|
|
9
|
+
(spec 04).
|
|
10
|
+
-->
|
|
11
|
+
<template>
|
|
12
|
+
<div
|
|
13
|
+
data-testid="tsp-nav-section"
|
|
14
|
+
:data-bare="!label"
|
|
15
|
+
class="mt-3 shrink-0"
|
|
16
|
+
>
|
|
17
|
+
<hr class="border-default">
|
|
18
|
+
<p
|
|
19
|
+
v-if="label"
|
|
20
|
+
data-testid="tsp-nav-section-label"
|
|
21
|
+
class="px-3 mt-3 mb-1 text-[10px] uppercase tracking-wide text-muted"
|
|
22
|
+
>
|
|
23
|
+
{{ label }}
|
|
24
|
+
</p>
|
|
25
|
+
</div>
|
|
26
|
+
</template>
|
|
27
|
+
|
|
28
|
+
<script setup lang="ts">
|
|
29
|
+
defineProps<{
|
|
30
|
+
/** Omit for a bare divider. */
|
|
31
|
+
label?: string
|
|
32
|
+
}>()
|
|
33
|
+
</script>
|