Feat(front): add UI component library + UI docs

addresses #2090 #2091 #2370 #2390
closes closes #2355 #2368 #2382 #2384

Co-Authored-By: ArneBo <arne@ecobasa.org>
Co-Authored-By: Flupsi <upsiflu@gmail.com>
Co-Authored-By: jon r <jon@allmende.io>
This commit is contained in:
jon r 2025-04-18 10:43:31 +02:00
parent 90b853b722
commit b485f05264
97 changed files with 12521 additions and 1 deletions

View File

@ -5,3 +5,4 @@ networks:
include:
- path: compose/docs.sphinx.yml
- path: compose/docs.openapi.yml
- path: compose/docs.ui.yml

21
compose/docs.ui.yml Normal file
View File

@ -0,0 +1,21 @@
services:
ui:
build:
context: ../front
dockerfile: Dockerfile.dev
command: yarn dev:docs --host 0.0.0.0
expose: ['5173']
ports:
- '8003:5173'
volumes:
- '../front:/app'
- '/app/node_modules'
networks: ['web']
labels:
- 'traefik.enable=true'
- 'traefik.http.routers.test-funkwhale-ui-web.rule=Host(`ui.funkwhale.test`)'
- 'traefik.http.routers.test-funkwhale-ui-web.entrypoints=web'
- 'traefik.http.services.test-funkwhale-ui.loadbalancer.server.port=5173'
- 'traefik.http.routers.test-funkwhale-ui-webs.rule=Host(`ui.funkwhale.test`)'
- 'traefik.http.routers.test-funkwhale-ui-webs.entrypoints=webs'
- 'traefik.http.routers.test-funkwhale-ui-webs.tls=true'

View File

@ -6,8 +6,11 @@
"author": "Funkwhale Collective <contact@funkwhale.audio>",
"scripts": {
"dev": "vite",
"dev:docs": "VP_DOCS=true vitepress dev ui-docs",
"build": "vite build --mode development",
"build:deployment": "vite build",
"build:docs": "VP_DOCS=true vitepress build ui-docs",
"serve:docs": "VP_DOCS=true vitepress serve ui-docs",
"serve": "vite preview",
"test": "vitest run",
"test:unit": "vitest run --coverage",
@ -18,7 +21,6 @@
"postinstall": "yarn run fix-fomantic-css"
},
"dependencies": {
"@funkwhale/ui": "0.2.2",
"@sentry/tracing": "7.47.0",
"@sentry/vue": "7.47.0",
"@tauri-apps/api": "2.0.0-beta.1",

View File

@ -0,0 +1,73 @@
<script setup lang="ts">
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { computed } from 'vue'
import { type Track, type User } from '~/types'
import OptionsButton from '~/components/ui/button/Options.vue'
import PlayButton from '~/components/ui/button/Play.vue'
const { t } = useI18n()
const emit = defineEmits<{ play: [track: Track] }>()
const { track, user } = defineProps<{ track: Track, user: User }>()
const router = useRouter()
const navigate = (to: 'track' | 'user') =>
to === 'track'
? router.push({ name: 'library.tracks.detail', params: { id: track.id } })
: router.push({ name: 'profile.full', params: profileParams.value })
const profileParams = computed(() => {
const [username, domain] = user.full_username.split('@')
return { username, domain }
})
</script>
<template>
<div
class="funkwhale activity"
@click="navigate('track')"
>
<div class="activity-image">
<img :src="track.cover?.urls.original">
<PlayButton
:round="false"
:shadow="false"
@play="emit('play', track)"
/>
</div>
<div class="activity-content">
<div class="track-title">
{{ track.title }}
</div>
<a
v-for="{ artist } in track.artist_credit"
:key="artist.id"
class="funkwhale link artist"
@click.stop="router.push({
name: 'library.artists.detail',
params: { id: artist.id }
})"
>
{{ artist.name }}
</a>
<a
class="funkwhale link user"
@click.stop="navigate('user')"
>
{{ t('vui.by-user', { username: user.username }) }}
</a>
</div>
<div>
<OptionsButton />
</div>
</div>
</template>
<style lang="scss">
@import './activity.scss'
</style>

View File

@ -0,0 +1,36 @@
<script setup lang="ts">
import { type PastelProps, color } from '~/composables/color'
import { type AlignmentProps, align } from '~/composables/alignment'
import Layout from '~/components/ui/Layout.vue'
export type Props = PastelProps & AlignmentProps
const props = defineProps<Props>()
</script>
<template>
<div
class="funkwhale alert"
role="alert"
v-bind="{
...$attrs,
...color(props, ['solid'])(
align(props)(
))}"
>
<slot />
<Layout
v-if="$slots.actions"
flex
class="actions"
>
<slot name="actions" />
</Layout>
</div>
</template>
<style lang="scss">
@import './alert.scss'
</style>

View File

@ -0,0 +1,305 @@
<script setup lang="ts">
import { ref, computed, useSlots, onMounted, onUnmounted, nextTick } from 'vue'
import { type ColorProps, type VariantProps, type DefaultProps, type RaisedProps, type PastelProps, color } from '~/composables/color'
import { type WidthProps, width } from '~/composables/width'
import { type AlignmentProps, align } from '~/composables/alignment'
import Loader from '~/components/ui/Loader.vue'
const props = defineProps<{
thinFont?: true
lowHeight?: true
isActive?: boolean
isLoading?: boolean
shadow?: boolean
round?: boolean
icon?: string | `right ${string}`
onClick?:(...args: any[]) => void | Promise<void> // The default fallback is `submit`
split?: boolean // Add this prop for split button support
splitIcon?: string // Add this prop for the split button icon
splitTitle?: string // Add this prop
onSplitClick?: (...args: any[]) => void | Promise<void> // Add click handler for split part
dropdownOnly?: boolean
disabled?: boolean
autofocus? : boolean
ariaPressed? : true
} & (ColorProps | DefaultProps | PastelProps)
& VariantProps
& RaisedProps
& WidthProps
& AlignmentProps>()
const slots = useSlots()
// TODO: Refactor this once upload button progress indicator can be tested (in Sidebar.vue)
const isIconOnly = computed(() =>
!!props.icon
&& (!slots.default
|| ('square' in props && props.square)
|| ('squareSmall' in props && props.squareSmall)
)
)
const isSplitIconOnly = computed(() => !!props.splitIcon && !props.splitTitle)
const internalLoader = ref(false)
const isLoading = computed(() => props.isLoading || internalLoader.value)
const fontWeight = props.thinFont ? 400 : 900
const button = ref()
const click = async (...args: any[]) => {
internalLoader.value = true
try {
await props.onClick?.(...args)
} finally {
internalLoader.value = false
}
}
const previouslyFocusedElement = ref()
onMounted(() => props.autofocus && nextTick(() => {
previouslyFocusedElement.value = document.activeElement
previouslyFocusedElement.value?.blur()
button.value.focus()
}))
onUnmounted(() =>
previouslyFocusedElement.value?.focus()
)
</script>
<template>
<div
v-if="split"
class="funkwhale split-button"
>
<button
v-if="!dropdownOnly"
ref="button"
v-bind="{
...$attrs,
...color(props, ['interactive'])(
width(props, isIconOnly ? ['square'] : ['normalHeight', 'buttonWidth'])(
align(props, { alignText:'center' })(
)))}"
class="funkwhale button split-main"
:autofocus="autofocus || undefined"
:disabled="disabled || undefined"
:aria-pressed="props.ariaPressed"
:class="{
'is-loading': isLoading,
'is-icon-only': isIconOnly,
'has-icon': !!icon,
'is-round': round,
'is-shadow': shadow,
}"
@click="click"
>
<slot name="main">
<i
v-if="icon && !icon.startsWith('right ')"
:class="['bi', icon]"
/>
<span v-if="!isIconOnly">
<slot />
</span>
<i
v-if="icon && icon.startsWith('right ')"
:class="['bi', icon.replace('right ', '')]"
/>
</slot>
<Loader
v-if="isLoading"
:container="false"
/>
</button>
<button
v-bind="{
...$attrs,
...color(props, ['interactive'])(
width(props, isSplitIconOnly ? ['square'] : ['normalHeight', 'buttonWidth'])(
align(props, { alignSelf:'start', alignText:'center' })(
)))}"
:disabled="disabled || undefined"
:autofocus="autofocus || undefined"
:class="[
'funkwhale',
'button',
{
'split-toggle': true,
'is-loading': isLoading,
'is-icon-only': isSplitIconOnly,
'has-icon': !!splitIcon,
'is-round': round,
'is-shadow': shadow
}
]"
@click="onSplitClick"
>
<span v-if="splitTitle">{{ splitTitle }}</span>
<i :class="['bi', splitIcon]" />
</button>
</div>
<button
v-else
ref="button"
v-bind="color(props, ['interactive'])(
width(props, isIconOnly ? ['square'] : ['normalHeight', 'buttonWidth'])(
align(props, { alignText:'center' })(
)))"
:disabled="disabled || undefined"
:autofocus="autofocus || undefined"
class="funkwhale button"
:aria-pressed="props.ariaPressed"
:class="{
'is-loading': isLoading,
'is-icon-only': isIconOnly,
'has-icon': !!icon,
'is-round': round,
'is-shadow': shadow,
}"
:type="onClick ? 'button' : 'submit' /* Prevents default `submit` if onCLick is set */"
@click="click"
>
<i
v-if="icon && !icon.startsWith('right ')"
:class="['bi', icon]"
/>
<span v-if="!isIconOnly">
<slot />
</span>
<i
v-if="icon && icon.startsWith('right ')"
:class="['bi', icon.replace('right ', '')]"
/>
<Loader
v-if="isLoading"
:container="false"
/>
</button>
</template>
<style lang="scss">
.funkwhale {
&.split-button {
.button {
display: inline-flex; // Ensure consistent display
align-items: center;
&.split-main {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
border-right: 1px solid var(--border-color);
}
&.split-toggle {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
}
}
&.button {
// Layout
--padding: 16px;
--shift-by: 0.5px;
gap: 8px;
position: relative;
display: inline-flex;
white-space: nowrap;
justify-content: space-between;
align-items: center;
&:not([disabled]) {
cursor: pointer;
}
padding: calc(var(--padding) / 2 - var(--shift-by)) var(--padding) calc(var(--padding) / 2 + var(--shift-by)) var(--padding);
&.is-icon-only {
padding: var(--padding);
}
// Font
font-family: $font-main;
font-weight: v-bind(fontWeight);
font-size: 14px;
line-height: 14px;
// Decoration
transform: translateX(var(--fw-translate-x)) translateY(var(--fw-translate-y)) scale(var(--fw-scale));
transition: all .2s ease;
&.is-shadow {
box-shadow: 0px 2px 4px 0px rgba(0, 0, 0, 0.2);
}
border-radius: var(--fw-border-radius);
&.is-round {
border-radius: 100vh;
}
// States
&[disabled] {
font-weight: normal;
pointer-events: none;
}
&.is-loading {
@extend :active;
> span {
opacity: 0;
}
}
// Content
> span {
position: relative;
top: calc(0px - var(--shift-by));
}
// Icon
> i.bi {
font-size: 18px;
margin: -2px 0;
&.large {
font-size: 32px;
margin: -8px 0;
}
}
&.is-icon-only i.bi {
margin: -6px;
&.large {
margin: -8px;
}
}
&:has(>i){
gap: 10px;
}
}
}
</style>

View File

@ -0,0 +1,319 @@
<script setup lang="ts">
import { computed } from 'vue'
import { type RouterLinkProps, RouterLink } from 'vue-router'
import { type ColorProps, type DefaultProps, type PastelProps, type RaisedProps, type VariantProps, color } from '~/composables/color'
import { type WidthProps, width } from '~/composables/width'
import TagsList from '~/components/tags/List.vue'
import Alert from './Alert.vue'
import { type Props as AlertProps } from './Alert.vue'
import Layout from './Layout.vue'
import Spacer from './Spacer.vue'
const props = defineProps<{
title: string
category?: true | 'h1' | 'h2' | 'h3' | 'h4' | 'h5'
tags?: string[]
image?: string | { src: string, style?: 'withPadding' }
icon?: string
alertProps?: AlertProps
} & Partial<RouterLinkProps>
&(PastelProps | ColorProps | DefaultProps)
& RaisedProps
& VariantProps
& WidthProps
>()
const tags = computed(() => {
return props.tags?.slice(0, 2)
})
const image = typeof props.image === 'string' ? { src: props.image } : props.image
const isExternalLink = computed(() => {
return typeof props.to === 'string' && props.to.startsWith('http')
})
const attributes = computed(() =>
color(props, props.to ? ['interactive', 'solid'] : [])(
width(props, ['medium'])()
))
</script>
<template>
<Layout
stack
no-gap
:class="[{ [$style.card]: true, [$style['is-category']]: category }, 'card']"
v-bind="attributes"
>
<!-- Link -->
<a
v-if="props.to && isExternalLink"
:class="$style.covering"
:href="to?.toString()"
target="_blank"
/>
<RouterLink
v-if="props.to && !isExternalLink"
:class="$style.covering"
:to="props.to"
/>
<!-- Image -->
<div
v-if="$slots.image"
:class="[$style.image, 'card-image']"
>
<slot
name="image"
:src="image"
/>
</div>
<img
v-else-if="image"
:src="image?.src"
:class="[{ [$style.image]: true, [$style['with-padding']]: image?.style === 'withPadding' }, 'card-image']"
>
<Spacer
v-else
:size="'small' in props ? 20 : 28"
/>
<!-- Icon -->
<i
v-if="props.icon"
:class="[$style.icon, 'bi', icon]"
/>
<!-- Title -->
<component
:is="typeof category === 'string' ? category : 'h6'"
:class="$style.title"
>
{{ title }}
</component>
<!-- Topright action -->
<div
v-if="$slots.topright"
:class="$style.topright"
>
<slot name="topright" />
</div>
<!-- Content -->
<Alert
v-if="$slots.alert"
v-bind="alertProps"
:class="$style.alert"
>
<slot name="alert" />
</Alert>
<Layout
v-if="tags"
flex
gap-4
:class="$style.tags"
>
<TagsList
label-classes="tiny"
:truncate-size="8"
:limit="2"
:show-more="false"
:tags="tags"
/>
</Layout>
<Layout
v-if="$slots.default"
no-gap
:class="$style.content"
>
<slot />
</Layout>
<Spacer
grow
no-size
/>
<!-- Footer and Action -->
<div
v-if="$slots.footer"
:class="$style.footer"
>
<slot name="footer" />
</div>
<div
v-if="$slots.action"
:class="$style.action"
>
<slot name="action" />
</div>
<Spacer
v-if="!$slots.footer && !$slots.action"
:size="'small' in props? 24 : 32"
/>
</Layout>
</template>
<style module lang="scss">
.card {
--fw-card-padding: v-bind("'small' in props ? '16px' : '24px'");
position: relative;
color: var(--fw-text-color);
background-color: var(--fw-bg-color);
box-shadow: 0px 2px 8px 0px rgb(0 0 0 / 20%);
border-radius: var(--fw-border-radius);
font-size: 1rem;
overflow: hidden;
>.covering {
position: absolute;
inset: 0;
&~:is(.title, .content:not(:has(:is(button, input, a, select)))) {
pointer-events:none;
}
&:hover~:is(.content:not(:has(:is(button, input, a, select)))) {
text-decoration: underline
}
}
>.image {
overflow: hidden;
border-radius: var(--fw-border-radius) var(--fw-border-radius) 0 0;
object-fit: cover;
width: 100%;
aspect-ratio: 1;
position:relative;
pointer-events: none;
&.with-padding {
margin: var(--fw-card-padding) var(--fw-card-padding) calc(var(--fw-card-padding) / 2) var(--fw-card-padding);
width: calc(100% - 2 * var(--fw-card-padding));
border-radius: var(--fw-border-radius);
}
}
>.icon {
position: absolute;
top: 20px;
right: 20px;
font-size: 1rem;
pointer-events: none;
&:global(.large) {
font-size: 32px;
margin: -8px;
}
}
&:has(>.image)>.icon {
top: var(--fw-card-padding);
right: var(--fw-card-padding);
font-size: 1.2rem;
}
>.title {
margin: 0;
padding: 0 var(--fw-card-padding);
line-height: 1.3em;
font-size: 1.125em;
font-weight: bold;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
flex-shrink: 0;
}
&:has(>.icon):not(:has(.image))>.title {
padding-right:calc(var(--fw-card-padding) + 22px);
}
&.is-category>.title {
font-size: 1.75em;
padding-bottom: .125em;
}
&:has(>.image:not(.with-padding))>.title {
margin-top:16px;
}
>.topright {
position: absolute;
top: 0;
right: 0;
}
>.alert {
padding-left: var(--fw-card-padding);
padding-right: var(--fw-card-padding);
margin-top: 8px;
}
>.tags {
padding: 0 var(--fw-card-padding);
margin-top: 8px;
}
>.content {
padding: 0 var(--fw-card-padding);
/* Consider making all line height, vertical paddings, margins and borders,
a multiple of a global vertical rhythm so that side-by-side lines coincide */
line-height: 24px;
margin-top: 8px;
}
>.footer {
padding: calc(var(--fw-card-padding) - 4px);
display: flex;
gap: 4px;
align-items: end;
font-size: 0.8125rem;
margin-top: 16px;
>.options-button {
margin-left: auto;
}
}
>.action {
display: flex;
background: color-mix(in oklab, var(--fw-bg-color) 80%, var(--fw-gray-500));
border-bottom-left-radius: var(--fw-border-radius);
border-bottom-right-radius: var(--fw-border-radius);
margin-top:16px;
>*:not(.with-padding) {
margin: 0;
border-top-left-radius: 0;
border-top-right-radius: 0;
border: 8px solid transparent;
&:not(:first-child) {
border-bottom-left-radius: 0;
}
&:not(:last-child) {
border-bottom-right-radius: 0;
}
}
}
}
</style>

View File

@ -0,0 +1,106 @@
<script setup lang="ts">
import type { ComponentProps } from 'vue-component-type-helpers'
import Layout from '~/components/ui/Layout.vue'
import Spacer from '~/components/ui/Spacer.vue'
import Button from '~/components/ui/Button.vue'
import Link from '~/components/ui/Link.vue'
import Heading from '~/components/ui/Heading.vue'
const actionComponents
= { Button, Link }
const props = defineProps<{
columnsPerItem?: 1 | 2 | 3 | 4
alignLeft?: boolean
action?: { text: string } & (ComponentProps<typeof Link> | ComponentProps<typeof Button>)
icon?: string
noGap?: false
} & {
[H in `h${ '1' | '2' | '3' | '4' | '5' | '6' }`]? : string
} & {
[S in 'page-heading' | 'section-heading' | 'large-section-heading' | 'subsection-heading' | 'caption' | 'title' | 'radio' | 'secondary' ]? : true
} & {
[Operation in 'expand' | 'collapse']?: () => void
}>()
</script>
<template>
<Layout
header
flex
gap-24
>
<div v-if="$slots.image">
<slot name="image" />
</div>
<Layout
stack
:gap-8="!(props.noGap as boolean)"
:no-gap="props.noGap"
style="flex-grow: 1;"
>
<Layout
flex
no-gap
style="align-self: stretch;"
>
<!-- Set distance between baseline and previous row -->
<Spacer
v
:size="53"
style="align-self: baseline;"
/>
<div
v-if="icon"
style="display: flex; justify-content: center; align-items: center; width: 48px;"
>
<i
:class="['bi', icon]"
style="font-size: 18px;"
/>
</div>
<slot name="topleft" />
<!-- The inferred type of props occasionally overloads the typescript compiler. -->
<!-- TODO: Remove @vue-ignore once tsc is re-implemented in Go (and 10x faster) -->
<!-- @vue-ignore -->
<Heading
v-bind="props"
style="
align-self: baseline;
padding: 0 0 0 0;
margin: 0;
"
/>
<Spacer grow />
<!-- Action! You can either specify `to` or `onClick`. -->
<component
:is="'onClick' in action ? actionComponents.Button : actionComponents.Link"
v-if="action"
thin-font
min-content
align-self="baseline"
:class="$style.action"
v-bind="action"
>
{{ action?.text }}
</component>
<div
v-if="$slots.action"
style="align-self: center;"
>
<slot name="action" />
</div>
</Layout>
<slot />
</Layout>
</Layout>
<Spacer />
</template>
<style module lang="scss">
// Visually push ghost link and non-solid button to the edge
.action:global(.interactive:not(:is(.primary, .solid, .destructive, .secondary)):is(button, a.ghost)) {
margin-right: -16px;
}
</style>

View File

@ -0,0 +1,53 @@
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
[H in `h${ '1' | '2' | '3' | '4' | '5' | '6' }`]? : string
} & {
[S in 'page-heading' | 'section-heading' | 'large-section-heading' | 'subsection-heading' | 'caption' | 'title' | 'radio' | 'secondary' ]? : true
}>()
const [level, title] = Object.entries(props).find(([key, value]) => value && key.startsWith('h')) || ['h1', '']
const size = computed(() => (Object.entries(props).find(([key, value]) => value && key !== level)?.[0] || 'section-heading').replace('-', '').toLowerCase())
</script>
<template>
<component
:is="level"
:class="[$style[size], $style.heading]"
>
<slot name="before" />
{{ title }}
<slot />
<slot name="after" />
</component>
</template>
<style module>
/* Any heading */
:is(h1, h2, h3, h4, h5, h6).heading { margin: 0; padding:0; vertical-align: baseline; align-self: baseline;}
/* Page heading */
:is(*, .vp-doc h3).pageheading { font-size: 36px; font-weight: 900; letter-spacing: -1px; }
/* TODO: Decide on a size. All mockups on https://design.funkwhale.audio/ have 20px. */
:is(*, .vp-doc h3).largesectionheading { font-size: 28px; font-weight: 700; letter-spacing: -.5px; }
/* Section heading, Modal heading [DEFAULT] */
:is(*, .vp-doc h3).sectionheading { font-size: 20px; font-weight: 700; letter-spacing: -.5px; }
/* Form subsection */
:is(*, .vp-doc h3).subsectionheading {font-size: 16px; font-weight: 600; letter-spacing: 0; }
/* input caption */
:is(*, .vp-doc h3).caption {font-size: 14px; font-weight: 600; letter-spacing: .25px; }
/* Tab title, Channel title, Card title, Activity title */
:is(*, .vp-doc h3).title { font-size: 16px; font-weight: 700; line-height: 18px; }
/* Primary radio title */
:is(*, .vp-doc h3).radio { font-size: 28px; font-weight: 900; letter-spacing: -.5px; }
/* Secondary radio title */
:is(*, .vp-doc h3).secondary { font-size: 28px; font-weight: 300; letter-spacing: -.5px; }
</style>

View File

@ -0,0 +1,164 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import onKeyboardShortcut from '~/composables/onKeyboardShortcut'
import { type ColorProps, type VariantProps, type DefaultProps, type RaisedProps, type PastelProps, color } from '~/composables/color.ts'
import Button from '~/components/ui/Button.vue'
import Layout from '~/components/ui/Layout.vue'
const { icon, placeholder, ...props } = defineProps<{
icon?: string;
placeholder?: string;
password?: true;
search?: true;
numeric?: true;
label?: string;
autofocus?: boolean;
reset?:() => void;
} & (ColorProps | DefaultProps | PastelProps)
& VariantProps
& RaisedProps>()
// TODO(A11y): Add `inputmode="numeric" pattern="[0-9]*"` to input if model type is number:
// https://technology.blog.gov.uk/2020/02/24/why-the-gov-uk-design-system-team-changed-the-input-type-for-numbers/
// const isNumeric = restProps.numeric
const showPassword = ref(false)
onKeyboardShortcut('escape', () => showPassword.value = false)
// TODO: Accept fallback $attrs: `const fallthroughAttrs = useAttrs()`
// TODO: Implement `copy password` button?
const attributes = computed(() => ({
...(props.password && !showPassword.value ? { type: 'password' } : {}),
...(props.search ? { type: 'search' } : {}),
...(props.numeric ? { type: 'numeric' } : {})
}))
const { t } = useI18n()
const input = ref()
const previouslyFocusedElement = ref()
onMounted(() => props.autofocus && nextTick(() => {
previouslyFocusedElement.value = document.activeElement
previouslyFocusedElement.value?.blur()
input.value.focus()
}))
onUnmounted(() =>
previouslyFocusedElement.value?.focus()
)
const model = defineModel<string|number>({ required: true })
</script>
<template>
<Layout
stack
no-gap
label
:class="{ 'has-icon': !!icon }"
class="funkwhale input"
>
<span
v-if="$slots['label']"
class="label"
>
<slot name="label" />
</span>
<span
v-if="props.label"
class="label"
>
{{ props.label }}
</span>
<input
v-bind="{...$attrs, ...attributes, ...color(props, ['solid', 'default', 'secondary'])()}"
ref="input"
v-model="model"
:autofocus="autofocus || undefined"
:placeholder="placeholder"
@click.stop
@blur="showPassword = false"
>
<!-- Left side icon -->
<div
v-if="icon"
class="prefix"
>
<i :class="['bi', icon]" />
</div>
<!-- Search -->
<div
v-if="props.search"
class="prefix"
>
<i class="bi bi-search" />
</div>
<!-- Right side -->
<div
v-if="$slots['input-right']"
class="input-right"
>
<span class="span-right">
<slot name="input-right" />
</span>
</div>
<!-- Password -->
<button
v-if="props.password"
style="background:transparent; border:none; appearance:none;"
role="switch"
type="button"
class="input-right show-password"
title="toggle visibility"
@click="showPassword = !showPassword"
@blur="(e) => { if (e.relatedTarget && 'value' in e.relatedTarget && e.relatedTarget.value === model) showPassword = showPassword; else showPassword = false; }"
>
<i class="bi bi-eye" />
</button>
<!-- Search -->
<Button
v-if="props.search"
solid
primary
class="input-right search"
>
{{ t('components.Sidebar.link.search') }}
</Button>
<!-- Reset -->
<Button
v-if="props.reset"
ghost
primary
square-small
icon="bi-arrow-counterclockwise"
class="input-right reset"
:on-click="reset"
:title="t('components.library.EditForm.button.reset')"
/>
</Layout>
</template>
<style lang="scss">
@import './input.scss';
input[type=number]::-webkit-inner-spin-button {
opacity: 1;
}
</style>

View File

@ -0,0 +1,115 @@
<script setup lang="ts">
import { computed } from 'vue'
import { type ColorProps, type DefaultProps, type PastelProps, type RaisedProps, type VariantProps, color } from '~/composables/color'
import { type WidthProps, width } from '~/composables/width'
const props = defineProps<{
columnWidth?: string,
noRule?: true,
noWrap?: true,
} & { [P in 'stack' | 'grid' | 'flex' | 'columns' | 'row' | 'page']?: true | string }
& { [C in 'nav' | 'aside' | 'header' | 'footer' | 'main' | 'label' | 'form' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5']?: true }
& { [G in 'no-gap' | `gap-${'4' | '8' | '12' | '16' | '24' | '32' | '48' | '64' | 'auto'}` ]?: true }
&(PastelProps | ColorProps | DefaultProps)
& RaisedProps
& VariantProps
& WidthProps>()
const columnWidth = props.columnWidth ?? '46px'
const maybeGap = Object.entries(props).find(
([key, value]) => value === true && key.startsWith('gap'))
const gapWidth = maybeGap ? `${maybeGap[0].replace('gap', '').replace('-', '')}px` : '32px'
const attributes = computed(() => ({
...color(props)(width(props)()),
layout:
props.grid === true
? 'grid'
: typeof props.grid === 'string'
? 'grid-custom'
: props.flex === true
? 'flex'
: props.columns === true
? 'columns'
: 'stack'
}))
</script>
<template>
<component
:is="props.nav ? 'nav' : props.aside ? 'aside' : props.header ? 'header' : props.footer ? 'footer' : props.main ? 'main' : props.label ? 'label' : props.form ? 'form' : props.h1 ? 'h1' : props.h2 ? 'h2' : props.h3 ? 'h3' : props.h4 ? 'h4' : props.h5 ? 'h5' : 'div'"
:class="[
$style.layout,
('noGap' in props && props.noGap === true) || $style.gap,
noWrap || $style.wrap,
]"
v-bind="attributes"
>
<slot />
</component>
</template>
<style module lang="scss">
.layout {
transition: all .15s;
/* Override --gap with your preferred value */
gap: var(--gap, v-bind(gapWidth));
&:not(.gap) {
gap: 0;
}
/* Growth */
&:has(:global(>.grow)) {
>:not(:global(.grow)) {
flex-grow: 0;
}
}
/* Layout strategy */
&[layout=columns] {
column-count: auto;
column-width: v-bind(columnWidth);
display: block;
column-rule: 1px solid v-bind("noRule ? 'transparent' : 'var(--border-color)'");
}
&[layout=grid] {
display: grid;
grid-template-columns:
repeat(auto-fit, v-bind(columnWidth));
grid-auto-flow: row dense;
/* If the grid has a fixed size smaller than its container, center it */
place-content: center;
align-items: baseline;
}
&[layout=grid-custom] {
display: grid;
grid: v-bind("props.grid");
grid-auto-flow: row dense;
/* If the grid has a fixed size smaller than its container, center it */
place-content: center;
}
&[layout=grid] > *, &[layout=grid-custom] > * {
/* Set this global variable through `width` */
grid-column: var(--grid-column);
}
&[layout=stack] {
display: flex;
flex-direction: column;
}
&[layout=flex] {
display: flex;
flex-direction: row;
flex-wrap: v-bind('props.noWrap ? "nowrap" : "wrap"');
}
}
</style>

View File

@ -0,0 +1,168 @@
<script setup lang="ts">
import { computed, onMounted, ref, useSlots } from 'vue'
import { type RouterLinkProps, RouterLink } from 'vue-router'
import { type ColorProps, type DefaultProps, type VariantProps, color, isNoColors } from '~/composables/color'
import { type WidthProps, width } from '~/composables/width'
import { type AlignmentProps, align } from '~/composables/alignment'
import { fromProps, notUndefined } from '~/ui/composables/useModal.ts'
const props = defineProps<{
thickWhenActive?: true
thinFont?: true
icon?: string;
round?: true;
autofocus? : boolean
forceUnderline? : true
} & RouterLinkProps
&(ColorProps | DefaultProps)
& VariantProps
& WidthProps
& AlignmentProps>()
const isExternalLink = computed(() =>
typeof props.to === 'string' && (props.to.startsWith('http') || props.to.startsWith('./'))
)
/**
* Any query matches
*/
const isNoMatchingQueryFlags = computed(() =>
fromProps(props, notUndefined)?.every(({ isOpen }) => !isOpen.value)
)
const [fontWeight, activeFontWeight] = 'solid' in props || props.thickWhenActive ? [600, 900] : [400, 400]
const isIconOnly = computed(() =>
!!props.icon && (
!useSlots().default
|| 'square' in props && props.square
|| 'squareSmall' in props && props.squareSmall
)
)
const button = ref()
onMounted(() => {
if (props.autofocus) button.value.focus()
})
</script>
<template>
<component
:is="isExternalLink ? 'a' : RouterLink"
v-bind="
color(props, ['interactive'])(
width(props,
isNoColors(props) ? [] : ['normalHeight', 'solid' in props ? 'buttonWidth' : 'auto']
)(
align(props, 'solid' in props ? {alignText: 'center'} : {})(
)))"
ref="button"
:autofocus="autofocus || undefined"
:class="[
$style.link,
round && $style['is-round'],
isIconOnly && $style['is-icon-only'],
(isNoColors(props) || props.forceUnderline) && $style['force-underline'],
isNoColors(props) && $style['no-spacing'],
isNoMatchingQueryFlags && 'router-link-no-matching-query-flag'
]"
:href="isExternalLink ? to.toString() : undefined"
:to="isExternalLink ? undefined : to"
:target="isExternalLink ? '_blank' : undefined"
>
<i
v-if="icon"
:class="['bi', icon]"
/>
<span>
<slot />
</span>
</component>
</template>
<style module lang="scss">
.link {
// Layout
--padding: 16px;
--shift-by: 0.5px;
position: relative;
display: inline-flex;
white-space: nowrap;
align-items: center;
padding: calc(var(--padding) / 2 - var(--shift-by)) var(--padding) calc(var(--padding) / 2 + var(--shift-by)) var(--padding);
&.is-icon-only {
padding: var(--padding);
}
&.no-spacing {
padding: 0;
margin: 0;
font-size: 1em;
}
// Font
font-family: $font-main;
font-weight: v-bind(fontWeight);
font-size: 14px;
line-height: 1em;
// Content
> span {
position: relative;
top: calc(0px - var(--shift-by));
}
// Decoration
&:not([disabled]) {
cursor: pointer;
}
transform: translateX(var(--fw-translate-x)) translateY(var(--fw-translate-y)) scale(var(--fw-scale));
transition:background-color .2s, border-color .3s;
&:not(.force-underline) {
text-decoration: none;
// background-color: transparent;
// border-color: transparent;
}
border-radius: var(--fw-border-radius);
&.is-round {
border-radius: 100vh;
}
// States
&:global(.router-link-exact-active) {
font-weight: v-bind(activeFontWeight);
}
// Icon
> i:global(.bi) {
font-size: 1.2rem;
&.large {
font-size:2rem;
}
&+span:not(:empty) {
margin-left: 1ch;
}
}
}
</style>

View File

@ -0,0 +1,25 @@
<script setup lang="ts">
const { container = true } = defineProps<{ container?: boolean }>()
</script>
<template>
<div
v-if="container"
class="funkwhale loader-container"
>
<div class="funkwhale">
<div class="loader" />
</div>
</div>
<div
v-else
class="funkwhale"
>
<div class="loader" />
</div>
</template>
<style lang="scss">
@import './loader.scss'
</style>

View File

@ -0,0 +1,92 @@
<script setup lang="ts">
import { computed } from 'vue'
import { char, createRegExp, exactly, global, oneOrMore, word } from 'magic-regexp/further-magic'
import showdown from 'showdown'
import SanitizedHtml from './SanitizedHtml.vue'
interface Props {
md: string
}
const props = defineProps<Props>()
showdown.extension('openExternalInNewTab', {
type: 'output',
regex: createRegExp(
exactly('<a'),
char.times.any(),
exactly(' href="'),
oneOrMore(
// TODO: Use negative set when implemented: https://github.com/danielroe/magic-regexp/issues/237#issuecomment-1606056174
char
),
exactly('">'),
[global]
),
replace (text: string) {
const href = createRegExp(
exactly('href="'),
oneOrMore(
// TODO: Use negative set when implemented: https://github.com/danielroe/magic-regexp/issues/237#issuecomment-1606056174
char
).as('url'),
exactly('">')
)
const matches = text.match(href)
const url = matches?.groups?.url ?? './'
if ((!url.startsWith('http://') && !url.startsWith('https://')) || url.startsWith('mailto:')) {
return text
}
try {
const { hostname } = new URL(url)
return hostname !== location.hostname
? text.replace(href, `href="${url}" target="_blank" rel="noopener noreferrer">`)
: text
} catch {
return text.replace(href, `href="${url}" target="_blank" rel="noopener noreferrer">`)
}
}
})
showdown.extension('linkifyTags', {
type: 'language',
regex: createRegExp(
exactly('#'),
oneOrMore(word),
[global]
),
// regex: /#[^\W]+/g,
replace (text: string) {
return `<a href="/library/tags/${text.slice(1)}">${text}</a>`
}
})
const markdown = new showdown.Converter({
extensions: ['openExternalInNewTab', 'linkifyTags'],
ghMentions: true,
ghMentionsLink: '/@{u}',
simplifiedAutoLink: true,
openLinksInNewWindow: false,
simpleLineBreaks: true,
strikethrough: true,
tables: true,
tasklists: true,
underline: true,
noHeaderId: true,
headerLevelStart: 3,
literalMidWordUnderscores: true,
excludeTrailingPunctuationFromURLs: true,
encodeEmails: true,
emoji: true
})
const html = computed(() => markdown.makeHtml(props.md))
</script>
<template>
<SanitizedHtml :html="html" />
</template>

View File

@ -0,0 +1,152 @@
<script setup lang="ts">
import { type ColorProps, type DefaultProps, color } from '~/composables/color'
import { watchEffect, ref, nextTick } from 'vue'
import onKeyboardShortcut from '~/composables/onKeyboardShortcut'
import Button from '~/components/ui/Button.vue'
import Spacer from '~/components/ui/Spacer.vue'
import Layout from '~/components/ui/Layout.vue'
import Heading from '~/components/ui/Heading.vue'
const props = defineProps<{
title: string,
overPopover?: true,
destructive?: true,
cancel?: string,
icon?: string,
autofocus?: true | 'off'
} &(ColorProps | DefaultProps)>()
const isOpen = defineModel<boolean>({ default: false })
const previouslyFocusedElement = ref()
// Handle focus and inertness of the elements behind the modal
watchEffect(() => {
if (isOpen.value) {
nextTick(()=>{
previouslyFocusedElement.value = document.activeElement
previouslyFocusedElement.value?.blur()
document.querySelector('#app')?.setAttribute('inert', 'true')
})
} else {
nextTick(() => previouslyFocusedElement.value?.focus())
document.querySelector('#app')?.removeAttribute('inert')
}
})
onKeyboardShortcut('escape', () => { isOpen.value = false })
// TODO:
// When overflowing content: Add inset shadow to indicate scrollability
</script>
<template>
<Teleport to="body">
<Transition mode="out-in">
<div
v-if="isOpen"
class="funkwhale overlay"
@click.exact.stop="isOpen = false"
>
<div
class="funkwhale modal"
:class="[
{ 'is-destructive': destructive,
'has-alert': !!$slots.alert,
'over-popover': overPopover,
}
]"
v-bind="{...$attrs, ...color(props)}"
@click.stop
>
<Layout
flex
gap-12
style="padding: 12px 12px 0 12px;"
>
<div
v-if="!$slots.topleft && !icon"
style="width: 48px;"
/>
<div
v-if="icon"
style="display: flex; justify-content: center; align-items: center; width: 48px;"
>
<i
:class="['bi', icon]"
style="font-size: 18px;"
/>
</div>
<slot name="topleft" />
<Spacer
v-if="!$slots.topleft"
grow
/>
<Heading
v-if="title !== ''"
:h2="title"
section-heading
:class="{'destructive-header': destructive}"
/>
<Spacer grow />
<Button
icon="bi-x-lg"
ghost
align-self="baseline"
:autofocus="props.autofocus === undefined ? ($slots.actions || cancel ? undefined : true) : props.autofocus !== 'off'"
@click="isOpen = false"
/>
</Layout>
<!-- Content -->
<div class="modal-shadow-top" />
<div class="modal-content">
<Transition>
<div
v-if="$slots.alert"
class="alert-container"
>
<div>
<slot name="alert" />
</div>
</div>
</Transition>
<slot />
<Spacer v-if="!$slots.actions" />
</div>
<div class="modal-shadow-bottom" />
<!-- Actions slot -->
<Layout
v-if="$slots.actions || cancel"
flex
gap-12
style="flex-wrap: wrap;"
class="modal-actions"
>
<slot name="actions" />
<Button
v-if="cancel"
secondary
autofocus
:on-click="()=>{ isOpen = false }"
>
{{ cancel }}
</Button>
</Layout>
</div>
</div>
</Transition>
</Teleport>
</template>
<style lang="scss">
@import './modal.scss'
</style>

View File

@ -0,0 +1,93 @@
<script setup lang="ts">
import { type RouterLinkProps } from 'vue-router'
import Link from '~/components/ui/Link.vue'
import Layout from '~/components/ui/Layout.vue'
type Tab = {
title: string,
to: RouterLinkProps['to'],
icon?: string,
badge?: string | number
}
const tabs = defineModel<Tab[]>({ required: true })
</script>
<template>
<Layout
nav
flex
>
<Link
v-for="tab in tabs"
:key="tab.title"
v-bind="tab"
ghost
min-content
:class="$style.tab"
>
<Layout
stack
no-gap
>
<span :class="$style.fakeTitle">{{ tab.title }}</span>
<span :class="$style.realTitle">{{ tab.title }}</span>
<span
v-if="tab.badge"
:class="$style.badge"
>
{{ tab.badge }}
</span>
</Layout>
</Link>
</Layout>
</template>
<style module>
.fakeTitle {
font-size: 16px;
font-weight: 900;
opacity: 0;
pointer-events: none;
max-height: 0;
overflow: hidden;
}
.realTitle {
font-size: 16px;
font-weight: 400;
}
.tab {
--hover-background-color: transparent;
--exact-active-background-color: transparent;
}
.tab:global(.router-link-exact-active) .realTitle {
font-weight: 900;
}
.badge {
display: block;
height: 16px;
background-color: var(--fw-secondary);
width: 16px;
position: absolute;
inset: -10px -14px auto auto;
border-radius: 100vh;
font-size: 10px;
font-weight: 900;
padding: 5px;
line-height: 5px;
color: black;
}
:is(.tab:global(.router-link-exact-active), .tab:hover) .realTitle:after {
content: '';
display: block;
height: 4px;
background-color: var(--fw-secondary);
margin: 0 auto;
width: calc(10% + 2rem);
position: absolute;
inset: auto 0 -14px 0;
border-radius: 100vh;
}
</style>

View File

@ -0,0 +1,183 @@
<script setup lang="ts">
import { useElementSize } from '@vueuse/core'
import { useI18n } from 'vue-i18n'
import { ref, computed, watch } from 'vue'
import { isMobileView } from '~/composables/screen'
import Button from '~/components/ui/Button.vue'
import Input from '~/components/ui/Input.vue'
const { t } = useI18n()
type NonnegativeInteger<T extends number> =
`${T}` extends `-${any}` | `${any}.${any}` ? never : T
const { pages } = defineProps<{ pages: NonnegativeInteger<number> }>()
const page = defineModel<number>('page', {
required: true,
validator: (value: number) => value > 0
})
const goTo = ref<number | string>('' as const)
const range = (start: number, end: number) => Array.from({ length: end - start + 1 }, (_, i) => i + start)
/* Why? What? */
const renderPages = computed(() => {
const start = range(2, 5)
const end = range(pages - 4, pages - 1)
const pagesArray = [1]
if (page.value < 5) pagesArray.push(...start)
if (page.value >= 5 && page.value <= pages - 4) {
pagesArray.push(page.value - 1)
pagesArray.push(page.value)
pagesArray.push(page.value + 1)
}
if (page.value > pages - 4) pagesArray.push(...end)
pagesArray.push(pages)
return pagesArray.filter((page, index, pages) => pages.indexOf(page) === index)
})
const pagination = ref()
const { width } = useElementSize(pagination)
const isSmall = isMobileView(width)
const setPage = () => {
if (goTo.value === '') return
page.value = pageFromInput(goTo.value)
}
watch(goTo, potentiallyWrongValue => {
goTo.value = typeof potentiallyWrongValue === 'string'
? ''
: pageFromInput(potentiallyWrongValue)
})
const pageFromInput = (input: string | number): number =>
input === 'NaN'
? pageFromInput('')
: typeof input === 'string'
? pageFromInput(parseInt(input))
: Number.isNaN(input)
? 1
: Math.min(Math.max(1, input), pages)
/* When user changes page, the "GoTo" input should be emptied */
watch(page, (_) => {
goTo.value = ''
})
</script>
<template>
<nav
ref="pagination"
:aria-label="t('vui.aria.pagination.nav')"
:class="{ 'is-small': isSmall }"
class="funkwhale pagination"
role="navigation"
>
<ul class="pages">
<li>
<Button
low-height
min-content
:disabled="page <= 1"
:aria-label="t('vui.aria.pagination.gotoPrevious')"
secondary
icon="bi-chevron-left"
@click="page -= 1"
>
<span v-if="!isSmall">{{ t('vui.pagination.previous') }}</span>
</Button>
</li>
<template
v-for="(i, index) in (isSmall ? [] : renderPages)"
:key="i"
>
<li>
<Button
v-if="i <= pages && i > 0 && pages > 3"
square-small
:aria-label="page !== i ? t('vui.aria.pagination.gotoPage', i) : t('vui.aria.pagination.currentPage', page)"
:secondary="page !== i"
@click="page = i"
>
{{ i }}
</Button>
</li>
<li v-if="i + 1 < renderPages[index + 1]">
{{ (() => '…')() }}
</li>
</template>
<template v-if="isSmall">
<li>
<Button
square-small
:aria-label="page !== 1 ? t('vui.aria.pagination.gotoPage', page) : t('vui.aria.pagination.currentPage', page)"
:secondary="page !== 1"
@click="page = 1"
>
{{ (() => '1')() }}
</Button>
</li>
<li v-if="page === 1 || page === pages">
{{ (() => '…')() }}
</li>
<li v-else>
<Button
square-small
:aria-label="t('vui.aria.pagination.currentPage', page)"
aria-current="true"
>
{{ page }}
</Button>
</li>
<li>
<Button
square-small
:aria-label="page !== pages ? t('vui.aria.pagination.gotoPage', page) : t('vui.aria.pagination.currentPage', page)"
:secondary="page !== pages"
@click="page = pages"
>
{{ pages }}
</Button>
</li>
</template>
<li>
<Button
low-height
min-content
:disabled="page >= pages"
:aria-label="t('vui.aria.pagination.gotoNext')"
secondary
icon="right bi-chevron-right"
@click="page += 1"
>
<span v-if="!isSmall">{{ t('vui.pagination.next') }}</span>
</Button>
</li>
</ul>
<!-- \d{1,100} -->
<div class="goto">
{{ t('vui.go-to') }}
<Input
v-model.number="goTo"
:placeholder="page?.toString()"
inputmode="numeric"
pattern="[0-9]*"
@click.stop
@keyup.enter="setPage"
/>
</div>
</nav>
</template>
<style lang="scss">
@import './pagination.scss'
</style>

View File

@ -0,0 +1,473 @@
<script setup lang="ts">
import { ref, watch, nextTick, computed, onMounted } from 'vue'
import { type ColorProps, type PastelProps, type VariantProps, type RaisedProps, color } from '~/composables/color'
import Layout from './Layout.vue'
import Button from './Button.vue'
import Input from './Input.vue'
import Popover from './Popover.vue'
import PopoverItem from './popover/PopoverItem.vue'
import { uniqBy } from 'lodash-es'
/* Event */
const emit = defineEmits<{
confirmed: [],
closed: [],
opened: []
}>()
/* Model */
const props = defineProps<{
noUnderline?: true,
cancel?: string,
autofocus?: boolean
} & (PastelProps | ColorProps)
& VariantProps
& RaisedProps
>()
type Item = { type: 'custom' | 'preset', label: string }
const currentItem = defineModel<Item>('current'),
otherItems = defineModel<Item[]>('others')
// Make sure there are no duplicate labels
const unique = (value: Item[]) => uniqBy(value, item => item.label)
const isEditing = ref<boolean>(false)
/* Lifecycle */
onMounted(() => {
if (props.autofocus) {
nextTick(() => {
if (!currentItem.value || !otherItems.value) return
clicked()
})
}
})
let previousValue: Item | undefined
let previouslyFocusedElement: Element | null
watch(isEditing, (isTrue, wasTrue) => {
if (!currentItem.value || !otherItems.value) return
// Cache the previous value, in case the user cancels later
if (isTrue && !wasTrue) {
emit('opened')
previousValue = { ...currentItem.value }
if (currentItem.value.type === 'preset') {
otherItems.value.push({...currentItem.value})
otherItems.value = unique(otherItems.value)
currentItem.value.type = 'custom'
}
// Shift focus between the input and the previously focused element
previouslyFocusedElement = document.activeElement
} else if (wasTrue && !isTrue) {
nextTick(() => (previouslyFocusedElement as HTMLElement)?.focus())
const matchInOthers
= otherItems.value.find(({ label }) => label === currentItem.value?.label.trim())
if (matchInOthers) {
currentItem.value = { ...matchInOthers }
}
otherItems.value = otherItems.value.filter(({ label }) => label !== currentItem.value?.label)
emit('closed')
}
})
/* Update */
const clicked = () => {
if (!currentItem.value || !otherItems.value) return
if (!isEditing.value) {
isEditing.value = true
}
}
const pressedKey = (e: KeyboardEvent) => {
if (!currentItem.value || !otherItems.value) return
// confirm or cancel
switch (e.key) {
case "Enter":
case "Tab":
case "ArrowLeft":
case "ArrowRight":
case "Space":
case ",":
case " ":
confirmed(); break;
case "Escape":
canceled(); break;
}
}
const releasedKey = () => {
if (!otherItems.value || !currentItem.value) return
currentItem.value.label = currentItem.value.label.replace(',', '').replace(' ', '').trim()
}
const canceled = () => {
if (!previousValue || !currentItem.value || !otherItems.value) return
const matchInOthers
= otherItems.value?.find(({ label })=>label === currentItem.value?.label.trim())
// Reset current label
currentItem.value
= matchInOthers
|| {...previousValue}
// Close dropdown
isEditing.value = false
}
const confirmed = () => {
if (!previousValue || !currentItem.value || !otherItems.value) return
// Sanitize label
currentItem.value.label = currentItem.value.label.replace(',', '').replace(' ', '').trim()
// Apply the identical, otherwise the best match, if available
currentItem.value
= otherItems.value?.find(({ label })=>label === currentItem.value?.label.trim())
|| match.value
|| currentItem.value
// Close dropdown
isEditing.value = false
// Tell parent component
if (previousValue !== currentItem.value)
emit('confirmed')
}
const sortedOthers = computed(()=>
otherItems.value && currentItem.value
? otherItems.value.map((item) =>
item.label.toLowerCase().includes(currentItem.value?.label.toLowerCase() || '')
? [item.label.length - (currentItem.value?.label.length || 0), item] as const /* TODO: Use a more sophisticated algorithm for suggestions */
: [99, item] as const
)
.sort(([deltaA, a], [deltaB, b]) =>
deltaA - deltaB
)
.map(([delta, item], index) =>
index===0 && delta < 99 && currentItem.value && currentItem.value.label.length>0 && currentItem.value.label !== previousValue?.label
? [-1, item] as const /* It's a match */
: [delta, item] as const /* It's not a match */
)
: []
)
const match = computed(()=>
sortedOthers.value.at(0)?.[0] === -1
? sortedOthers.value.at(0)?.[1]
: undefined
)
const other = computed(() => (option: Item) => ({
item: {
onClick: () => {
if (!currentItem.value || !otherItems.value) return;
currentItem.value = { ...option, type: 'custom' }
otherItems.value = unique([...(
currentItem.value.label.trim() === '' || otherItems.value.find(({ label }) => label === currentItem.value?.label.trim())
? []
: [{ ...currentItem.value }]
), ...otherItems.value.filter(
({ label, type }) => label !== option.label || type === 'preset'
)])
isEditing.value = false
},
isMatch: match.value?.label === option.label,
isSame: option.label === currentItem.value?.label
},
action: option.type === 'custom'
? {
title: 'Delete custom',
icon: 'bi-trash',
onClick: () => {
if (!currentItem.value || !otherItems.value) return;
otherItems.value = otherItems.value.filter(({ label }) => label !== option.label)
}
}
: undefined
} as const))
const current = computed(() => (
!currentItem.value || !otherItems.value
? undefined
: currentItem.value.label === '' && previousValue?.label !== ''
? {
attributes: {
title: `Reset to ${previousValue?.label || currentItem.value}`,
icon: 'bi-arrow-counterclockwise'
},
onClick: () => {
if (!currentItem.value || !otherItems.value) return;
currentItem.value = previousValue || currentItem.value
}
} as const
: currentItem.value.label === previousValue?.label && currentItem.value.type==='custom' && !otherItems.value?.find(({ label })=>label === currentItem.value?.label.trim()) && currentItem.value.label !== ''
? {
attributes: {
title: `Delete ${currentItem.value.label}`,
icon: 'bi-trash',
destructive: true
},
onClick: () => {
if (!currentItem.value || !otherItems.value) return;
currentItem.value.label = ''
isEditing.value = false
}
} as const
: currentItem.value.label !== match.value?.label && currentItem.value.type === 'custom' && currentItem.value.label.trim() !== '' && !otherItems.value?.find(({ label })=>label === currentItem.value?.label.trim())
? {
attributes: {
title: `Add ${currentItem.value.label}`,
icon: 'bi-plus',
'aria-pressed': !match.value,
primary: true
},
onClick: () => {
if (!otherItems.value || !currentItem.value || otherItems.value.find(({ label })=>label === currentItem.value?.label.trim())) return
otherItems.value.push({...currentItem.value})
otherItems.value = unique(otherItems.value)
}
} as const
: undefined
))
</script>
<template>
<button
:class="['funkwhale', $style.pill, (props.noUnderline || currentItem) && $style['no-underline']]"
type="button"
@click="clicked"
>
<Layout
flex
no-wrap
no-gap
:class="$style.container"
v-bind="color(props, ['solid', 'interactive', 'secondary'])()"
>
<!-- Image -->
<div
v-if="!!$slots.image"
:class="$style['pill-image']"
>
<slot name="image" />
</div>
<!-- Preset content -->
<div :class="$style['pill-content']">
<slot />
{{ currentItem?.label }} {{ `&ZeroWidthSpace;${''}` }}
<Popover
v-if="currentItem && otherItems"
v-model="isEditing"
>
<div />
<template #items>
<!-- Current item -->
<PopoverItem>
<Input
v-model="currentItem.label"
autofocus
low-height
:class="$style.input"
@keydown.enter.stop.prevent="pressedKey"
@keydown="pressedKey"
@keyup="releasedKey"
/>
<template #after>
<Button
v-if="current"
ghost
v-bind="current?.attributes"
square-small
style="border-radius: 4px;"
:class="$style['input-delete-button']"
@click.stop.prevent="current?.onClick"
/>
</template>
</PopoverItem>
<hr>
<!-- Other items, Sorted by matchingness -->
<PopoverItem
v-for="[, option] in sortedOthers"
:key="option.label"
:aria-pressed="other(option).item.isMatch || other(option).item.isSame || undefined"
@click.stop.prevent="other(option).item.onClick"
>
<span :class="other(option).item.isMatch && $style.match">
{{ option.label }}
</span>
<template #after>
<Button
v-if="other(option).action"
round
ghost
square-small
destructive
:title="other(option).action?.title"
:icon="other(option).action?.icon"
@click.stop.prevent="other(option).action?.onClick"
/>
</template>
</PopoverItem>
<hr>
<PopoverItem
v-if="cancel"
@click.stop.prevent="canceled"
>
{{ cancel }}
</PopoverItem>
</template>
</Popover>
</div>
<!-- Action -->
<label
v-if="!!$slots.action"
:class="$style['pill-action']"
>
<slot name="action" />
</label>
</Layout>
</button>
</template>
<style module lang="scss">
.pill {
position: relative;
display: block;
appearance: none;
background: transparent;
outline: 0px transparent;
border: 0px;
font-size: 12px;
line-height: 16px;
border-radius: 100vh;
// Negative margins for increased interactive area; visual correction for rounded shape
margin: -4px -4px;
padding: 0px;
border-radius: 100vh;
width: fit-content;
> .container {
border-radius: inherit;
> .pill-content {
// 1px border
padding: 4px 9px;
white-space: nowrap;
min-width: 56px;
border-radius: inherit;
//Works as anchor point for popup
position: relative;
&input {
min-width: 44px; flex-basis: 44px;
}
&:focus-visible, &:focus {
outline: 1px solid var(--focus-ring-color);
outline-offset: 2px;
}
&:has(+.pill-action) {
margin-right: -26px;
padding-right: 26px;
}
}
> .pill-image {
position: relative;
border-radius: inherit;
overflow: hidden;
height: 26px;
aspect-ratio: 1;
align-content: center;
> * {
height: 100%;
width: 100%;
}
> i.bi {
font-size: 18px;
}
> img {
object-fit: cover;
}
}
> .pill-action {
position: relative;
width: 44px;
height: 44px;
padding: 9px;
margin: -9px;
aspect-ratio: 1;
border-radius: inherit;
overflow: hidden;
align-content: center;
flex-shrink:0;
> * {
height: 100% !important;
width: 100% !important;
padding: 0 !important;
}
}
}
&:hover:not(.no-underline) {
text-decoration: underline;
}
&[disabled] {
font-weight: normal;
cursor: default;
}
&.is-focused,
&:focus {
box-shadow: none !important;
}
}
.input {
// Position the input label within a 40px high popover item
margin: -4px -16px;
position: relative;
top: -4px;
&:has(+* .input-delete-button:hover) input{
background: var(--background-color);
color: var(--disabled-color) !important;
}
}
</style>

View File

@ -0,0 +1,169 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch, onMounted } from 'vue'
import { color } from '~/composables/color'
import Pill from './Pill.vue'
import Layout from './Layout.vue'
import Button from './Button.vue'
/**
* Use `get` to read the pills into your app.
* Use `set` to write your app's state back into the pills.
*/
const props = defineProps<{
icon?: string,
placeholder?: string,
label?: string,
cancel?: string,
get: (v: Model) => void,
set: (v: Model) => Model
}>()
const model = ref<Model>({ currents: [] })
type Item = { type: 'custom' | 'preset', label: string }
type Model = { currents: Item[], others?: Item[] }
const isStatic = computed(() =>
!model.value.others
)
const emptyItem = {
label: '', type: 'custom'
} as const
const nextIndex = ref<number | undefined>(undefined)
const sanitize = () => {
if (model.value.others) {
model.value.currents = [...model.value.currents.filter(({ label }) => label !== ''), { ...emptyItem }]
props.get({ ...model.value, currents: [...model.value.currents.filter(({ label }) => label !== '')] });
}
}
const next = (index: number) => nextTick(() => { nextIndex.value = index + 1 })
watch(model, () => {
sanitize()
})
sanitize();
onMounted(() => {
model.value = props.set(model.value)
})
</script>
<template>
<Layout
stack
no-gap
label
:class="$style.pills"
for="dropdown"
>
<!-- Label -->
<span
v-if="$slots['label']"
:class="$style.label"
>
<slot name="label" />
</span>
<span
v-if="props.label"
:class="$style.label"
>
{{ props.label }}
</span>
<!-- List of Pills -->
<Layout
flex
gap-4
v-bind="color({}, ['solid', 'default', 'secondary'])()"
:class="$style.list"
>
<Pill
v-for="(_, index) in model.currents"
:key="index+1000*(nextIndex || 0)"
v-model:current="model.currents[index]"
v-model:others="model.others"
:cancel="cancel"
:autofocus="index === nextIndex && nextIndex < model.currents.length"
outline
no-underline
:class="[$style.pill, $style[
isStatic
? 'static'
: model.currents[index].label === ''
? 'empty'
: model.currents[index].type
]]"
@opened="() => { model = props.set(model); }"
@closed="() => { sanitize(); }"
@confirmed="() => { next(index) }"
>
<span
v-if="isStatic"
:class="$style['pill-content']"
>{{ model.currents[index].label }}</span>
<template
v-if="model.others && model.currents[index].label !== ''"
#action
>
<Button
ghost
primary
round
icon="bi-x"
title="Deselect"
@click.stop.prevent="() => {
if (!model.others) return
model.others.push({...model.currents[index]});
model.currents[index] = {label: '', type: 'custom'}
sanitize()
}"
/>
</template>
</Pill>
</Layout>
</Layout>
</template>
<style module lang="scss">
.pills {
>.label {
margin-top: -18px;
padding-bottom: 8px;
font-size: 14px;
font-weight: 600;
}
>.list {
position: relative;
// Compensation for round shapes -> https://en.wikipedia.org/wiki/Overshoot_(typography)
margin: 0 -4px;
// padding: 4px;
border-radius: 22px;
gap: 8px;
padding: 2px;
min-height: 36px;
.empty {
flex-grow: 1;
}
}
&:hover:has(select)>.list {
box-shadow: inset 0 0 0 4px var(--border-color)
}
:has(>select:focus) {
box-shadow: inset 0 0 0 4px var(--focus-ring-color)
}
}
</style>

View File

@ -0,0 +1,163 @@
<script setup lang="ts">
import { computed, ref, inject, provide, shallowReactive, watch, onScopeDispose } from 'vue'
import { whenever, useElementBounding, onClickOutside } from '@vueuse/core'
import { isMobileView, useScreenSize } from '~/composables/screen'
import { POPOVER_INJECTION_KEY, POPOVER_CONTEXT_INJECTION_KEY } from '~/injection-keys'
import { type ColorProps, type DefaultProps, type RaisedProps, color } from '~/composables/color'
/* TODO: Basic accessibility
-> See ui-docs
*/
const isOpen = defineModel<boolean>({ default: false })
const { positioning = 'vertical', ...colorProps } = defineProps<{
positioning?:'horizontal' | 'vertical'
} &(ColorProps | DefaultProps) & RaisedProps>()
// Template refs
const popover = ref()
const slot = ref()
const inSlot = ref()
// Click outside
const mobileClickOutside = (event: MouseEvent) => {
const inPopover = !!(event.target as HTMLElement).closest('.funkwhale.popover')
if (isMobile.value && !inPopover) {
isOpen.value = false
}
}
onClickOutside(popover, async (event) => {
const inPopover = !!(event.target as HTMLElement).closest('.funkwhale.popover')
if (!isMobile.value && !inPopover) {
isOpen.value = false
}
}, { ignore: [slot] })
// Auto positioning
const isMobile = isMobileView()
const { width, height, left, top, update } = useElementBounding(() => slot.value?.children[0])
const { width: popoverWidth, height: popoverHeight } = useElementBounding(popover, {
windowScroll: false
})
whenever(isOpen, update, { immediate: true })
const { width: screenWidth, height: screenHeight } = useScreenSize()
// TODO (basic functionality):
// - I can't operate the popup with a keyboard. Remove barrier for people not using a mouse (A11y)
// - Switching to submenus is error-prone. When moving cursor into freshly opened submenu, it should not close if the cursor crosses another menu item
// - Large menus disappear. When menus get big, they need to scroll.
const position = computed(() => {
if (positioning === 'vertical' || isMobile.value) {
let offsetTop = top.value + height.value
if (offsetTop + popoverHeight.value > screenHeight.value) {
offsetTop -= popoverHeight.value + height.value
}
let offsetLeft = left.value
if (offsetLeft + popoverWidth.value > screenWidth.value) {
offsetLeft -= popoverWidth.value - width.value
}
return {
left: offsetLeft + 'px',
top: offsetTop + 'px'
}
}
let offsetTop = top.value
if (offsetTop + popoverHeight.value > screenHeight.value) {
offsetTop -= popoverHeight.value - height.value
}
let offsetLeft = left.value + width.value
if (offsetLeft + popoverWidth.value > screenWidth.value) {
offsetLeft -= popoverWidth.value + width.value
}
return {
left: offsetLeft + 'px',
top: offsetTop + 'px'
}
})
// Popover close stack
let stack = inject(POPOVER_INJECTION_KEY, [ref(false)])
if (!stack) {
provide(POPOVER_INJECTION_KEY, stack = shallowReactive([]))
}
stack.push(isOpen)
onScopeDispose(() => {
stack?.splice(stack.indexOf(isOpen), 1)
})
// Provide context for child items
const hoveredItem = ref(-2)
provide(POPOVER_CONTEXT_INJECTION_KEY, {
items: ref(0),
hoveredItem
})
// Closing
const closeChild = () => {
const ref = stack?.[stack.indexOf(isOpen) + 1]
if (!ref) return
ref.value = false
}
// Recursively close popover tree
watch(isOpen, (isOpen) => {
if (isOpen) return
closeChild()
})
</script>
<template>
<div
ref="slot"
:class="['funkwhale popover-container', { 'split-button': inSlot?.classList?.contains('button-group') }]"
:style="inSlot?.classList?.contains('button-group') ? 'display: inline-flex' : 'display: contents'"
>
<slot
ref="inSlot"
:is-open="isOpen"
:toggle-open="() => isOpen = !isOpen"
:open="() => isOpen = true"
:close="() => isOpen = false"
/>
</div>
<teleport
v-if="isOpen"
to="body"
>
<div
:class="{ 'is-mobile': isMobile }"
class="funkwhale popover-outer"
@click.stop="mobileClickOutside"
>
<div
ref="popover"
:style="position"
:class="{ 'is-mobile': isMobile }"
class="funkwhale popover"
v-bind="color(colorProps)()"
style="display:flex; flex-direction:column;"
>
<slot name="items" />
</div>
</div>
</teleport>
</template>
<style lang="scss">
@import './popover.scss'
</style>

View File

@ -0,0 +1,12 @@
<script setup lang="ts">
const { as = 'div', html: rawHtml } = defineProps<{ as?:string, html:string }>()
</script>
<template>
<component
:is="as"
v-dompurify-html="rawHtml"
/>
</template>

View File

@ -0,0 +1,174 @@
<script setup lang="ts">
import type { ComponentProps } from 'vue-component-type-helpers'
import Layout from '~/components/ui/Layout.vue'
import Spacer from '~/components/ui/Spacer.vue'
import Button from '~/components/ui/Button.vue'
import Link from '~/components/ui/Link.vue'
import Heading from '~/components/ui/Heading.vue'
const actionComponents
= { Button, Link }
const props = defineProps<{
columnsPerItem?: 1 | 2 | 3 | 4
alignLeft?: boolean
action?: { text: string } & (ComponentProps<typeof Link> | ComponentProps<typeof Button>)
icon?: string
} & {
[H in `h${ '1' | '2' | '3' | '4' | '5' | '6' }`]? : string
} & {
[S in 'page-heading' | 'section-heading' | 'large-section-heading' | 'subsection-heading' | 'caption' | 'title' | 'radio' | 'secondary' ]? : true
} & {
[Operation in 'expand' | 'collapse']?: () => void
}>()
</script>
<template>
<section style="flex-grow: 1;">
<Layout
header
v-bind="columnsPerItem
? { grid: `auto / repeat(auto-fit, calc(46px * ${columnsPerItem} + 32px * ${(columnsPerItem) - 1}))` }
: { flex: true }
"
:class="[alignLeft && $style.left, expand || collapse ? $style.collapsible : $style.uncollapsible]"
>
<!-- The title row's width is a multiple of the expected items' column span -->
<Layout
flex
no-gap
style="
grid-column: 1 / -1;
align-self: baseline;
align-items: baseline;
position: relative;
flex-grow: 1;
"
>
<!-- Accordion? -->
<template v-if="expand || collapse">
<Button
full
align-text="start"
align-self="end"
:class="$style.summary"
:aria-pressed="!!collapse"
v-bind="action"
raised
@click="() => expand ? expand() : collapse ? collapse() : (() => { return })()"
>
<slot name="topleft" />
<!-- @vue-ignore -->
<Heading v-bind="props" />
</Button>
<i
:class="!!expand ? 'bi bi-chevron-down' : 'bi bi-chevron-up'"
style="
position: absolute;
top: 12px;
right: 0;
pointer-events: none;
"
/>
</template>
<!-- Normal (non-accordion)? -->
<template v-else>
<!-- Set distance between baseline and previous row -->
<Spacer
v
:size="64"
/>
<div
v-if="icon"
style="display: flex; justify-content: center; align-items: center; width: 48px;"
>
<i
:class="['bi', icon]"
style="font-size: 18px;"
/>
</div>
<slot name="topleft" />
<Heading
v-bind="props"
style="
padding: 0 0 24px 0;
margin: 0;
"
/>
<Spacer grow />
</template>
<!-- Action! You can either specify `to` or `onClick`. -->
<component
:is="'onClick' in action ? actionComponents.Button : actionComponents.Link"
v-if="action"
thin-font
min-content
align-self="baseline"
:class="$style.action"
v-bind="action"
>
{{ action?.text }}
</component>
</Layout>
</Layout>
<!-- Love: https://css-tricks.com/css-grid-can-do-auto-height-transitions/ -->
<Layout
main
:inert="!!expand"
:style="`${
'alignLeft' in props && props.alignLeft
? 'justify-content: start;'
: ''
}${
!!expand
? 'grid-template-rows: 0fr; overflow: hidden; max-height: 0;'
: 'max-height: 4000px;'
}${
!!collapse
? 'padding: 12px 0;'
: ''
}
position: relative;
transition: max-height .5s, grid-template-rows .3s, padding .2s;
`"
v-bind="columnsPerItem
? { grid: `auto / repeat(auto-fit, 46px)` }
: { flex: true }
"
>
<slot />
</Layout>
</section>
</template>
<style module lang="scss">
// Thank you, css, for offering this weird alternative to !important
header.left.left {
justify-content: start;
}
.uncollapsible {
margin-top: -64px;
}
.summary {
align-self: baseline;
min-width: calc(100% + 32px);
margin: 0 -16px;
--fw-border-radius: 32px;
}
// Visually push ghost link and non-solid button to the edge
.action:global(.interactive:not(:is(.primary, .solid, .destructive, .secondary)):is(button, a.ghost)) {
margin-right: -16px;
}
</style>

View File

@ -0,0 +1,211 @@
<script setup lang="ts" generic="T extends string">
import { ref, computed, onMounted } from 'vue'
import Layout from '~/components/ui/Layout.vue'
import Spacer from '~/components/ui/Spacer.vue'
import Markdown from '~/components/ui/Markdown.vue'
const props = defineProps<{
label?: string,
options: Record<T, string>,
autofocus?: true
}>()
const keys = computed(() => Object.keys(props.options) as T[])
const model = defineModel<T | undefined>({ required: true })
const index = computed({
get () {
return model.value
? keys.value.indexOf(model.value)
: undefined
},
set (newIndex) {
model.value = newIndex
? keys.value[newIndex]
: undefined
}
})
const input = ref()
onMounted(() => {
if (props.autofocus) input.value.focus()
})
</script>
<template>
<Layout
stack
no-gap
:class="$style.slider"
:style="`
--step-size: calc(100% / ${keys.length + 2});
--slider-width: calc(var(--step-size) * ${keys.length - 1} + 16px);
--slider-opacity: ${ index === undefined ? .5 : 1 };
--current-step: ${ index === undefined ? keys.length - 1 : index };
`"
>
<!-- Label -->
<label
v-if="$slots['label']"
:class="$style.label"
>
<slot name="label" />
</label>
<label
v-if="label"
:class="$style.label"
>
{{ label }}
</label>
<!-- List of options -->
<Layout
flex
no-gap
>
<button
v-for="key in keys"
:key="key"
:class="[$style.key, { [$style.current]: key === model } ]"
style="flex-basis: var(--step-size); padding-bottom: 8px;"
type="button"
tabindex="-1"
@click="() => { model = key; input.focus(); }"
>
{{ key }}
</button>
</Layout>
<!-- Slider -->
<span style="position: relative;">
<input
ref="input"
v-model="index"
type="range"
style="width: var(--slider-width); cursor: pointer;"
:max="keys.length - 1"
:autofocus="autofocus || undefined"
>
<div :class="$style.range" />
<div
v-if="model !== undefined"
:class="$style.pin"
/>
</span>
<Spacer size-8 />
<!-- Description of current option -->
<span style="position: relative;">
<span style="display: inline-flex; margin-right: -100%; width: 100%; visibility: hidden;">
<span
v-for="key in keys"
:key="key"
:class="$style.description"
:style="`margin-right: -20%; --current-step: 0; color: magenta;`"
>
<!-- For some reason, the linter complains that (Record<T, string>)[T] is not string... -->
<!-- TODO: https://dev.funkwhale.audio/funkwhale/funkwhale/-/issues/2437 -->
<!-- @vue-ignore -->
<Markdown :md="options[model]" />
</span>
</span>
<span
v-if="model !== undefined"
style="position: absolute;"
:class="$style.description"
>
<!-- For some reason, the linter complains that (Record<T, string>)[T] is not string... -->
<!-- TODO: https://dev.funkwhale.audio/funkwhale/funkwhale/-/issues/2437 -->
<!-- @vue-ignore -->
<Markdown :md="options[model]" />
</span>
</span>
</Layout>
</template>
<style module lang="scss">
.slider {
.label {
margin-top: -18px;
padding-bottom: 8px;
font-size: 14px;
font-weight: 600;
}
.key {
all: unset;
cursor: pointer;
opacity: .7;
&.current {
opacity: 1;
}
}
.description {
font-size: 12px;
font-weight: 600;
width: 11rem;
overflow: visible;
transition: margin .2s;
--inset: calc(var(--step-size) * var(--current-step));
margin-left: var(--inset);
margin-right: calc(0px - var(--inset));
p {
margin: 0;
line-height: 1.5em;
}
}
// Fake slider
.range {
width: calc(var(--step-size) * var(--current-step) + 4px);
position: absolute;
top: 6px;
left: 2.5px;
height: 8px;
border-radius: 8px;
background-color: var(--fw-primary);
transition: all .1s;
pointer-events: none;
opacity: var(--slider-opacity);
}
input[type=range]::-moz-range-thumb {
background-color: var(--fw-primary);
transition: all .1s;
pointer-events: none;
}
input[type="range"]::-moz-range-track {
border-radius: 8px;
@include light-theme {
background: var(--fw-gray-400);
}
}
.pin {
border-radius: 8px;
width: 16px;
height: 16px;
left: calc(var(--step-size) * var(--current-step));
background: var(--fw-primary);
position: absolute;
top: 2px;
margin-left: 2px;
transition: all .1s;
pointer-events: none;
}
input:focus~.pin,
input:hover~.pin {
outline: 1px solid currentColor;
}
&[disabled] .pin {
display: none;
}
&[disabled] * {
pointer-events: none;
}
}
</style>

View File

@ -0,0 +1,72 @@
<script setup lang="ts">
import { ref, watchEffect } from 'vue'
type Size = 'no-size' | `size-${'4' | '8' | '12' | '16' | '32' | '46' | '64'}`
const props = defineProps<{
grow?:true;
shrink?:true;
title?:string;
} & { [Direction in 'h' | 'v']? : true }
&({ [S in Size]? : true } | {size?:number})>()
const minSize = 0
const measure = ref()
watchEffect(() => {
const maybeSize = Object.entries(props).find(
([key, value]) => value === true && key.startsWith('size'))
const size
= maybeSize
? +(maybeSize[0].replace('size', ''))
: 'size' in props && props.size
? props.size
: 'noSize' in props && props.noSize
? 0
: 32
measure.value = {
size: `${Math.max(size, minSize)}px`,
margin: `${(size - Math.max(size, minSize)) / 2}px`
}
})
</script>
<template>
<div :class="[$style.spacer, grow && 'grow', title && $style['has-title']]">
<slot />
</div>
</template>
<style module lang="scss">
.spacer {
width: v-bind('props.v ? 0 : measure.size');
height: v-bind('props.h ? 0 : measure.size');
margin: v-bind('measure.margin');
flex-grow: v-bind('grow ? 1 : 0');
flex-shrink: v-bind('shrink ? 1 : 0');
transition: flex-grow .2s, flex-shrink .2s;
position: relative;
&.has-title::after {
position: absolute;
inset: calc(50% - 1em);
content: v-bind('`"${title}"`')
}
@if $docs {
animation: blink .7s 1;
@keyframes blink { 50% {
outline: 2px dashed var(--fw-secondary);
outline-offset: v-bind('measure.margin');
} }
&:hover {
animation-iteration-count: infinite;
}
}
@else {
pointer-events: none;
}
}
</style>

View File

@ -0,0 +1,25 @@
<script setup lang="ts">
import { type TabProps, TABS_INJECTION_KEY } from '~/injection-keys'
import { whenever } from '@vueuse/core'
import { inject, ref } from 'vue'
const props = defineProps<TabProps>()
const { currentTitle, tabs } = inject(TABS_INJECTION_KEY, {
currentTitle: ref(props.title),
tabs: []
})
whenever(() => !tabs.some(tab => props.title === tab.title), () => {
tabs.push(props)
}, { immediate: true })
</script>
<template>
<div
v-if="currentTitle === title"
class="tab-content"
>
<slot />
</div>
</template>

View File

@ -0,0 +1,67 @@
<script setup lang="ts" generic="T extends string">
defineProps<{
gridTemplateColumns:(`${number}${'px' | 'fr'}` | 'auto')[]
headerProps?: { [key: string]: unknown }
}>()
</script>
<template>
<section
:class="$style.table"
:style="`grid-template-columns: ${gridTemplateColumns.join(' ')};`"
>
<span
:class="$style['table-header']"
v-bind="headerProps"
>
<slot name="header" />
</span>
<slot />
</section>
</template>
<style module>
.table {
width: 100%; align-self: stretch; display: grid;
}
/* Table cells */
.table > * {
height: 64px;
display: flex;
align-items: center;
}
/* Table header */
.table-header {
display: contents;
}
/* Table header cells */
.table-header > *{
height: 40px;
display: flex;
align-items: center;
line-height: 36px;
border: 0px solid var(--border-color);
border-width: 1px 0;
color: color-mix(in oklab, currentcolor 50%, var(--border-color));
font-weight: 900;
grid-column: span 1;
}
/* Auto-expand cells if following cells are empty */
.table > :has(+ :empty) {
grid-column-end: span 2;
}
.table > :has(+ :empty + :empty) {
grid-column-end: span 3;
}
.table > :has(+ :empty + :empty + :empty) {
grid-column-end: span 4;
}
/* Hide empty content (after the header row) */
.table > .table-header ~ :empty {
display: none;
}
</style>

View File

@ -0,0 +1,67 @@
<script setup lang="ts">
import { type TabProps, TABS_INJECTION_KEY } from '~/injection-keys'
import { computed, provide, reactive, ref, watch } from 'vue'
import Button from '~/components/ui/Button.vue'
import Link from '~/components/ui/Link.vue'
import { useRoute } from 'vue-router'
const currentTitle = ref<TabProps['title']>('')
const tabs = reactive<TabProps[]>([])
const currentRoute = useRoute()
provide(TABS_INJECTION_KEY, {
currentTitle,
tabs
})
/* Note that this only compares the name. Make sure to add a `name` field to identify paths in your router config! */
const actualCurrentTitle = computed(() =>
tabs.find(({ to }) => to && typeof to !== 'string' && 'name' in to && currentRoute.name === to?.name)?.title
|| currentTitle.value)
const currentIndex = computed(() =>
tabs.findIndex(({ title }) => title === actualCurrentTitle.value)
)
// select first tab
watch(tabs, () => {
if (tabs.length === 1) {
currentTitle.value = tabs[0].title
}
})
</script>
<template>
<div class="funkwhale tabs">
<div class="tabs-header">
<component
:is="tab.to ? Link : Button"
v-for="tab in tabs"
:key="tab.title"
ghost
:class="{ 'is-active': actualCurrentTitle === tab.title }"
v-bind="tab"
:on-click="'to' in tab ? undefined : () => { currentTitle = tab.title }"
class="tabs-item"
@keydown.left="currentTitle = tabs[(currentIndex - 1 + tabs.length) % tabs.length].title"
@keydown.right="currentTitle = tabs[(currentIndex + 1) % tabs.length].title"
>
<div class="is-spacing">
{{ tab.title }}
</div>
<label>{{ tab.title }}</label>
</component>
<div class="tabs-right">
<slot name="tabs-right" />
</div>
</div>
<slot />
</div>
</template>
<style lang="scss">
@import './tabs.scss'
</style>

View File

@ -0,0 +1,366 @@
<script setup lang="ts">
import { nextTick, computed, ref, type ComputedRef, onMounted } from 'vue'
import { useTextareaAutosize, computedWithControl, useManualRefHistory, watchDebounced } from '@vueuse/core'
import { useI18n } from "vue-i18n"
import Button from './Button.vue'
import Spacer from './Spacer.vue'
import Markdown from './Markdown.vue'
import Layout from './Layout.vue'
const { t } = useI18n()
const { charLimit = Infinity, placeholder = '', initialLines: minLines = 3, ...props } = defineProps<{
label?: string,
placeholder?: string,
charLimit?: number,
initialLines?: number | string,
autofocus?: true,
required?: true
}>()
const model = defineModel<string>({ required: true })
const { undo, redo, commit: commitHistory, last } = useManualRefHistory(model)
const textarea = ref()
const { triggerResize } = useTextareaAutosize({ input: model, element: textarea })
const commit = () => {
triggerResize()
commitHistory()
}
const preview = ref(false)
watchDebounced(model, (value) => {
if (value !== last.value.snapshot) {
commit()
}
}, { debounce: 300 })
const lineNumber = computedWithControl(
() => [textarea.value, model],
() => {
const { selectionStart } = textarea.value ?? {}
return model.value.slice(0, selectionStart).split('\n').length - 1
}
)
const updateLineNumber = () => setTimeout(lineNumber.trigger, 0)
const currentLine = computed({
get: () => (model.value.split('\n')[model.value.split('\n').length > lineNumber.value ? lineNumber.value : 0]),
set: (line) => {
const content = model.value.split('\n')
content[lineNumber.value] = line
model.value = content.join('\n')
}
})
// Textarea manipulation
const splice = async (start: number, deleteCount: number, items?: string) => {
let { selectionStart, selectionEnd } = textarea.value
const lineBeginning = model.value.slice(0, selectionStart).lastIndexOf('\n') + 1
let lineStart = selectionStart - lineBeginning
let lineEnd = selectionEnd - lineBeginning
const text = currentLine.value.split('')
text.splice(start, deleteCount, items ?? '')
currentLine.value = text.join('')
if (start <= lineStart) {
lineStart += items?.length ?? 0
lineStart -= deleteCount
}
if (start <= lineEnd) {
lineEnd += items?.length ?? 0
lineEnd -= deleteCount
}
selectionStart = lineBeginning + Math.max(0, lineStart)
selectionEnd = lineBeginning + Math.max(0, lineEnd)
textarea.value.focus()
await nextTick()
textarea.value.setSelectionRange(selectionStart, selectionEnd)
}
const newLineOperations = new Map<RegExp, ((event: KeyboardEvent, line: string, groups: string[]) => void)>()
const newline = async (event: KeyboardEvent) => {
const line = currentLine.value
for (const regexp of newLineOperations.keys()) {
const matches = line.match(regexp) ?? []
if (matches.length > 0) {
newLineOperations.get(regexp)?.(event, line, matches.slice(1))
}
}
}
// Conditions
const isHeading1 = computed(() => currentLine.value.startsWith('# '))
const isHeading2 = computed(() => currentLine.value.startsWith('## '))
const isQuote = computed(() => currentLine.value.startsWith('> '))
const isUnorderedList = computed(() => currentLine.value.startsWith('- ') || currentLine.value.startsWith('* '))
const isOrderedList = computed(() => /^\d+\. /.test(currentLine.value))
const isParagraph = computed(() => !isHeading1.value && !isHeading2.value && !isQuote.value && !isUnorderedList.value && !isOrderedList.value)
// Prefix operations
const paragraph = async (shouldCommit = true) => {
if (isHeading1.value || isQuote.value || isUnorderedList.value) {
await splice(0, 2)
if (shouldCommit) commit()
return
}
if (isHeading2.value || isOrderedList.value) {
await splice(0, 3)
if (shouldCommit) commit()
}
}
const prefixOperation = (prefix: string, condition?: ComputedRef<boolean>) => async () => {
if (condition?.value) {
return paragraph()
}
await paragraph(false)
await splice(0, 0, prefix)
return commit()
}
const heading1 = prefixOperation('# ', isHeading1)
const heading2 = prefixOperation('## ', isHeading2)
const quote = prefixOperation('> ', isQuote)
const orderedList = prefixOperation('1. ', isOrderedList)
const unorderedList = prefixOperation('- ', isUnorderedList)
// Newline operations
const newlineOperation = (regexp: RegExp, newLineHandler: (line: string, groups: string[]) => Promise<void> | void) => {
newLineOperations.set(regexp, async (event, line, groups) => {
event.preventDefault()
if (new RegExp(regexp.toString().slice(1, -1) + '$').test(line)) {
return paragraph()
}
await newLineHandler(line, groups)
lineNumber.trigger()
return commit()
})
}
newlineOperation(/^(\d+)\. /, (line, [lastNumber]) => splice(line.length, 0, `\n${+lastNumber + 1}. `))
newlineOperation(/^- /, (line) => splice(line.length, 0, '\n- '))
newlineOperation(/^> /, (line) => splice(line.length, 0, '\n> '))
newlineOperation(/^\* /, (line) => splice(line.length, 0, '\n* '))
// Inline operations
const inlineOperation = (chars: string) => async () => {
const { selectionStart, selectionEnd } = textarea.value
const lineBeginning = model.value.slice(0, selectionStart).lastIndexOf('\n') + 1
await splice(selectionStart - lineBeginning, 0, chars)
await splice(selectionEnd - lineBeginning + chars.length, 0, chars)
const start = selectionStart === selectionEnd
? selectionStart + chars.length
: selectionEnd + chars.length * 2
textarea.value.setSelectionRange(start, start)
return commit()
}
const bold = inlineOperation('**')
const italics = inlineOperation('_')
const strikethrough = inlineOperation('~~')
const link = async () => {
const { selectionStart, selectionEnd } = textarea.value
const lineBeginning = model.value.slice(0, selectionStart).lastIndexOf('\n') + 1
await splice(selectionStart - lineBeginning, 0, '[')
await splice(selectionEnd - lineBeginning + 1, 0, '](url)')
textarea.value.setSelectionRange(selectionEnd + 3, selectionEnd + 6)
return commit()
}
// Fix focus
const focus = () => textarea.value.focus()
onMounted(() => {
if (props.autofocus) focus()
})
</script>
<template>
<Layout
stack
gap-8
label
class="funkwhale textarea-label"
>
<span
v-if="$slots['label']"
class="label"
>
<slot name="label" />
</span>
<span
v-if="props.label"
class="label"
>
{{ props.label }}
</span>
<div
:class="{ 'has-preview': preview }"
class="funkwhale textarea"
@mousedown.prevent="focus"
@mouseup.prevent="focus"
>
<Markdown
:md="model"
class="preview"
/>
<textarea
v-bind="$attrs"
id="textarea_id"
ref="textarea"
v-model="model"
:maxlength="charLimit"
:autofocus="autofocus || undefined"
:required="required"
:placeholder="placeholder"
:rows="initialLines"
:style="`min-height:${((typeof(initialLines) === 'string' ? parseInt(initialLines) : (initialLines ?? 3)) + 1.2) * 1.5}rem`"
@click="updateLineNumber"
@mousedown.stop
@mouseup.stop
@keydown.left="updateLineNumber"
@keydown.right="updateLineNumber"
@keydown.up="updateLineNumber"
@keydown.down="updateLineNumber"
@keydown.enter="newline"
@keydown.ctrl.shift.z.exact.prevent="redo"
@keydown.ctrl.z.exact.prevent="undo"
@keydown.ctrl.b.exact.prevent="bold"
@keydown.ctrl.i.exact.prevent="italics"
@keydown.ctrl.shift.x.exact.prevent="strikethrough"
@keydown.ctrl.k.exact.prevent="link"
/>
<label
class="textarea-buttons"
:for="preview ? 'expanded-preview-button' : 'nothing'"
>
<Button
secondary
square-small
icon="bi-paragraph"
:aria-pressed="isParagraph || undefined"
:disabled="preview"
@click="paragraph"
/>
<Button
secondary
square-small
icon="bi-type-h1"
:aria-pressed="isHeading1 || undefined"
:disabled="preview"
@click="heading1"
/>
<Button
secondary
square-small
icon="bi-type-h2"
:aria-pressed="isHeading2 || undefined"
:disabled="preview"
@click="heading2"
/>
<Button
secondary
square-small
icon="bi-quote"
:aria-pressed="isQuote || undefined"
:disabled="preview"
@click="quote"
/>
<Button
secondary
square-small
icon="bi-list-ol"
:aria-pressed="isOrderedList || undefined"
:disabled="preview"
@click="orderedList"
/>
<Button
secondary
square-small
icon="bi-list-ul"
:aria-pressed="isUnorderedList || undefined"
:disabled="preview"
@click="unorderedList"
/>
<Spacer />
<Button
secondary
square-small
icon="bi-type-bold"
:disabled="preview"
@click="bold"
/>
<Button
secondary
square-small
icon="bi-type-italic"
:disabled="preview"
@click="italics"
/>
<Button
secondary
square-small
icon="bi-type-strikethrough"
:disabled="preview"
@click="strikethrough"
/>
<Button
secondary
square-small
icon="bi-link-45deg"
:disabled="preview"
@click="link"
/>
<span
v-if="charLimit !== Infinity && typeof charLimit === 'number'"
class="letter-count"
>{{ charLimit - model.length }}</span>
<Spacer />
<Spacer
v-if="!$slots.default"
h
grow
/>
<slot />
<Button
id="expanded-preview-button"
secondary
low-height
min-content
icon="bi-eye"
:aria-pressed="preview || undefined"
@click="preview = !preview"
>
{{ t('components.common.ContentForm.button.preview') }}
</Button>
</label>
</div>
</Layout>
</template>
<style lang="scss">
@import './textarea.scss';
</style>

View File

@ -0,0 +1,59 @@
<script setup lang="ts">
import { computed, ref, watchEffect } from 'vue'
import { slugify } from 'transliteration'
import { useScroll } from '@vueuse/core'
import Button from '~/components/ui/Button.vue'
const { heading = 'h1' } = defineProps<{heading?:'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'}>()
const toc = ref()
const headings = computed(() => toc.value?.querySelectorAll(heading) ?? [])
watchEffect(() => {
for (const heading of headings.value) {
heading.id = slugify(heading.textContent)
}
})
const activeLink = ref()
const { y } = useScroll(window)
watchEffect(() => {
let lastActive = headings.value[0]
for (const heading of headings.value) {
if (y.value > heading.offsetTop) {
lastActive = heading
}
}
activeLink.value = lastActive?.id
})
</script>
<template>
<div
ref="toc"
class="funkwhale toc"
>
<div class="toc-content">
<slot />
</div>
<div class="toc-toc">
<div class="toc-links">
<Button
v-for="h of headings"
:key="h.id"
:class="{ 'is-active': activeLink === h.id }"
@click.prevent="h.scrollIntoView({ behavior: 'smooth' })"
>
{{ h.textContent }}
</Button>
</div>
</div>
</div>
</template>
<style lang="scss">
@import './toc.scss'
</style>

View File

@ -0,0 +1,86 @@
<script setup lang="ts">
import { color } from '~/composables/color'
const { big } = defineProps<{
big?: boolean
label?: string
}>()
const isOn = defineModel<boolean>()
const diameter = big ? '28px' : '20px'
</script>
<template>
<label
:class="[$style.toggle, 'funkwhale']"
v-bind="color({}, ['interactive', 'raised'])"
:checked="isOn || undefined"
>
<input
v-model="isOn"
type="checkbox"
style="opacity: 0; /* Hide even before stylesheet is loaded */"
>
<span v-if="label">{{ label }}</span>
</label>
</template>
<style module lang="scss">
.toggle {
font-size: 14px;
font-weight: 600;
line-height: 1em;
position: relative;
padding: calc(var(--padding) - 1px) 0 calc(var(--padding) + 1px) 0;
min-width: calc(var(--diameter) * 2);
height: min-content;
--diameter: v-bind(diameter);
--lineWidth: 2px;
--padding: 10px;
--void-color: var(--void-off-background-color);
--pin-color: var(--void-off-pin-color);
&[checked] {
--void-color: var(--void-on-background-color);
--pin-color: var(--void-on-pin-color);
&::after{
transform:translateX(var(--diameter));
}
}
&:hover, &:has(:focus-visible) {
--void-color: var(--void-off-hover-background-color);
--pin-color: var(--void-off-hover-pin-color);
&[checked] {
--void-color: var(--void-on-hover-background-color);
--pin-color: var(--void-on-hover-pin-color);
}
}
&::before, &::after {
content: '';
position: absolute;
border-radius: var(--diameter);
}
&::before {
height: var(--diameter);
aspect-ratio: 2;
background-color: var(--void-color);
left: 0;
top: calc(var(--padding) * 2 - var(--diameter) / 2);
}
&::after {
height: calc(var(--diameter) - var(--lineWidth) * 2);
aspect-ratio: 1;
background-color: var(--pin-color);
left: var(--lineWidth);
top: calc(var(--padding) * 2 - var(--diameter) / 2 + var(--lineWidth));
transition: all .2s;
}
> span {
padding-left: calc(var(--diameter) * 2 - 12px);
}
}
</style>

View File

@ -0,0 +1,127 @@
.funkwhale {
&.activity {
padding: 0 16px;
height: 72px;
display: flex;
align-items: center;
border-top: 1px solid;
cursor: pointer;
display: grid;
grid-template-columns: auto 1fr auto;
gap: 12px;
grid-column: span 4;
&.clickable {
cursor: pointer;
}
@include light-theme {
color: var(--fw-gray-500);
+ .funkwhale.activity {
border-top: 1px solid var(--fw-gray-300);
}
> .activity-content {
> .track-title {
color: var(--fw-gray-900);
}
> .artist {
--fw-link-color: var(--fw-gray-900);
}
> .user {
--fw-link-color: var(--fw-gray-500);
}
}
.play-button {
background: rgba(255, 255, 255, .5);
&:hover {
--fw-text-color: var(--fw-gray-800) !important;
}
}
}
@include dark-theme {
+ .funkwhale.activity {
border-top: 1px solid var(--fw-gray-800);
}
> .activity-content {
> .track-title {
color: var(--fw-gray-300);
}
> .artist {
--fw-link-color: var(--fw-gray-300);
}
> .user {
--fw-link-color: var(--fw-gray-500);
}
}
.play-button {
background: rgba(0, 0, 0, .2);
&:hover {
background: rgba(0, 0, 0, .8);
--fw-text-color: var(--fw-gray-200) !important;
}
}
}
> .activity-image {
position: relative;
width: 40px;
aspect-ratio: 1;
overflow: hidden;
border-radius: var(--fw-border-radius);
> img {
width: 100%;
aspect-ratio: 1;
object-fit: cover;
}
> .play-button {
position: absolute;
top: 0;
left: 0;
padding: 0 !important;
width: 100%;
aspect-ratio: 1;
margin: 0;
border: 0 !important;
opacity: 0;
}
}
&:hover {
.play-button {
opacity: 1;
}
}
> .activity-content {
display: flex;
flex-direction: column;
align-items: flex-start;
> .track-title {
font-weight: 700;
line-height: 1.5em;
}
> .artist {
line-height: 1.5em;
font-size: 0.857rem;
}
> .user {
line-height: 1.5em;
font-size: 0.8125rem;
}
}
}
}

View File

@ -0,0 +1,38 @@
.funkwhale.alert {
padding: 2rem 2rem;
line-height: 1.2;
display: flex;
flex-direction: column;
h2, h3, h4 {
margin-top: 0;
margin-bottom: 0;
}
> .actions {
margin-left: auto;
}
// Add styles for when alert is used as a notification
&.is-notification {
position: fixed;
bottom: 1rem;
right: 1rem;
z-index: 1000;
min-width: 200px;
max-width: 400px;
background-color: var(--background-color);
&.fade-enter-active,
&.fade-leave-active {
transition: all 0.3s ease;
}
&.fade-enter-from,
&.fade-leave-to {
opacity: 0;
transform: translateY(1rem);
}
}
}

View File

@ -0,0 +1,157 @@
.funkwhale {
&.button {
background-color: var(--fw-bg-color);
color: var(--fw-text-color);
border: 1px solid var(--fw-bg-color);
@include light-theme {
&.is-secondary.is-outline {
--fw-bg-color: var(--fw-gray-600);
--fw-text-color: var(--fw-gray-700);
&[disabled] {
--fw-bg-color: var(--fw-gray-600) !important;
--fw-text-color: var(--fw-gray-600) !important;
}
&.is-hovered,
&:hover {
--fw-bg-color: var(--fw-gray-700) !important;
--fw-text-color: var(--fw-gray-800) !important;
}
&.is-active,
&:active {
--fw-text-color: var(--fw-red-010) !important;
border: 1px solid var(--fw-gray-600) !important;
}
}
&.is-outline {
&:not(:active):not(.is-active) {
background-color: transparent !important;
--fw-text-color:--fw-gray-400;
}
}
&.is-ghost {
&:not(:active):not(.is-active):not(:hover):not(.is-hovered) {
background-color: transparent !important;
border-color: transparent !important;
--fw-text-color:--fw-gray-400;
}
}
}
@include dark-theme {
&.is-secondary.is-outline {
--fw-bg-color: var(--fw-gray-500);
--fw-text-color: var(--fw-gray-400);
&[disabled] {
--fw-bg-color: var(--fw-gray-600) !important;
--fw-text-color: var(--fw-gray-700) !important;
}
&.is-hovered,
&:hover {
--fw-bg-color: var(--fw-gray-600) !important;
--fw-text-color: var(--fw-gray-500) !important;
}
&.is-active,
&:active {
--fw-text-color: var(--fw-red-010) !important;
--fw-bg-color: var(--fw-gray-700) !important;
border: 1px solid var(--fw-gray-600) !important;
}
}
&.is-outline {
&:not(:active):not(.is-active) {
background-color: transparent !important;
}
}
&.is-ghost {
&:not(:active):not(.is-active):not(:hover):not(.is-hovered) {
background-color: transparent !important;
border-color: transparent !important;
}
}
}
@if $docs {
color: var(--fw-text-color) !important;
}
position: relative;
display: inline-flex;
align-items: center;
white-space: nowrap;
font-family: $font-main;
font-weight: 900;
font-size: 0.875em;
line-height: 1em;
padding: 0.642857142857em;
border-radius: var(--fw-border-radius);
margin: 0 0.5ch;
transform: translateX(var(--fw-translate-x)) translateY(var(--fw-translate-y)) scale(var(--fw-scale));
transition: all .2s ease;
&.is-aligned-center {
justify-content: center;
}
&.is-aligned-left {
justify-content: flex-start;
}
&.is-aligned-right {
justify-content: flex-end;
}
&.is-shadow {
box-shadow: 0px 2px 4px 0px rgba(0, 0, 0, 0.2);
}
&:not(.icon-only):not(.is-auto) {
min-width: 8.5rem;
}
&.is-full {
display: block;
}
&.is-round {
border-radius: 100vh;
}
&[disabled] {
font-weight: normal;
pointer-events: none;
}
&.is-loading {
@extend .is-active;
> span {
opacity: 0;
}
}
i.bi {
font-size: 1.2rem;
}
i.bi + span:not(:empty) {
margin-left: 1ch;
}
}
}

View File

@ -0,0 +1,25 @@
<script setup lang="ts">
import Button from '../Button.vue'
defineProps<{
isSquare?: boolean
isGhost?: boolean
isSquareSmall?: boolean
}>()
</script>
<template>
<Button
icon="bi-three-dots-vertical"
v-bind="$attrs"
:class="['options-button', {'is-ghost': isGhost}]"
:secondary="!isGhost"
:ghost="isGhost"
:round="!isSquare && !isSquareSmall"
:square-small="isSquareSmall"
/>
</template>
<style lang="scss">
@import './options.scss'
</style>

View File

@ -0,0 +1,19 @@
<script setup lang="ts">
import Button from '../Button.vue'
const play = defineEmits(['play'])
</script>
<template>
<Button
icon="bi-play-fill"
class="play-button"
shadow
round
@click="$emit('play')"
/>
</template>
<style lang="scss">
@import './play.scss';
</style>

View File

@ -0,0 +1,13 @@
.funkwhale {
&.options-button {
will-change: transform;
transition: all .2s ease;
font-size: 0.6rem !important;
padding: 0.6em;
&.is-ghost {
position: absolute;
bottom: 0px;
right: 0px;
}
}
}

View File

@ -0,0 +1,40 @@
.funkwhale {
&.play-button {
@include light-theme {
--fw-bg-color: var(--fw-red-010) !important;
--fw-text-color: var(--fw-gray-600) !important;
&:hover {
--fw-text-color: var(--fw-pastel-4, var(--fw-primary)) !important;
}
}
@include dark-theme {
--fw-bg-color: var(--fw-gray-800) !important;
--fw-text-color: var(--fw-gray-300) !important;
&:hover {
--fw-text-color: var(--fw-pastel-2, var(--fw-blue-400)) !important;
}
}
will-change: transform;
font-size: 0.6rem !important;
padding: 0.625em !important;
border: 0px !important;
i {
font-size: 2rem;
&::before {
transform: translateX(1px);
backface-visibility: hidden;
}
}
&:hover {
--fw-scale: 1.091;
}
}
}

View File

@ -0,0 +1,131 @@
.funkwhale.input {
position: relative;
flex-grow: 1;
--padding-v: 9px;
--padding: 16px;
> input {
border: none !important;
width: 100%;
padding: 10px 16px;
font-size: 14px;
font-family: $font-main;
line-height: 28px;
border-radius: var(--fw-border-radius);
cursor: text;
@include light-theme {
&.raised {
background-color: #ffffff;
border-color: var(--border-color);
}
}
&:hover {
box-shadow: inset 0 0 0 4px var(--border-color);
}
&:focus {
box-shadow: inset 0 0 0 4px var(--focus-ring-color);
&:focus-visible {
outline: none;
}
}
&::placeholder {
color: var(--fw-placeholder-color);
}
}
&.has-icon > input {
padding-left: 36px;
}
> .label {
margin-top: -18px;
padding-bottom: 8px;
font-size:14px;
font-weight:600;
}
> .prefix,
> .input-right {
align-items: center;
font-size: 14px;
color: var(--fw-placeholder-color);
}
> .prefix {
position: absolute;
left: 0;
bottom: 0;
height: 100%;
min-width: 48px;
display: flex;
> i {
font-size:18px;
margin: auto;
}
}
&:has(>.prefix)>input {
padding-left: 40px;
}
> .input-right {
position: absolute;
right: 0px;
bottom: 0px;
height: 100%;
min-width: 48px;
display: flex;
> i {
font-size:18px;
}
> .span-right {
padding: calc(var(--padding-v) - 1px) var(--padding) calc(var(--padding-v) + 1px) var(--padding);
> .button {
margin-right: -16px;
margin-top: 2px;
border-bottom-left-radius: 0px;
border-top-left-radius: 0px;
}
}
}
> .search {
> i {
font-size:18px;
}
&.button {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
}
&:has(>.search)>input {
padding-right: 140px;
}
> .show-password {
justify-content:center;
}
&:has(>.show-password)>input {
padding-right: 40px;
}
>.reset {
min-width: auto;
margin: 4px;
// Make button fit snuggly into rounded border
border-radius: 4px;
}
}

View File

@ -0,0 +1,66 @@
// Modified version of https://github.com/lukehaas/css-loaders
.funkwhale {
&.loader-container {
position: relative;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}
.loader,
.loader:after {
border-radius: 50%;
width: 1.5em;
height: 1.5em;
}
.loader {
font-size: 1.2em;
position: absolute;
left: 50%;
top: 50%;
margin-left: -0.75em;
margin-top: -0.75em;
text-indent: -9999em;
@include light-theme {
border-top: 0.2em solid rgba(0, 0, 0, 0.2);
border-right: 0.2em solid rgba(0, 0, 0, 0.2);
border-bottom: 0.2em solid rgba(0, 0, 0, 0.2);
border-left: 0.2em solid rgba(0, 0, 0, 0.7);
}
@include dark-theme {
border-top: 0.2em solid rgba(255, 255, 255, 0.2);
border-right: 0.2em solid rgba(255, 255, 255, 0.2);
border-bottom: 0.2em solid rgba(255, 255, 255, 0.2);
border-left: 0.2em solid rgba(255, 255, 255, 0.7);
}
-webkit-transform: translateZ(0);
-ms-transform: translateZ(0);
transform: translateZ(0);
-webkit-animation: load8 .5s infinite linear;
animation: load8 .5s infinite linear;
}
@-webkit-keyframes load8 {
0% {
-webkit-transform: rotate(0deg);
transform: rotate(0deg);
}
100% {
-webkit-transform: rotate(360deg);
transform: rotate(360deg);
}
}
@keyframes load8 {
0% {
-webkit-transform: rotate(0deg);
transform: rotate(0deg);
}
100% {
-webkit-transform: rotate(360deg);
transform: rotate(360deg);
}
}
}

View File

@ -0,0 +1,149 @@
.funkwhale.modal {
background: var(--background-color);
box-shadow: 0 2px 4px 2px rgba(#000, 0.2);
border-radius: 1rem;
max-width: min(90vw, 55rem);
width: 100%;
display: grid;
max-height: 90vh;
grid-template-rows: auto 1fr auto;
position: relative;
&.is-destructive {
border-top: 24px solid var(--fw-red-400);
> h2 {
&.destructive-header {
color: var(--fw-red-400);
}
}
}
> h2 {
font-size: 1.25em;
padding: 1.625rem 4.5rem;
line-height: 1.2;
text-align: center;
position: relative;
> .funkwhale.button {
font-size: 1rem;
position: absolute;
right: 1rem;
top: 50%;
transform: translateY(-49%);
}
}
.modal-content {
padding: 1rem 2rem 3rem 2rem;
overflow: auto;
position: relative;
> .alert-container {
position: sticky;
top: -1rem;
margin: -1rem -2rem 1rem;
display: grid;
grid-template-rows: 1fr;
&.v-enter-active,
&.v-leave-active {
transition: grid-template-rows 0.2s ease;
}
&.v-enter-from,
&.v-leave-to {
grid-template-rows: 0fr;
}
> div {
overflow: hidden;
}
}
}
.modal-shadow-top {
z-index: 2;
height: 16px;
width: 100%;
position: absolute;
top: 60px;
background:linear-gradient(var(--background-color), color-mix(in oklab, var(--background-color) 50%, transparent), color-mix(in oklab, var(--background-color) 20%, transparent), transparent);
}
.modal-shadow-bottom {
height: 32px;
margin-top: -32px;
position: sticky;
bottom: 0px;
background:linear-gradient(transparent, color-mix(in oklab, var(--background-color) 20%, transparent), color-mix(in oklab, var(--background-color) 50%, transparent), var(--background-color));
&:last-child{
border-bottom-right-radius: 1rem;
border-bottom-left-radius: 1rem;
}
}
.modal-actions {
padding: 0 2rem 1rem 2rem;
display: flex;
justify-content: space-between;
align-items: flex-start;
> :first-child {
margin-left: 0px !important;
}
> :last-child {
margin-right: 0px !important;
}
position: relative;
}
}
.funkwhale.overlay:has(.over-popover) {
// override z-index
z-index: 999999;
}
.funkwhale.overlay {
background: rgba(#000, .2);
position: fixed;
inset: 0;
z-index: 9001;
display: flex;
align-items: center;
justify-content: center;
&.v-enter-active,
&.v-leave-active {
transition: opacity 0.2s ease;
.funkwhale.modal {
transition: transform 0.2s ease;
}
}
&.v-enter-from,
&.v-leave-to {
opacity: 0;
.funkwhale.modal {
transform: translateY(1rem);
}
}
&.v-leave-to {
.funkwhale.modal {
transform: translateY(-1rem);
}
}
}

View File

@ -0,0 +1,78 @@
.funkwhale {
&.pagination {
@include light-theme {
> .goto {
border-left: 1px solid var(--fw-gray-200);
}
}
@include dark-theme {
> .goto {
border-left: 1px solid var(--fw-gray-800);
}
}
height: 34px;
display: flex;
&.is-small {
> .pages {
width: auto;
}
> .goto {
margin-right: auto;
}
}
> ul.pages {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
list-style: none;
margin: 0;
padding: 0;
> li {
margin-top: 0;
margin: 0 0.5ch;
text-align: center;
&:not(:first-child):not(:last-child) {
width: 34px;
}
> .funkwhale.button {
min-width: 34px;
margin: 0;
}
&:first-child > .funkwhale.button,
&:last-child > .funkwhale.button {
min-width: 94px;
text-align: center;
}
}
}
> .goto {
margin-left: 16px;
padding-left: 16px;
display: flex;
align-items: center;
white-space: nowrap;
> .funkwhale.input {
margin-left: 16px;
width: calc(3ch + 32px);
input {
text-align: center;
padding: 6px 8px;
}
}
}
}
}

View File

@ -0,0 +1,68 @@
.funkwhale.popover-container {
width: max-content;
&.split-button {
display: inline-flex;
gap: 0;
}
}
.funkwhale.popover-outer {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 99999;
&:not(.is-mobile) {
pointer-events: none;
}
}
.funkwhale {
&.popover {
border: 1px solid var(--fw-border-color);
background-color: color-mix(in oklab, var(--background-color) 98%, var(--color));
hr {
border-bottom: 1px solid var(--fw-border-color);
}
@include light-theme {
--fw-border-color: var(--fw-gray-500);
}
@include dark-theme {
--fw-border-color: var(--fw-gray-800);
.popover-item:hover {
background-color: var(--hover-background-color);
}
}
pointer-events: auto;
&.is-mobile {
width: 90vw;
margin: 0 5vw;
left: 0 !important;
box-shadow: 0 0 0 1000vh rgba(0,0,0,0.2),
0 0 100vh rgba(0,0,0,0.6);
}
position: absolute;
padding: 16px;
border-radius: var(--fw-border-radius);
min-width: 246px;
z-index: 999;
font-size: 0.875rem;
hr {
padding-top: 12px;
margin-bottom: 12px;
}
}
}

View File

@ -0,0 +1,19 @@
<script setup lang="ts">
import PopoverItem from './PopoverItem.vue'
const value = defineModel<boolean>()
</script>
<template>
<PopoverItem
class="checkbox"
@click="value = !value"
>
<i :class="['bi', value ? 'bi-check-square' : 'bi-square']" />
<slot />
<template #after>
<slot name="after" />
</template>
</PopoverItem>
</template>

View File

@ -0,0 +1,139 @@
<script setup lang="ts">
import { inject, ref } from 'vue'
import { type RouterLinkProps, RouterLink } from 'vue-router'
import { POPOVER_CONTEXT_INJECTION_KEY, type PopoverContext } from '~/injection-keys'
import Button from '~/components/ui/Button.vue'
const emit = defineEmits<{ setId: [value: number] }>()
const { parentPopoverContext, to } = defineProps<{
parentPopoverContext?: PopoverContext;
to?:RouterLinkProps['to'];
icon?: string;
}>()
const { items, hoveredItem } = parentPopoverContext ?? inject(POPOVER_CONTEXT_INJECTION_KEY, {
items: ref(0),
hoveredItem: ref(-2)
})
const id = items.value++
emit('setId', id)
</script>
<template>
<a
v-if="to && typeof to === 'string' && to.startsWith('http')"
:href="to.toString()"
class="popover-item"
target="_blank"
>
<i
v-if="icon"
:class="['bi', icon]"
/>
<slot />
<div class="after">
<slot name="after" />
</div>
</a>
<RouterLink
v-else-if="to"
:to="to"
class="popover-item"
@mouseover="hoveredItem = id"
>
<i
v-if="icon"
:class="['bi', icon]"
/>
<slot />
<div class="after">
<slot name="after" />
</div>
</RouterLink>
<Button
v-else
ghost
thin-font
v-bind="$attrs"
style="
width: 100%;
textAlign: left;
gap: 8px;
"
:icon="icon"
class="popover-item"
@mouseover="hoveredItem = id"
>
<slot />
<div class="after">
<slot name="after" />
</div>
</Button>
</template>
<style scoped>
div { color:var(--fw-text-color); }
</style>
<style lang="scss">
.popover .popover-item {
cursor: pointer;
text-decoration: none;
padding: 0px 8px;
height: 32px;
display: flex;
align-items: center;
border-radius: var(--fw-border-radius);
white-space: nowrap;
&:hover {
background-color: var(--hover-background-color);
}
> .bi:first-child {
margin-right: 16px;
}
> .bi:last-child {
margin-right: 8px;
}
> .after {
margin-left: auto;
}
}
.popover .popover-item.button {
justify-content: flex-start !important;
height: 32px !important;
padding: 0px 8px;
border: none;
align-items: center;
&:hover {
background-color: var(--hover-background-color) !important;
}
span {
width: 100%;
position: relative;
i {
font-size: 14px;
}
> i {
margin-right: 14px !important;
}
.after {
position: absolute;
right: -12px;
top: 0;
height: 100%;
display: flex;
place-items: center;
gap: 8px;
}
}
}
</style>

View File

@ -0,0 +1,35 @@
<script setup lang="ts">
import PopoverRadioItem from './PopoverRadioItem.vue'
import { computed } from 'vue'
const { choices } = defineProps<{ choices:string[] }>()
const filteredChoices = computed(() => new Set(choices))
const value = defineModel<string>('modelValue', { required: true })
// NOTE: Due to the usage of a ref inside a Proxy, this is reactive.
const choiceValues = new Proxy<Record<string, boolean>>(Object.create(null), {
get (_, key) {
return key === value.value
},
set (_, key, val) {
if (!val || typeof key === 'symbol') return false
value.value = key
return true
}
})
</script>
<template>
<PopoverRadioItem
v-for="choice of filteredChoices"
:key="choice"
v-model="choiceValues[choice]"
>
{{ choice }}
</PopoverRadioItem>
</template>

View File

@ -0,0 +1,19 @@
<script setup lang="ts">
import PopoverItem from './PopoverItem.vue'
const value = defineModel<boolean>('modelValue', { required: true })
</script>
<template>
<PopoverItem
class="checkbox"
@click="value = !value"
>
<i :class="['bi', value ? 'bi-record-circle' : 'bi-circle']" />
<slot />
<template #after>
<slot name="after" />
</template>
</PopoverItem>
</template>

View File

@ -0,0 +1,44 @@
<script setup lang="ts">
import { inject, ref, watchEffect } from 'vue'
import { POPOVER_CONTEXT_INJECTION_KEY } from '~/injection-keys'
import Popover from '../Popover.vue'
import PopoverItem from './PopoverItem.vue'
const context = inject(POPOVER_CONTEXT_INJECTION_KEY, {
items: ref(0),
hoveredItem: ref(-2)
})
const isOpen = ref(false)
const id = ref(-1)
watchEffect(() => {
isOpen.value = context.hoveredItem.value === id.value
})
</script>
<template>
<Popover
v-model="isOpen"
positioning="horizontal"
>
<PopoverItem
:parent-popover-context="context"
class="submenu"
@click="isOpen = !isOpen"
@internal:id="id = $event"
>
<slot />
<template #after>
<slot name="after">
<i class="bi bi-chevron-right" />
</slot>
</template>
</PopoverItem>
<template #items>
<slot name="items" />
</template>
</Popover>
</template>

View File

@ -0,0 +1,85 @@
.funkwhale {
&.tabs {
color: var(--fw-text-color);
@include light-theme {
--fw-border-color: var(--fw-gray-300);
}
@include dark-theme {
--fw-text-color: var(--fw-gray-300);
--fw-border-color: var(--fw-gray-700);
}
> .tabs-header {
display: flex;
align-items: end;
padding-bottom: 8px;
margin-bottom: 23px;
border-bottom: 1px solid var(--fw-border-color);
&:has(:focus-visible) {
outline:1px dotted currentColor;
}
> .tabs-item {
font-size: 1rem;
padding: 8px;
min-width: 96px;
text-align: center;
cursor: pointer;
position: relative;
font-weight: normal;
border: none;
background-color: transparent !important;
&:hover {
&::after {
content: '';
display: block;
height: 4px;
background-color: var(--fw-secondary);
margin: 0 auto;
width: calc(10% + 2rem);
position: absolute;
inset: auto 0 0px 0;
border-radius: 100vh;
}
}
.is-spacing {
height: 0;
visibility: hidden;
}
.is-icon {
display: block;
position: relative;
transform: translateY(-.5rem);
font-size: 1.5em;
color: var(--fw-gray-500);
}
&.is-active, .is-spacing {
font-weight: 900;
&::after {
content: '';
display: block;
height: 4px;
background-color: var(--fw-secondary);
margin: 0 auto;
width: calc(10% + 2rem);
position: absolute;
inset: auto 0 0px 0;
border-radius: 100vh;
}
}
}
> .tabs-right {
margin-left: auto;
}
}
}
}

View File

@ -0,0 +1,136 @@
.funkwhale {
&.textarea-label {
> .label {
margin-top: -18px;
font-size: 14px;
font-weight: 600;
}
}
&.textarea {
background-color: var(--fw-bg-color);
box-shadow: inset 0 0 0 4px var(--fw-border-color);
&.has-preview {
background-color: var(--fw-bg-color);
}
> textarea {
resize: none;
order: 0;
outline: none;
border: none;
color: currentcolor;
}
overflow: hidden;
> .textarea-buttons {
flex-wrap: wrap;
// Offset padding of the textarea
margin: -8px;
> button+button {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
> button:has(+button) {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
}
@include light-theme {
--fw-border-color: var(--fw-bg-color);
--fw-buttons-border-color: var(--fw-gray-400);
--fw-bg-color: var(--fw-gray-100);
--fw-buttons-color: var(--fw-gray-100);
&:hover {
--fw-border-color: var(--fw-gray-300);
}
&.has-preview,
&:hover:focus-within {
--fw-border-color: transparent;
--fw-bg-color: var(--fw-blue-010);
}
}
@include dark-theme {
--fw-bg-color: var(--fw-gray-850);
--fw-border-color: var(--fw-bg-color);
--fw-buttons-border-color: var(--fw-gray-950);
--fw-buttons-color: var(--fw-gray-700);
&:hover {
--fw-border-color: var(--fw-gray-700);
}
&.has-preview,
&:hover:focus-within {
--fw-border-color: transparent;
--fw-bg-color: var(--fw-gray-800);
}
}
position: relative;
padding: 8px;
// Make border trace the radius of the buttons smoothly
border-radius: calc(var(--fw-border-radius) + 4px);
&.has-preview,
&:focus-within {
> .textarea-buttons {
opacity: 1;
transform: translateY(0rem);
pointer-events: auto;
}
}
&.has-preview > .preview {
opacity: 1;
transform: translateY(0rem);
pointer-events: auto;
}
> .preview {
position: absolute;
top: 8px;
left: 8px;
right: 8px;
bottom: 42px;
overflow-y: auto;
background-color: inherit;
opacity: 0;
transform: translateY(.5rem);
pointer-events: none;
transition: all .2s ease;
}
> textarea {
line-height: 1.5rem;
display: block;
width: 100%;
padding: 8px 12px 8px;
font-family: monospace;
background: transparent;
&:placeholder-shown {
font-family: $font-main;
}
}
> .textarea-buttons {
display: flex;
align-items: center;
position: relative;
opacity: 0;
transform: translateY(1rem) scale(1.03);
pointer-events: none;
transition: all .2s ease;
}
}
}

View File

@ -0,0 +1,65 @@
.funkwhale {
&.toc {
> .toc-toc > .toc-links > button {
--fw-link-color: var(--fw-text-color) !important;
&.is-active::before {
background-color: var(--fw-secondary);
}
}
@include light-theme {
--fw-border-color: var(--fw-gray-300);
}
@include dark-theme {
--fw-border-color: var(--fw-gray-700);
> .toc-toc > .toc-links > button {
--fw-text-color: var(--fw-gray-300);
}
}
display: grid;
grid-template-columns: 1fr 280px;
gap: 1rem;
> .toc-toc {
border-left: 1px solid var(--fw-border-color);
> .toc-links {
position: sticky;
top: 0;
padding-left: 8px;
/* @include docs {
top: 72px;
} */
> button {
position: relative;
font-size: 1rem;
padding: 4px 8px 4px 11px;
display: block;
width: 100%;
text-align: left;
&.is-active {
font-weight: 900;
&::before {
content: '';
width: 4px;
height: 100%;
position: absolute;
right: 100%;
top: 0;
border-radius: 100vh;
}
}
}
}
}
}
}

View File

@ -0,0 +1,96 @@
.funkwhale {
&.toggle {
background-color: var(--fw-bg-color);
@include light-theme {
--fw-bg-color: var(--fw-gray-500);
&::before {
background: var(--fw-red-010);
}
&:hover {
--fw-bg-color: var(--fw-gray-300);
}
&.is-active {
--fw-bg-color: var(--fw-blue-100);
&:hover {
--fw-bg-color: var(--fw-blue-400);
}
&::before {
background: var(--fw-blue-010);
}
}
}
@include dark-theme {
--fw-bg-color: var(--fw-gray-600);
&::before {
background: var(--fw-red-010);
}
&:hover {
--fw-bg-color: var(--fw-gray-700);
}
&.is-active {
--fw-bg-color: var(--fw-blue-400);
&:hover {
--fw-bg-color: var(--fw-blue-700);
}
&::before {
background: var(--fw-blue-010);
}
}
}
position: relative;
border-radius: 100vw;
overflow: hidden;
height: 20px;
aspect-ratio: 2;
> input {
opacity: 0;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
cursor: pointer;
}
&::before {
display: block;
content: '';
position: absolute;
top: 2px;
left: 2px;
height: calc(100% - 4px);
aspect-ratio: 1;
border-radius: 50%;
transition: transform .2s ease;
}
&.is-big {
height: 28px;
}
&.is-active {
&::before {
transform: translateX(calc(100% + 4px));
}
}
&[disabled] {
opacity: 0;
pointer-events: none;
}
}
}

View File

@ -0,0 +1,81 @@
import { defineConfig } from 'vitepress'
export default defineConfig({
title: 'Funkwhale UI',
cleanUrls: true,
cacheDir: './.vitepress/.vite',
themeConfig: {
nav: [
{ text: 'Home', link: 'https://funkwhale.audio' },
{ text: 'Gitlab', link: 'https://dev.funkwhale.audio/funkwhale/ui' },
],
sidebar: [
{ text: 'Designing Pages', link: '/designing-pages' },
{ text: 'Using Color', link: '/using-color' },
{ text: 'Using Width', link: '/using-width' },
{ text: 'Using Alignment', link: '/using-alignment' },
{ text: 'Using Components', link: '/using-components' },
{ text: 'Contributing', link: '/contributing' },
{
items: [
{ text: 'Activity', link: '/components/ui/activity' },
{ text: 'Alert', link: '/components/ui/alert' },
{ text: 'Card', link: '/components/ui/card' },
{
text: 'Navigation',
link: '/navigation',
items: [
{ text: 'Link', link: 'components/ui/link' },
{ text: 'Pagination', link: '/components/ui/pagination' },
{ text: 'Table of Contents', link: '/components/ui/toc' },
{ text: 'Tabs', link: '/components/ui/tabs' },
{ text: 'Nav', link: '/components/ui/nav' },
],
},
{
text: 'Form',
items: [
{
text: 'Button', link: '/components/ui/button',
items: [
{ text: 'Options Button', link: '/components/ui/button/options' },
{ text: 'Play Button', link: '/components/ui/button/play' },
],
},
{ text: 'Input', link: '/components/ui/input' },
{ text: 'Slider', link: '/components/ui/slider' },
{ text: 'Popover (Dropdown Menu)', link: '/components/ui/popover' },
{ text: 'Textarea', link: '/components/ui/textarea' },
{ text: 'Toggle', link: '/components/ui/toggle' },
],
},
{ text: 'Heading', link: '/components/ui/heading' },
{
text: 'Layout', link: '/components/ui/layout/',
items: [
{ text: "Spacer", link: "/components/ui/layout/spacer" },
{ text: "Header", link: "/components/ui/layout/header" },
{ text: "Section", link: "/components/ui/layout/section" },
{ text: "Table", link: "/components/ui/layout/table" },
{ text: "Using `flex`", link: "/components/ui/layout/flex" },
{ text: "Using `stack`", link: "/components/ui/layout/stack" },
{ text: "Using `grid`", link: "/components/ui/layout/grid" },
{ text: "Using `columns`", link: "/components/ui/layout/columns" },
]
},
{ text: 'Loader', link: '/components/ui/loader' },
{ text: 'Modal', link: '/components/ui/modal' },
{ text: 'Pill',
link: '/components/ui/pill',
items: [
{ text: 'List of pills', link: '/components/ui/pills' }
]
},
],
},
],
search: {
provider: 'local',
},
},
})

View File

@ -0,0 +1,106 @@
<script setup>
import DefaultTheme from 'vitepress/theme'
const { Theme } = DefaultTheme
</script>
<template>
<Theme>
<template>
</template>
</Theme>
</template>
<style>
:root {
--vp-sidebar-width: 250px;
}
.Layout {
--vp-c-text-1: color-mix(in oklab, light-dark(var(--fw-gray-970), var(--fw-beige-100)) 100%, transparent);
--vp-c-text-2: color-mix(in oklab, light-dark(var(--fw-gray-970), var(--fw-beige-100)) 60%, transparent);
--vp-c-bg: color-mix(in oklab, light-dark(white, var(--fw-gray-950)) 50%, transparent);
--vp-c-bg-soft: color-mix(in oklab, light-dark(var(--fw-beige-300), var(--fw-gray-970)) 50%, transparent);
--vp-c-gutter: light-dark(transparent, var(--fw-gray-970));
--vp-local-nav-bg-color: light-dark(transparent, var(--fw-gray-970));
--vp-code-block-bg: light-dark(var(--fw-beige-200), var(--fw-gray-900));
--vp-c-bg-alt: light-dark(var(--fw-beige-300), var(--fw-gray-850));
--vp-c-divider: light-dark(var(--fw-beige-300), var(--fw-gray-850));
--vp-code-line-highlight-color: light-dark(var(--fw-beige-300), var(--fw-gray-850));
--vp-custom-block-details-bg: light-dark(var(--fw-beige-400), var(--fw-gray-800));
--vp-nav-bg-color: light-dark(var(--fw-beige-100), var(--fw-gray-960));
--vp-sidebar-bg-color: light-dark(var(--fw-beige-100), var(--fw-gray-960));
background-color: light-dark(var(--fw-beige-100), var(--fw-gray-960));
}
.VPNavBarTitle .title {
font-size: 14px;
}
.vp-doc div[class*="language-"]:has(+h1){
width: min-content;
float: right;
margin: -4px -32px -4px -4px;
background: transparent;
&>pre {
padding-right: 32px;
}
}
.language-template:has(~.preview){
flex-grow: 1;
&~.preview {
flex-grow: 0;
}
}
:has(.vp-doc) .container .items {
background: light-dark(var(--fw-beige-100), var(--fw-gray-960));
}
.vp-doc .custom-block{
margin-left: -2px;
/* background-color: transparent; */
border-width: 0;
border-radius: 0;
border-left-width: 4px;
--vp-custom-block-details-border: currentcolor;
&:has(summary:hover){color: var(--fw-blue-400)}
--vp-custom-block-details-bg: color-mix(in oklab, var(--vp-custom-block-details-border) 0%, transparent);
--vp-custom-block-warning-border: var(--fw-pastel-yellow-4);
--vp-custom-block-warning-bg: color-mix(in oklab, var(--vp-custom-block-warning-border) 10%, transparent);
--vp-custom-block-info-border: var(--fw-pastel-blue-4);
--vp-custom-block-info-bg: color-mix(in oklab, var(--vp-custom-block-info-border) 10%, transparent);
}
.funkwhale h3 {
/* override vitepress */
margin:0;
}
.vp-doc .preview:not(.funkwhale .preview) {
padding: 16px 0;
flex-grow: 1;
border-radius: 8px;
justify-content: start;
--vp-code-block-bg: transparent;
&:not(.transparent){
background-color:var(--background-color);
box-shadow: 0px 0px 16px 16px var(--background-color);
margin:16px 0;
padding:0;
}
/* .preview overrides the cascade coming from .vp-docs and other containers
that may leak rules here */
p {
margin: 0;
line-height: normal
}
}
</style>

View File

@ -0,0 +1,56 @@
import { createI18n } from 'vue-i18n'
import DefaultTheme from 'vitepress/theme'
import en from '../../../src/locales/en_US.json'
import Theme from './Theme.vue'
import VueDOMPurifyHTML from 'vue-dompurify-html';
import { createRouter, createWebHistory } from 'vue-router'
import routesV2 from '../../../src/ui/routes'
const routes = routesV2
export default {
...DefaultTheme,
Theme: Theme,
enhanceApp({ app }) {
const i18n = createI18n({
legacy: false,
locale: 'en',
fallbackLocale: 'en',
messages: { en }
})
const router = createRouter({
history: createWebHistory('/'),
linkActiveClass: 'active',
routes,
scrollBehavior (to, _, savedPosition) {
if (to.meta.preserveScrollPosition) {
return savedPosition ?? { left: 0, top: 0 }
}
return new Promise(resolve => {
setTimeout(() => {
if (to.hash) {
resolve({ el: to.hash, behavior: 'smooth' })
}
resolve(savedPosition ?? { left: 0, top: 0 })
}, 100)
})
}
})
// Simsalabim: Incantation for a confused i18n... Thank you s-ol https://github.com/vikejs/vike/discussions/1778#discussioncomment-10192261
if (!('__VUE_PROD_DEVTOOLS__' in globalThis)) {
(globalThis as any).__VUE_PROD_DEVTOOLS__ = false;
}
app.use(i18n)
app.use(router)
app.use(VueDOMPurifyHTML);
}
}

View File

@ -0,0 +1,137 @@
<script setup lang="ts">
import type { Track, User } from "~/types";
import Activity from "~/components/ui/Activity.vue"
const track: Track = {
id: 0,
fid: "",
title: 'Some lovely track',
description: {
content_type: 'text/markdown',
text: `**New:** Music for the eyes!`
},
cover: {
uuid: "",
urls: {
original: 'https://images.unsplash.com/photo-1524650359799-842906ca1c06?ixlib=rb-1.2.1&dl=te-nguyen-Wt7XT1R6sjU-unsplash.jpg&w=640&q=80&fm=jpg&crop=entropy&cs=tinysrgb',
medium_square_crop: 'https://images.unsplash.com/photo-1524650359799-842906ca1c06?ixlib=rb-1.2.1&dl=te-nguyen-Wt7XT1R6sjU-unsplash.jpg&w=640&q=80&fm=jpg&crop=entropy&cs=tinysrgb',
large_square_crop: 'https://images.unsplash.com/photo-1524650359799-842906ca1c06?ixlib=rb-1.2.1&dl=te-nguyen-Wt7XT1R6sjU-unsplash.jpg&w=640&q=80&fm=jpg&crop=entropy&cs=tinysrgb'
}
},
tags: ["example"],
uploads: [],
downloads_count: 1927549377,
artist_credit: [{
artist: {
id: 0,
fid: "",
name: "The Artist",
description: {
content_type: 'text/markdown',
text: `I'm a musician based on the internet.
Find all my music on [Funkwhale](https://funkwhale.audio)!`},
tags: [],
content_category: 'music',
albums: [],
tracks_count: 1,
attributed_to: {
id: 0,
summary: "",
preferred_username: "User12345",
full_username: "User12345",
is_local: false,
domain: "myDomain.io"
},
is_local: false,
is_playable: true
},
credit: "",
joinphrase: " and ",
index: 22
}],
disc_number: 7,
listen_url: "https://funkwhale.audio",
creation_date: "12345",
attributed_to: {
id: 0,
summary: "",
preferred_username: "User12345",
full_username: "User12345",
is_local: false,
domain: "myDomain.io"
},
is_playable: true,
is_local: false
}
const user: User = {
id: 12,
avatar: {
uuid: "",
urls: {
original: 'https://images.unsplash.com/photo-1524650359799-842906ca1c06?ixlib=rb-1.2.1&dl=te-nguyen-Wt7XT1R6sjU-unsplash.jpg&w=640&q=80&fm=jpg&crop=entropy&cs=tinysrgb',
medium_square_crop: 'https://images.unsplash.com/photo-1524650359799-842906ca1c06?ixlib=rb-1.2.1&dl=te-nguyen-Wt7XT1R6sjU-unsplash.jpg&w=640&q=80&fm=jpg&crop=entropy&cs=tinysrgb',
large_square_crop: 'https://images.unsplash.com/photo-1524650359799-842906ca1c06?ixlib=rb-1.2.1&dl=te-nguyen-Wt7XT1R6sjU-unsplash.jpg&w=640&q=80&fm=jpg&crop=entropy&cs=tinysrgb'
}
},
email: "user12345@example.org",
summary: { text: "Hi! I'm Example from The Internet.", content_type: "text" },
username: "user12345",
full_username: "user12345",
instance_support_message_display_date: "?",
funkwhale_support_message_display_date: "?",
is_superuser: true,
privacy_level: "everyone"
}
</script>
```ts
import Activity from "~/components/ui/Activity.vue"
```
# Activity
Activities display history entries for a Funkwhale pod. Each item contains the following information:
- An image
- A track title
- An artist name
- A username
- A [popover](./popover.md)
| Prop | Data type | Required? | Description |
| ------- | ------------ | --------- | -------------------------------------------- |
| `track` | Track object | Yes | The track to render in the activity entry. |
| `user` | User object | Yes | The user associated with the activity entry. |
## Single items
You can render a single activity item by passing the track and user information to the `<Activity>` component.
```vue-html
<Activity :track="track" :user="user" />
```
<Activity :track="track" :user="user" />
## Activity lists
You can display a list of activity items by passing a `v-for` directive and adding a `key` to the item. The `key` must
be unique to the list.
::: info
Items in a list are visually separated by a 1px border.
:::
```vue-html{4-5}
<Activity :track="track" :user="user" v-for="i in 3" :key="i" />
```
<Activity :track="track" :user="user" v-for="i in 3" :key="i" />

View File

@ -0,0 +1,98 @@
<script setup>
import Alert from "~/components/ui/Alert.vue"
import Button from "~/components/ui/Button.vue"
</script>
```ts
import Alert from "~/components/ui/Alert.vue"
```
# Alert
| Prop | Data type | Required? | Default | Description |
| ------- | -------------------------------------------------- | --------- | ----------- | -------------------------------- |
| `color` | `blue` \| `red` \| `purple` \| `green` \| `yellow` | No | `secondary` | The color of the alert container |
## Alert colors
Funkwhale alerts support a range of pastel colors for visual appeal.
### Blue
```vue-html
<Alert blue>
Blue alert
</Alert>
```
<Alert blue>
Blue alert
</Alert>
### Red
```vue-html
<Alert red>
Red alert
</Alert>
```
<Alert red>
Red alert
</Alert>
### Purple
```vue-html
<Alert purple>
Purple alert
</Alert>
```
<Alert purple>
Purple burglar alert
</Alert>
### Green
```vue-html
<Alert green>
Green alert
</Alert>
```
<Alert green>
Green alert
</Alert>
### Yellow
```vue-html
<Alert yellow>
Yellow alert
</Alert>
```
<Alert yellow>
Yellow alert
</Alert>
## Alert actions
```vue-html{3-6}
<Alert blue>
Awesome artist
<template #actions>
<Button disabled>Deny</Button>
<Button>Got it</Button>
</template>
</Alert>
```
<Alert blue>
Awesome artist
<template #actions>
<Button disabled>Deny</Button>
<Button>Got it</Button>
</template>
</Alert>

View File

@ -0,0 +1,500 @@
<script setup lang="ts">
import Button from '~/components/ui/Button.vue'
import Layout from '~/components/ui/Layout.vue'
const click = ():Promise<void> => new Promise(resolve => setTimeout(resolve, 1000))
</script>
```ts
import Button from "~/components/ui/Button.vue"
```
# Button
Buttons are UI elements that users can interact with to perform actions and manipulate objects. They are distinct from [Links](link) and will not change the user's position.
```ts
{
thinFont?: true
lowHeight?: true
isActive?: boolean
isLoading?: boolean
shadow?: boolean
round?: boolean
icon?: string
onClick?: (...args: any[]) => void | Promise<void>
autofocus?: boolean
ariaPressed?: true
} & (ColorProps | DefaultProps)
& VariantProps
& RaisedProps
& WidthProps
& AlignmentProps
```
## Action
[The default action of buttons is `submit` \[mdn\]](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/button#type). Specify an [`onClick` or `@click`](#short-form-click) function to change the behavior of a button.
```vue-html
<form>
<Button>Without `onClick`</Button>
<Button :onClick="() => {}">With `onClick`</Button>
</form>
```
<form>
<Button>Without `onClick`</Button>
<Button :onClick="() => {}">With `onClick`</Button>
</form>
### Short-form @click
For convenience, you can use the Vue-specific `@click` syntax to add the `onClick` prop. The following two buttons are effectively equal:
```vue-html
<Button :onClick="() => { console.log('Hi!') }"> Long form </Button>
<Button @click="console.log('Hi!')"> Short form </Button>
```
<Button :onClick="() => { console.log('Hi!') }"> Long form </Button>
<Button @click="console.log('Hi!')"> Short form </Button>
## Button colors
Buttons come in different types depending on the type of action they represent.
Find [a complete overview of recommended styles on the color page](../../using-color#links-and-buttons).
### Default
Default buttons represent **neutral** actions such as cancelling a change or dismissing a notification.
<Layout grid class="preview transparent">
<div style="grid-column: span 3">
```vue-html
<Button>
Default button
</Button>
```
</div>
<Button>
Default button
</Button>
</Layout>
### Primary
The primary button represents the **single positive** action on a page or modal, such as uploading, confirming, and accepting changes.
<Layout grid class="preview">
<div style="grid-column: span 3">
```vue-html
<Button primary>
Primary button
</Button>
```
</div>
<Button primary>
Primary button
</Button>
</Layout>
### Secondary
Secondary buttons represent **neutral** actions such as cancelling a change or dismissing a notification.
<Layout grid class="preview secondary">
<div style="grid-column: span 4">
```vue-html
<Button secondary raised>
Secondary button
</Button>
```
</div>
<Button secondary raised>
Secondary button
</Button>
</Layout>
Note that on a secondary background, the button needs to be `raised` to make it stand out.
### Destructive
Desctrutive buttons represent **dangerous** actions including deleting items or purging domain information.
<Layout grid class="preview">
<div style="grid-column: span 3">
```vue-html
<Button destructive>
Destructive button
</Button>
```
</div>
<Button destructive>
Destructive button
</Button>
</Layout>
## Button variants
Buttons come in different styles that you can use depending on the location of the button.
### Solid
Solid buttons have a filled background. Use these to emphasize the action the button performs.
::: info
This is the default style. If you don't specify a style, a solid button is rendered.
:::
```vue-html
<Button>
Filled button
</Button>
<Button solid>
Also filled button
</Button>
```
<Button>
Filled button
</Button>
<Button solid>
Also filled button
</Button>
### Outline
Outline buttons have a transparent background. Use these to deemphasize the action the button performs.
```vue-html
<Button outline secondary>
Outline button
</Button>
```
<Button outline secondary>
Outline button
</Button>
### Ghost
Ghost buttons have a transparent background and border. Use these to deemphasize the action the button performs.
```vue-html
<Button ghost secondary>
Ghost button
</Button>
```
<Button ghost secondary>
Ghost button
</Button>
## Button styles
### Shadow
You can give a button a shadow to add depth.
```vue-html
<Button shadow>
Shadow button
</Button>
```
<Button shadow>
Shadow button
</Button>
## Button shapes
You can choose different shapes for buttons depending on their location and use.
### Normal
Normal buttons are slightly rounded rectangles.
::: info
This is the default shape. If you don't specify a type, a normal button is rendered.
:::
```vue-html
<Button>
Normal button
</Button>
```
<Button>
Normal button
</Button>
### Round
Round buttons have fully rounded edges.
```vue-html
<Button round>
Round button
</Button>
```
<Button round>
Round button
</Button>
## Split button
<Layout class="preview default raised">
<Button split splitIcon="bi-star" splitTitle="Star!" :onSplitClick="()=>console.log('1 star')">I am split</Button>
<Button> I am not split </Button>
<Button disabled> I am not split and I am disabled </Button>
<Button disabled split splitIcon="bi-star" splitTitle="Star!" :onSplitClick="()=>console.log('1 star')">I am split and disabled</Button>
</Layout>
## Button states
### On/Off
You can force an active state by passing an `aria-pressed` prop.
::: tip When do I use a Toggle vs. a Button?
**Use a Button with an `aria-pressed` prop**
- if the semantics of the option change depending whether it's on or off
- to perform asynchronous, stateful and fallible actions
**Examples:**
- Toggle a remote property
- Open/close a section in the UI
- Toolbar buttons that toggle through many options such as "Paragraph/Heading/List"
**Use the [Toggle component](toggle) (a.k.a. switch)**
- for options that don't cause side-effects and never change the Toggle label content based on the Toggle state (think of the traditional checkbox).
- for options that don't have any intermediary state besides "on" and "off"
**Examples:**
- A checkbox in the User settings
- A checkbox in a form that the user submits later
:::
```vue-html
<Button>
Off
</Button>
<Button aria-pressed>
On
</Button>
```
**Default:**
<Button>
Off
</Button>
<Button aria-pressed>
On
</Button>
---
**Secondary:**
<Button secondary>
Off
</Button>
<Button secondary aria-pressed>
On
</Button>
---
**Primary:**
<Button primary>
Off
</Button>
<Button primary aria-pressed>
On
</Button>
### Disabled
Disabled buttons are non-interactive and inherit a less bold color than the one provided.
::: tip When do I use `disabled`?
Use the `disabled` property for buttons that the user expects at a certain position, for example in a toolbar or in a row of action buttons.
If there is just one button in a form and its action is disabled, you may instead just remove it.
:::
```vue-html
<Button disabled>
Disabled button
</Button>
```
<Button disabled>
Disabled button
</Button>
### Loading
If a user can't interact with a button until something has finished loading, you can add a spinner by passing the `is-loading` prop.
```vue-html
<Button is-loading>
Loading button
</Button>
```
<Button is-loading>
Loading button
</Button>
### Promise handling in `@click`
When a function passed to `@click` returns a promise, the button automatically toggles a loading state on click. When the promise resolves or is rejected, the loading state turns off.
::: danger
There is no promise rejection mechanism implemented in the `<Button>` component. Make sure the `@click` handler never rejects.
:::
```vue
<script setup lang="ts">
const click = () => new Promise((resolve) => setTimeout(resolve, 1000));
</script>
<template>
<Button @click="click"> Click me </Button>
</template>
```
<Button @click="click">
Click me
</Button>
You can override the promise state by passing a false `is-loading` prop.
```vue-html
<Button :is-loading="false">
Click me
</Button>
```
<Button :is-loading="false">
Click me
</Button>
## Add an icon
You can use [Bootstrap Icons](https://icons.getbootstrap.com/) in your button component.
::: info
- Icon buttons shrink down to the icon size if you don't pass any content. If you want to keep the button at full width with just an icon, add `button-width` as a prop.
- When combining icons with other content, prefix the icon prop with `right ` to place it after the content.
- To make icons large, add ` large` to the icon prop.
:::
```vue-html
<Button icon="bi-three-dots-vertical" />
<Button round icon="bi-x large" />
<Button primary icon="bi-save" button-width/>
<Button destructive icon="bi-trash">
Delete
</Button>
<Button low-height icon="right bi-chevron-right">
Next
</Button>
```
<Layout flex>
<Button icon="bi-three-dots-vertical" />
<Button round secondary icon="bi-x large" />
<Button primary icon="bi-save" button-width/>
<Button destructive icon="bi-trash">
Delete
</Button>
<Button low-height icon="right bi-chevron-right">
Next
</Button>
</Layout>
## Set width and alignment
See [Using width](/using-width) and [Using alignment](/using-alignment)
<Layout flex>
```vue-html
<Button min-content>🐌</Button>
<Button tiny>🐌</Button>
<Button buttonWidth>🐌</Button>
<Button small>🐌</Button>
<Button auto>🐌</Button>
<hr />
<Button alignSelf="start">🐌</Button>
<Button alignSelf="center">🐌</Button>
<Button alignSelf="end">🐌</Button>
<hr />
<Button alignText="left">🐌</Button>
<Button alignText="center">🐌</Button>
<Button alignText="right">🐌</Button>
```
<Layout class="preview solid primary" stack no-gap>
<Button min-content>🐌</Button>
<Button tiny>🐌</Button>
<Button buttonWidth>🐌</Button>
<Button small>🐌</Button>
<Button auto>🐌</Button>
<hr />
<Button alignSelf="start">🐌</Button>
<Button alignSelf="center">🐌</Button>
<Button alignSelf="end">🐌</Button>
<hr />
<Button alignText="left">🐌</Button>
<Button alignText="center">🐌</Button>
<Button alignText="right">🐌</Button>
</Layout>
</Layout>

View File

@ -0,0 +1,17 @@
<script setup>
import OptionsButton from '~/components/ui/button/Options.vue'
</script>
```ts
import OptionsButton from "~/components/ui/button/Options.vue"
```
# Options Button
-> For use cases, see [components/popover](../popover.md)
```vue-html
<OptionsButton />
```
<OptionsButton />

View File

@ -0,0 +1,17 @@
<script setup>
import PlayButton from '~/components/ui/button/Play.vue'
</script>
```ts
import PlayButton from "~/components/ui/button/Play.vue"
```
# Play Button
The play button is a specialized button used in many places across the Funkwhale app. Map a function to the `@play` event handler to toggle it on click.
```vue-html
<PlayButton />
```
<PlayButton />

View File

@ -0,0 +1,423 @@
<script setup lang="ts">
import Button from '~/components/ui/Button.vue'
import Link from '~/components/ui/Link.vue'
import Card from '~/components/ui/Card.vue'
import Layout from '~/components/ui/Layout.vue'
import OptionsButton from '~/components/ui/button/Options.vue'
import Spacer from '~/components/ui/Spacer.vue'
const alert = ( message: string ) => window.alert(message)
</script>
```ts
import Card from "~/components/ui/Card.vue"
```
# Card
Funkwhale cards are used to contain textual information, links, and interactive buttons. You can use these to create visually pleasing links to content or to present information.
::: details Props
```ts
{
title: string
category?: true | "h1" | "h2" | "h3" | "h4" | "h5"
tags?: string[]
image?: string | { src: string, style?: "withPadding" }
icon?: string
alertProps?: PastelProps
} & Partial<RouterLinkProps>
& (PastelProps | ColorProps | DefaultProps)
& RaisedProps
& VariantProps
& WidthProps
```
:::
<Layout grid class="preview">
<div style="grid-column: span 5; grid-row: span 2;">
```vue-html
<Card large
title="For music lovers"
>
Access your personal music
collection from anywhere.
Funkwhale gives you access to
publication and sharing tools
you can use to promote that
your content across the web.
</Card>
```
</div>
<Card medium title="For music lovers">
Access your personal music collection from anywhere. Funkwhale gives you access to publication and sharing tools that you can use to promote your content across the web.
</Card>
</Layout>
## Card as a Link
Add a `:to` prop, either containing an external link (`"https://..."`) or a Vue Router destination:
<Layout flex class="preview">
```ts
<Card min-content title="Link"
:to="{name: 'library.albums.detail', params: {id: album.id}}"
/>
```
<Card min-content title="Link"
:to="{name: 'library.albums.detail', params: {id: 1}}"
/>
</Layout>
If you add interactive elements, only the surrounding surfaces will be linked.
For details, see the section on [interactive elements on top of a linked card](#interactive-elements-on-top-of-a-linked-card)
## Card as a Category header
Category cards are basic cards that contain only a title. To create a category card, pass a `category` prop.
<Layout flex class="preview">
```vue-html{1,5}
<Card category min-content
title="Good Translations"
/>
<Card category
title="Bad Translations"
/>
```
<Layout stack>
<Card category min-content
title="Good Translations"
/>
<Card category
title="Bad Translations"
/>
</Layout>
</Layout>
## Add an Image
Pass an image source to the `image` prop or set both `image.src` and `image.style` by passing an object.
<Layout grid class="preview">
<div style="grid-column: span 5; grid-row: span 2">
```vue-html{3,8-9}
<Card
title=" smallFor music lovers"
image="https://images.unsplash.com/photo-1524650359799-842906ca1c06?ixlib=rb-1.2.1&dl=te-nguyen-Wt7XT1R6sjU-unsplash.jpg&w=640&q=80&fm=jpg&crop=entropy&cs=tinysrgb">
/>
<Card small
title="For music lovers"
:image="{ src:'https://images.unsplash.com/photo-1524650359799-842906ca1c06?ixlib=rb-1.2.1&dl=te-nguyen-Wt7XT1R6sjU-unsplash.jpg&w=640&q=80&fm=jpg&crop=entropy&cs=tinysrgb',
style:'withPadding' }"
/>
```
</div>
<Card small
title="For music lovers"
image="https://images.unsplash.com/photo-1524650359799-842906ca1c06?ixlib=rb-1.2.1&dl=te-nguyen-Wt7XT1R6sjU-unsplash.jpg&w=640&q=80&fm=jpg&crop=entropy&cs=tinysrgb"
/>
<Card small
title="For music lovers"
:image="{ src:'https://images.unsplash.com/photo-1524650359799-842906ca1c06?ixlib=rb-1.2.1&dl=te-nguyen-Wt7XT1R6sjU-unsplash.jpg&w=640&q=80&fm=jpg&crop=entropy&cs=tinysrgb',
style:'withPadding' }"
/>
</Layout>
## Add an Icon
<Layout grid class="preview">
<div style="grid-column: span 5; grid-row: span 2;">
```vue-html{4,10}
<Card
title="Uploading..."
image="https://images.unsplash.com/photo-1524650359799-842906ca1c06?ixlib=rb-1.2.1&dl=te-nguyen-Wt7XT1R6sjU-unsplash.jpg&w=640&q=80&fm=jpg&crop=entropy&cs=tinysrgb"
icon="bi-cloud-arrow-up-fill"
/>
<Card
to="./"
title="Find out more"
icon="bi-box-arrow-up-right"
>
Visit the Docs and learn more about developing Funkwhale
</Card>
```
</div>
<Card
title="Uploading..."
image="https://images.unsplash.com/photo-1524650359799-842906ca1c06?ixlib=rb-1.2.1&dl=te-nguyen-Wt7XT1R6sjU-unsplash.jpg&w=640&q=80&fm=jpg&crop=entropy&cs=tinysrgb"
icon="bi-cloud-arrow-up-fill"
/>
<Card
to="./"
title="Find out more"
icon="bi-box-arrow-up-right">
Visit the Docs and learn more about developing Funkwhale
</Card>
</Layout>
You can combine this prop with any other prop configuration. If you combine it with an image, keep an eye on the contrast ratio between the icon color and the image.
## Add an Alert
```vue-html{2-4}
<Card title="Your Collection" :alertProps="{ red: true }">
<template #alert>
Please annotate all items with the required metadata.
</template>
</Card>
```
<div class="preview">
<Card title="Your Collection" :alertProps="{ red: true }">
<template #alert>Please annotate all items with the required metadata.</template>
</Card>
</div>
::: info
To add props to the alert, add the `alert-props` property to the card. Check out [the Alert component docs](/components/ui/alert) to find out which props are supported
:::
## Add a topright action
```vue-html
<Card title="Topright action">
<template #topright>
<OptionsButton square-small />
</template>
</Card>
```
<Card title="Topright action">
<template #topright>
<OptionsButton square-small />
</template>
</Card>
## Add a Footer
Items in this region are secondary and will be displayed smaller than the main content.
```vue-html{3-9}
<Card large title="My items">
<template #alert> There are no items in this list </template>
<template #footer>
<Button outline icon="bi-upload" @click="alert('Uploaded. Press OK!')">
Upload
</Button>
<Spacer style="flex-grow: 1" />
<OptionsButton />
</template>
</Card>
```
<div class="preview">
<Card medium title="My items">
<template #alert>There are no items in this list
</template>
<template #footer>
<Button outline icon="bi-upload" @click="alert('Uploaded. Press OK!')">Upload</Button>
<Spacer style="flex-grow: 1" />
<OptionsButton />
</template>
</Card>
</div>
## Add an Action
Large Buttons or links at the bottom edge of the card serve as Call-to-Actions (CTA).
```vue-html{3-6}
<Card large
title="Join an existing pod"
>
The easiest way to get started with Funkwhale is to register an account on a public pod.
<template #action>
<Button secondary full @click="alert('Open the pod picker')">Action!
</Button>
</template>
</Card>
```
<div class="preview">
<Card medium
title="Join an existing pod"
>
The easiest way to get started with Funkwhale is to register an account on a public pod.
<template #action>
<Button secondary full @click="alert('Open the pod picker')">Action!
</Button>
</template>
</Card>
</div>
If there are multiple actions, they will be presented in a row:
```vue-html{4,7}
<Card title="Creating a new playlist...">
All items have been assimilated. Ready to go!
<template #action>
<Button secondary ghost style="justify-content: flex-start;" icon="bi-chevron-left">
Back
</Button>
<Button style="flex-grow:0;" primary @click="alert('Yay')">
Create
</Button>
</template>
</Card>
```
<div class="preview">
<Card full title="Creating a new playlist...">
All items have been assimilated. Ready to go!
<template #action>
<Button secondary ghost min-content
align-text="start"
icon="bi-chevron-left"
>
Items
</Button>
<Spacer h grow />
<Button primary @click="alert('Yay')">
OK
</Button>
<Button destructive @click="alert('Yay')">
Cancel
</Button>
</template>
</Card>
</div>
## Add Tags
You can include tags on a card by adding a list of `tags`. These are rendered as [pills](./pill.md).
```vue-html{3}
<Card
title="For music lovers"
:tags="['rock', 'folk', 'punk']"
>
Access your personal music collection from anywhere. Funkwhale gives you access to publication and sharing tools that you can use to promote your content across the web.
</Card>
```
<div class="preview">
<Card medium
title="For music lovers"
:tags="['rock', 'folk', 'punk']"
>
Access your personal music collection from anywhere. Funkwhale gives you access to publication and sharing tools that you can use to promote your content across the web.
</Card>
</div>
## Interactive elements on top of a linked card
Avoid adding buttons and links on top of a [linked card](#card-as-a-link). This is an uncommon pattern and will confuse users.
```vue-html
<Card
full
title="Linked card with interactive elements"
to="./card.md"
:tags="['rock', 'folk', 'punk']"
icon="bi-exclamation large"
>
<Button primary low-height :onClick="()=>alert('Button clicked')">Click me!</Button>
<Link secondary to="./card.md" align-text="end">Open this file in a new window</Link>
</Card>
```
<!-- prettier-ignore-start -->
<Card
full
title="Linked card with interactive elements"
to="./card.md"
:tags="['rock', 'folk', 'punk']"
icon="bi-exclamation large"
>
<Button primary low-height :onClick="()=>alert('Button clicked')">Click me!</Button>
<Link secondary to="./card.md" align-text="end">Open this file in a new window</Link>
</Card>
<!-- prettier-ignore-end -->
Instead, use the [action area](#add-an-action) to offer the primary link:
```vue-html
<Card
full
title="Card with interactive elements"
:tags="['rock', 'folk', 'punk']"
icon="bi-check-lg large"
>
<Button secondary low-height :onClick="()=>alert('Button clicked')">Click me!</Button>
<Link secondary to="./card.md" align-text="end">Open this file in a new window</Link>
<template #action>
<Link solid full primary to="./card.md" align-text="center">Details</Link>
</template>
</Card>
```
<!-- prettier-ignore-start -->
<Card
full
title="Card with interactive elements"
:tags="['rock', 'folk', 'punk']"
icon="bi-check-lg large"
>
<Button secondary low-height :onClick="()=>alert('Button clicked')">Click me!</Button>
<Link secondary to="./card.md" align-text="end">Open this file in a new window</Link>
<template #action>
<Link solid full primary to="./card.md" align-text="center">Details</Link>
</template>
</Card>
<!-- prettier-ignore-end -->
## Add color
- Choose a color: `default`, `primary`, `secondary`, `destructive`, or a Pastel (red, yellow, purple, green or blue)
- Choose a variant: `raised`, `solid`, `outline`,...
Read more: [Using Color](/using-color)
## Set size
`large` (304px), `medium` (208px), `auto`, `small`, ...
Read more: [Using Width](/using-width)

View File

@ -0,0 +1,117 @@
<script setup>
import Heading from "~/components/ui/Heading.vue"
</script>
```ts
import Heading from "~/components/ui/Heading.vue"
```
# Heading
Use a heading when the content or the page structure requires it. Define the visual style independently from the logical hierarchy.
- Each page has exactly one `h1` with the visual size `page-heading`.
- Headings always describe the content below. Do not use headings for input captions.
- Try to avoid gaps in the hierarchy.
## Semantic heading level
Use heading levels wherever you want to add logical (not necessarily visual) hierarchy.
Consult [the allyproject for a comprehensive guide on headings](https://www.a11yproject.com/posts/how-to-accessible-heading-structure/).
## Visual sizes for page sections and subsections
---
```vue-html
<Heading h1="Page heading" page-heading />
```
<Heading h1="Page heading" page-heading />
Use this visual size on the main heading of the page.
---
```vue-html
<Heading h3="Section heading" section-heading />
```
<Heading h3="Section heading" section-heading />
Use section headings to subdivide the main content on the page. Also use for modal headings. This is the default visual size.
---
```vue-html
<Heading h3="Subsection heading" subsection-heading />
```
<Heading h3="Subsection heading" subsection-heading />
Use subsection headings to break long sections or forms with several groups into digestible subsections.
## Visual sizes for special elements
---
```vue-html
<Heading h4="Caption" caption />
```
<Heading h4="Caption" caption />
Caption-style headings are found only within forms.
---
```vue-html
<Heading h3="Title" title />
```
<Heading h3="Title" title />
Use this visual size to title [Tabs](/components/ui/tabs), Channels, [Cards](/components/ui/card) and [Activities](/components/ui/activity).
---
```vue-html
<Heading h3="Radio" radio />
```
<Heading h3="Radio" radio />
Radio cards have giant titles.
---
```vue-html
<Heading h3="Secondary" secondary />
```
<Heading h3="Secondary" secondary />
A card may have a secondary title, [as exemplified in the designs](https://design.funkwhale.audio/#/workspace/a4e0101a-252c-80ef-8003-918b4c2c3927/e3a187f0-0f5e-11ed-adb9-fff9e854a67c?page-id=5e293790-52d3-11ed-9497-8deeaf0bfa97).
## Add content to the left and to the right
```vue-html
<Heading h2="Heading" page-heading>
<template #before>
(Before)
</template>
<template #after>
(After)
</template>
</Heading>
```
<Heading h2="Heading" page-heading>
<template #before>
(Before)
</template>
<template #after>
(After)
</template>
</Heading>

View File

@ -0,0 +1,166 @@
<script setup>
import { ref } from 'vue'
import Input from "~/components/ui/Input.vue"
import Button from "~/components/ui/Button.vue"
import Layout from "~/components/ui/Layout.vue"
import Spacer from "~/components/ui/Spacer.vue"
import Alert from "~/components/ui/Alert.vue"
const value = ref("Preset Value")
const search = ref("")
const user = ref("")
const password = ref("")
const reset = () => { console.log("Hello"); value.value = 'Original value' }
</script>
```ts
import Input from "~/components/ui/Input.vue"
```
# Input
Inputs are areas in which users can enter a single-line text or a number. Several [presets](#presets) are available.
| Prop | Data type | Required? | Description |
| ------------- | --------- | --------- | --------------------------------------------------------------------------- |
| `placeholder` | String | No | The placeholder text that appears when the input is empty. |
| `icon` | String | No | The [Bootstrap icon](https://icons.getbootstrap.com/) to show on the input. |
| `v-model` | String | Yes | The text entered in the input. |
Link a user's input to form data by referencing the data in a `v-model` of type `string`.
```ts
const value = ref("Preset Value");
```
```vue-html{2}
<Input v-model="value" placeholder="Your favorite animal" />
```
<Input v-model="value" placeholder="Your favorite animal" />
## Input icons
Add a [Bootstrap icon](https://icons.getbootstrap.com/) to an input to make its purpose more visually clear.
```vue-html{3}
<Input v-model="value" icon="bi-search" />
```
<Input v-model="value" icon="bi-search" />
## Label slot
```vue-html{2-4}
<Input v-model="user">
<template #label>
User name
</template>
</Input>
```
<Input v-model="user">
<template #label>
User name
</template>
</Input>
If you just have a string, we have a convenience prop, so instead you can write:
```vue-html
<Input v-model="user" label="User name" />
```
<Input v-model="user" label="User name" />
## Input-right slot
You can add a template on the right-hand side of the input to guide the user's input.
```vue-html{2-4}
<Input v-model="value" placeholder="Search">
<template #input-right>
suffix
</template>
</Input>
```
<Input v-model="search" placeholder="Search">
<template #input-right>
suffix
</template>
</Input>
## Color
See [Button](./button.md#button-colors) for a detailed overview of available props.
## Presets
### Search
```vue-html
<Input search v-model="search" />
```
<Input search v-model="search" />
### Password
```vue-html
<Spacer :size="64" />
<Layout form stack>
<Input v-model="user" label="User name" />
<Input password v-model="password" label="Password" />
<Layout flex>
<Button primary> Submit </Button>
<Button> Cancel </Button>
</Layout>
</Layout>
```
<Spacer :size="64" />
<Layout form stack>
<Input v-model="user" label="User name" />
<Input password v-model="password" label="Password" />
<Layout flex>
<Button primary> Submit </Button>
<Button> Cancel </Button>
</Layout>
</Layout>
::: tip
We use the spacer to simulate the baseline alignment on page layouts (64px between sections)
:::
### Add a reset option
```vue-html
<Input
v-model="value"
:reset="() => { value = 'Original value' }">
</Input>
```
<Input
v-model="value"
:reset="() => { value = 'Original value' }">
</Input>
## Fallthrough attributes
If you add attributes that are no props, they will be added to the component:
```vue-html
<Input v-model="password" required
field-id="password-field"
/>
```
<Input v-model="password" required
field-id="password-field"
/>

View File

@ -0,0 +1,195 @@
<script setup>
import { ref } from 'vue'
import Card from '~/components/ui/Card.vue'
import Alert from '~/components/ui/Alert.vue'
import Layout from '~/components/ui/Layout.vue'
import Spacer from '~/components/ui/Spacer.vue'
import Tab from '~/components/ui/Tab.vue'
import Tabs from '~/components/ui/Tabs.vue'
import Toggle from '~/components/ui/Toggle.vue'
import Button from '~/components/ui/Button.vue'
const isGrowing = ref(true)
const noGap = ref(true)
</script>
```ts
import Layout from "~/components/ui/Layout.vue"
```
# Layout
CSS provides [four methods to arrange items in a container](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_grid_layout/Relationship_of_grid_layout_with_other_layout_methods): Flow, Columns, Flex and Grid. To make typical alignment tasks in the Funkwhale UI easier, we have created a few useful presets.
By default, the items have a 32px gap. You can [change it with the `gap-x` prop](#gap-x-set-the-gap-to-one-of-the-defaults).
## Apply presets
The following containers are responsive. Change your window's size or select a device preset from your browser's dev tools to see how layouts are affected by available space.
<Layout flex gap-8>
<Card width="163px" title="flex" to="/components/ui/layout/flex" >
<Layout flex style="outline: 4px dashed var(--border-color)">
<Button primary icon="bi-eye" />
<Button outline icon="bi-eye" />
<Button destructive icon="bi-eye" />
</Layout>
</Card>
<Card width="163px" title="grid" to="/components/ui/layout/grid" >
<Layout grid column-width="40" style="outline: 4px dashed var(--border-color)">
<Button primary icon="bi-eye" />
<Button outline icon="bi-eye" style="grid-row: span 2; height: 100%;" />
<Button destructive icon="bi-eye" />
</Layout>
</Card>
<Card width="163px" title="stack" to="/components/ui/layout/stack">
<Layout stack no-gap style="margin:-8px; outline: 4px dashed var(--border-color)">
<Button primary icon="bi-eye" />
<Button outline icon="bi-eye" />
<Button destructive icon="bi-eye" />
</Layout></Card>
<Card width="163px" title="columns" to="/components/ui/layout/columns" >
<Layout columns column-width="40" style="outline: 4px dashed var(--border-color)">
<Button primary icon="bi-eye" />
<Button outline icon="bi-eye" />
<Button destructive icon="bi-eye" />
</Layout></Card>
</Layout>
## Add semantics
Add one of these props to your `Layout` component to turn them into semantic containers (without affecting their presentation):
**Headings:** [`"h1" | "h2" | "h3" | "h4" | "h5"`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/Heading_Elements#usage_notes)
**Sectioning:** [`"nav" | "aside" | "header" | "footer" | "main"`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/section#usage_notes)
**Forms:** [`"label" | "form"`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element#forms)
## Common props
### `gap-x`: Set the gap to one of the defaults
```ts
`gap-${'4' | '8' | '12' | '16' | '24' | '32' | '48' | '64' | 'auto'}`
```
### `no-gap`: Remove the gap between items
<Layout flex>
```vue
<script setup>
const noGap = ref(true);
</script>
<template>
<Toggle v-model="noGap" />
<Layout flex :no-gap="noGap || undefined">
<Card title="A" small />
<Card title="B" small />
<Card title="C" small />
<Card title="D" small />
</Layout>
</template>
```
<div class="preview" style="width:0">
<Toggle v-model="noGap" label="no-gap" />
---
<Layout flex :no-gap="noGap || undefined">
<Card title="A" tiny />
<Card title="B" tiny />
<Card title="C" tiny />
<Card title="D" tiny />
</Layout>
</div>
</Layout>
### Add fixed or flexible Spacers
::: info Only available on:
- **stack**
- **flex**
:::
If you add a spacer with attribute `grow`, it will push the other item until the Layout fills the available space. This only works if the parent element itself grows beyond its minimal contents.
<Layout flex>
```vue
<script setup>
const isGrowing = ref(true);
</script>
<template>
<Toggle v-model="isGrowing" />
<Layout stack style="height:25em;">
<Alert red />
<Alert purple />
<Spacer :grow="isGrowing || undefined" />
<Alert blue />
</Layout>
</template>
```
<div class="preview">
<Toggle v-model="isGrowing" label="grow"/>
---
<Layout stack style="height:25em">
<Alert red />
<Alert purple />
<Spacer :grow="isGrowing || undefined" />
<Alert blue />
</Layout>
</div>
</Layout>
Multiple spacers will distribute their growth evenly.
Note that you can set the minimum space occupied by the `Spacer` with its `size` prop [(docs)](./layout/spacer). Negative values can offset the gap of the `Layout` (but, due to a limitation of flexbox, not eat into the space occupied by adjacent items):
<Layout flex>
```vue
<template>
<Toggle v-model="isGrowing" />
<Layout stack style="height:35em;">
<Alert blue />
<Spacer :size="-32" :grow="isGrowing || undefined" />
<Alert green />
<Alert yellow />
<Spacer :size="-32" :grow="isGrowing || undefined" />
<Alert red />
</Layout>
</template>
```
<div class="preview" style="width:0">
<Toggle v-model="isGrowing" />
---
<Layout stack style="height:35em;">
<Alert blue />
<Spacer :size="-32" :grow="isGrowing || undefined" />
<Alert green />
<Alert yellow />
<Spacer :size="-32" :grow="isGrowing || undefined" />
<Alert red />
</Layout>
</div>
</Layout>

View File

@ -0,0 +1,101 @@
<script setup>
import { ref } from 'vue'
import Card from '~/components/ui/Card.vue'
import Alert from '~/components/ui/Alert.vue'
import Layout from '~/components/ui/Layout.vue'
import Spacer from '~/components/ui/Spacer.vue'
import Tab from '~/components/ui/Tab.vue'
import Tabs from '~/components/ui/Tabs.vue'
import Toggle from '~/components/ui/Toggle.vue'
import Button from '~/components/ui/Button.vue'
const isGrowing = ref(true)
const noGap = ref(true)
</script>
🡔 [Layout](../layout)
# Layout `columns`
Let items flow like words on a printed newspaper. Works for long, text-heavy content. Consider using `stack`, `flex` and `grid` instead if you want a homogeneous list of items to flow into multiple responsive columns.
<Layout stack no-gap>
```vue-html
<Layout columns :column-width="120">
<Button icon="bi-star"/>
<Button icon="bi-star"/>
<Button icon="bi-star"/>
Normal paragraph. Text flows like in a newspaper. Set the column width in px. Columns will be equally distributed over the width of the container.
<Card min-content title="Cards...">...break over columns...</Card>
<Button icon="bi-star"/>
<Button icon="bi-star"/>
<Button icon="bi-star"/>
</Layout>
```
<div class="preview">
<Layout columns column-width="120px">
<Button icon="bi-star"/>
<Button icon="bi-star"/>
<Button icon="bi-star"/>
---
Normal paragraph. Text flows like in a newspaper. Set the column width in px. Columns will be equally distributed over the width of the container.
---
<Card auto title="Cards...">...break over columns...</Card>
---
<Button icon="bi-star"/>
<Button icon="bi-star"/>
<Button icon="bi-star"/>
</Layout>
</div>
</Layout>
Make sure that all elements fit the column width, else they will leak into the neighboring columns.
### `no-rule`: Remove the rule (thin line) between columns
<Layout columns column-width="300px">
```vue-html
<Layout columns column-width="40px">
<div>Lorem ipsum dolor sit amet.</div>
</Layout>
<Layout columns no-rule column-width="40px">
<div>Lorem ipsum dolor sit amet.</div>
</Layout>
```
<Layout class="preview">
<Layout columns column-width="40px">
<div>Lorem ipsum dolor sit amet.</div>
</Layout>
<Layout columns no-rule column-width="40px">
<div>Lorem ipsum dolor sit amet.</div>
</Layout>
</Layout>
</Layout>
### Tricks
#### Prevent column breaks
Add the following style to the element that you want to preserve whole:
```css
style="break-inside: avoid;"
```

View File

@ -0,0 +1,54 @@
<script setup>
import { ref } from 'vue'
import Card from '~/components/ui/Card.vue'
import Alert from '~/components/ui/Alert.vue'
import Layout from '~/components/ui/Layout.vue'
import Spacer from '~/components/ui/Spacer.vue'
import Tab from '~/components/ui/Tab.vue'
import Tabs from '~/components/ui/Tabs.vue'
import Toggle from '~/components/ui/Toggle.vue'
import Button from '~/components/ui/Button.vue'
const isGrowing = ref(true)
const noGap = ref(true)
</script>
🡔 [Layout](../layout)
# Layout `flex`
Items are laid out in a row and wrapped as they overflow the container.
By default, all items in a row assume the same (maximum) height.
```vue-html
<Layout flex>
<Card title="A" style="width:100px; min-width:100px"></Card>
<Card title="B" :tags="['funk', 'dunk', 'punk']"></Card>
<Card title="C" style="width:100px; min-width:100px"></Card>
<Card title="D"></Card>
</Layout>
```
<Layout flex class="preview">
<Card title="A" style="width:100px; min-width:100px"></Card>
<Card title="B" :tags="['funk', 'dunk', 'punk']"></Card>
<Card title="C" style="width:100px; min-width:100px"></Card>
<Card title="D"></Card>
</Layout>
## Use additional `flexbox` properties
<Layout flex
class="preview"
style="font-size:11px; font-weight:bold; --gap: 4px;">
--gap: 4px
<Alert red style="align-self: flex-end;">align-self: flex-end</Alert>
<Alert green style="flex-grow: 1;">flex-grow: 1</Alert>
<Alert yellow style="height: 5rem;">height: 5rem</Alert>
<Alert purple style="width: 100%;">width: 100%</Alert>
</Layout>
Find a list of all styles here: [Flexbox guide on css-tricks](https://css-tricks.com/snippets/css/a-guide-to-flexbox/).

View File

@ -0,0 +1,188 @@
<script setup>
import { ref } from 'vue'
import Card from '~/components/ui/Card.vue'
import Alert from '~/components/ui/Alert.vue'
import Layout from '~/components/ui/Layout.vue'
import Spacer from '~/components/ui/Spacer.vue'
import Tab from '~/components/ui/Tab.vue'
import Tabs from '~/components/ui/Tabs.vue'
import Toggle from '~/components/ui/Toggle.vue'
import Button from '~/components/ui/Button.vue'
const isGrowing = ref(true)
const noGap = ref(true)
</script>
🡔 [Layout](../layout)
# Layout `grid`
Align items both vertically and horizontally.
You can either specify the column width (default: 320px) or set the desired number of columns.
### Override the `column-width` (in px)
_Note that we set the width to match 2 columns plus one gap._
<Layout flex>
```vue-html{2}
<Layout grid
column-width="90px"
:width="`${2 * 90 + 32}px`"
>
<Alert yellow />
<Card auto title="90px" />
<Alert blue />
</Layout>
```
<Layout grid column-width="90px" class="preview" :width="`${2 * 90 + 32}px`">
<Alert yellow />
<Card auto title="90px" />
<Alert blue />
</Layout>
</Layout>
### Let elements span multiple rows, columns, or areas
```vue-html{1,2}
<Alert purple style="grid-column: span 5;" />
<Alert green style="grid-row: span 2;" />
<Card title="2"/>
<Card title="1" />
```
<Layout grid>
<Alert purple style="grid-column: span 5;" />
<Alert green style="grid-row: span 2;" />
<Card title="2"/>
<Card title="1" />
</Layout>
You can also span an element to a rectangle of multiple columns and rows, in the format `<row-start> / <column-start> / <row-end> / <column-end>`:
```vue-html
<Layout grid="auto / repeat(5, min-content)">
<Card auto title="A" />
<Card auto title="B" />
<Card auto title="C" />
<Card auto title="D" style="grid-area: 2 / 1 / 4 / 5;" />
<Card auto title="E" />
<Card auto title="F" />
<Card auto title="G" />
<Card auto title="H" />
<Card auto title="I" />
</Layout>
```
<Layout grid="auto / repeat(5, min-content)">
<Card auto title="A" />
<Card auto title="B" />
<Card auto title="C" />
<Card auto title="D" style="grid-area: 2 / 1 / 4 / 5;" />
<Card auto title="E" />
<Card auto title="F" />
<Card auto title="G" />
<Card auto title="H" />
<Card auto title="I" />
</Layout>
### Custom grid configuration
You can pass any valid CSS `grid` value to the `grid` prop.
#### Minimal width, fit as many as possible on one row
```vue-html
<Layout grid="auto / repeat(auto-fit, minmax(max-content, 200px))">
<Alert green />
<Alert red>Very long text that will force other items to wrap</Alert>
<Alert blue />
<Alert yellow />
<Alert purple />
</Layout>
```
<Layout class="preview" grid="auto / repeat(auto-fit, minmax(max-content, 200px))">
<Alert green />
<Alert red>Very long text that will force other items to wrap</Alert>
<Alert blue />
<Alert yellow />
<Alert purple />
</Layout>
#### Up to 2 in a row, between 230-x and 300px
```vue-html
<Layout no-gap
grid="auto / repeat(auto-fit, minmax(230px, min(50%, 300px)))"
```
<Layout class="preview" no-gap grid="auto / repeat(auto-fit, minmax(230px, min(50%, 300px)))">
<Alert red />
<Alert yellow />
<Alert blue />
</Layout>
#### Up to 5 in a row, between 230-x and 300px
```vue-html
<Layout no-gap
grid="auto / repeat(auto-fit, minmax(120px, min(20%, 130px)))"
```
<Layout class="preview" no-gap grid="auto / repeat(auto-fit, minmax(120px, min(20%, 130px)))">
<Alert red />
<Alert yellow />
<Alert blue />
<Alert red />
<Alert yellow />
<Alert blue />
</Layout>
#### As many as fit, at least 100px, stretch them if necessary
```vue-html
<Layout
grid="auto / repeat(auto-fit, minmax(100px, 2fr))"
```
<Layout class="preview" grid="auto / repeat(auto-fit, minmax(100px, 2fr))">
<Alert red />
<Alert yellow />
<Alert blue />
<Alert red />
<Alert yellow />
<Alert blue />
</Layout>
#### Three columns of different widths, stretch gaps if necessary
```vue-html
<Layout
grid="auto / 100px 200px 100px"
style="justify-content: space-between"
```
<Layout class="preview" grid="auto / 100px 200px 100px" style="justify-content:space-between">
<Alert red />
<Alert yellow />
<Alert blue />
<Alert red />
<Alert yellow />
<Alert blue />
</Layout>
Note that on slim screens, the content will overflow here because the grid has no way to shrink under 464px.
### Debugging Layouts
The browser's devtools can visualize the components of a grid.
![alt text](image.png)
And read [the illustrated overview of all grid properties on css-tricks.com](https://css-tricks.com/snippets/css/complete-guide-grid/).

View File

@ -0,0 +1,88 @@
<script setup lang="ts">
import Header from '~/components/ui/Header.vue'
import Layout from '~/components/ui/Layout.vue'
import Spacer from '~/components/ui/Spacer.vue'
import Button from '~/components/ui/Button.vue'
</script>
```ts
import Header from '~/components/ui/Header.vue'
```
# Page header
Place the `Header` at the beginning of a page. Choose an appropriate heading level: `h1` or `h2` or `h3`. Choose `h1` unless the header is part of a page subsection or a modal. You can use all props for [Heading](../heading.md), including the [stylistic variants](../heading.md#visual-sizes-for-page-sections-and-subsections) such as `radio` or `page-heading`.
```vue-html
<Header
page-heading
h1="My title"
/>
```
<Header
page-heading
h1="My title"
/>
For a detailed explanation of the props, read [the entry on `Section`](section.md)
### Add an image
Use the `<template #image>` slot to place a picture to the left of the header.
```vue-html
<Header h1="My title">
<template #image>
<img
style="width: 196px;"
src="https://images.unsplash.com/photo-1524650359799-842906ca1c06?ixlib=rb-1.2.1&dl=te-nguyen-Wt7XT1R6sjU-unsplash.jpg&w=640&q=80&fm=jpg&crop=entropy&cs=tinysrgb" />
</template>
<div style="height: 48px;">
My subtitle
</div>
<Layout flex gap-8>
<Button outline square>A</Button>
<Button outline square>B</Button>
<Spacer h grow />
<Button outline square>C</Button>
</Layout>
</Header>
```
<Header h1="My title">
<template #image>
<img
style="width: 196px;"
src="https://images.unsplash.com/photo-1524650359799-842906ca1c06?ixlib=rb-1.2.1&dl=te-nguyen-Wt7XT1R6sjU-unsplash.jpg&w=640&q=80&fm=jpg&crop=entropy&cs=tinysrgb" />
</template>
<div style="height: 48px;">
My subtitle
</div>
<Layout flex gap-8>
<Button outline square>A</Button>
<Button outline square>B</Button>
<Spacer h grow />
<Button outline square>C</Button>
</Layout>
</Header>
::: tip Responsive layout:
On narrow screens, the header will first wrap between image and title/content area.
Make sure to keep the minimum width of the title and the content area narrow to prevent unnecessary wrapping.
:::
::: tip Spacing:
The distance between the image and the content area is 24px (`gap-24`). The title baseline is at 68px below top.
[-> Reference design (Penpot)](https://design.funkwhale.audio/#/workspace/a4e0101a-252c-80ef-8003-918b4c2c3927/e3a187f0-0f5e-11ed-adb9-fff9e854a67c?page-id=6ca536f0-0f5f-11ed-adb9-fff9e854a67c)
:::
### Add an action to the right of the heading
-> Use the `action` prop [which is the same as in the `Section` component](/components/ui/layout/section#provide-an-action).

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

View File

@ -0,0 +1,439 @@
<script setup lang="ts">
import { ref } from 'vue'
import { type Track, type User } from '~/types'
import Card from '~/components/ui/Card.vue'
import Layout from '~/components/ui/Layout.vue'
import Toggle from '~/components/ui/Toggle.vue'
import Spacer from '~/components/ui/Spacer.vue'
import Pill from '~/components/ui/Pill.vue'
import Activity from '~/components/ui/Activity.vue'
import Section from '~/components/ui/Section.vue'
const alignLeft = ref(true)
const track: Track = {
id: 0,
fid: "",
title: 'Some lovely track',
description: {
content_type: 'text/markdown',
text: `**New:** Music for the eyes!`
},
cover: {
uuid: "",
urls: {
original: 'https://images.unsplash.com/photo-1524650359799-842906ca1c06?ixlib=rb-1.2.1&dl=te-nguyen-Wt7XT1R6sjU-unsplash.jpg&w=640&q=80&fm=jpg&crop=entropy&cs=tinysrgb',
medium_square_crop: 'https://images.unsplash.com/photo-1524650359799-842906ca1c06?ixlib=rb-1.2.1&dl=te-nguyen-Wt7XT1R6sjU-unsplash.jpg&w=640&q=80&fm=jpg&crop=entropy&cs=tinysrgb',
large_square_crop: 'https://images.unsplash.com/photo-1524650359799-842906ca1c06?ixlib=rb-1.2.1&dl=te-nguyen-Wt7XT1R6sjU-unsplash.jpg&w=640&q=80&fm=jpg&crop=entropy&cs=tinysrgb'
}
},
tags: ["example"],
uploads: [],
downloads_count: 1927549377,
artist_credit: [{
artist: {
id: 0,
fid: "",
name: "The Artist",
description: {
content_type: 'text/markdown',
text: `I'm a musician based on the internet.
Find all my music on [Funkwhale](https://funkwhale.audio)!`},
tags: [],
content_category: 'music',
albums: [],
tracks_count: 1,
attributed_to: {
id: 0,
summary: "",
preferred_username: "User12345",
full_username: "User12345",
is_local: false,
domain: "myDomain.io"
},
is_local: false,
is_playable: true
},
credit: "",
joinphrase: " and ",
index: 22
}],
disc_number: 7,
listen_url: "https://funkwhale.audio",
creation_date: "12345",
attributed_to: {
id: 0,
summary: "",
preferred_username: "User12345",
full_username: "User12345",
is_local: false,
domain: "myDomain.io"
},
is_playable: true,
is_local: false
}
const user: User = {
id: 12,
avatar: {
uuid: "",
urls: {
original: 'https://images.unsplash.com/photo-1524650359799-842906ca1c06?ixlib=rb-1.2.1&dl=te-nguyen-Wt7XT1R6sjU-unsplash.jpg&w=640&q=80&fm=jpg&crop=entropy&cs=tinysrgb',
medium_square_crop: 'https://images.unsplash.com/photo-1524650359799-842906ca1c06?ixlib=rb-1.2.1&dl=te-nguyen-Wt7XT1R6sjU-unsplash.jpg&w=640&q=80&fm=jpg&crop=entropy&cs=tinysrgb',
large_square_crop: 'https://images.unsplash.com/photo-1524650359799-842906ca1c06?ixlib=rb-1.2.1&dl=te-nguyen-Wt7XT1R6sjU-unsplash.jpg&w=640&q=80&fm=jpg&crop=entropy&cs=tinysrgb'
}
},
email: "user12345@example.org",
summary: { text: "Hi! I'm Example from The Internet.", content_type: "text" },
username: "user12345",
full_username: "user12345",
instance_support_message_display_date: "?",
funkwhale_support_message_display_date: "?",
is_superuser: true,
privacy_level: "everyone"
}
const sections = ref<boolean[]>([false, false, false])
</script>
```ts
import Section from '~/components/ui/Section.vue'
```
# Layout section
Sections divide the page vertically. Choose an appropriate heading level for each section.
You can use all props for [Heading](../heading.md), including `h1` to `h6` and [stylistic variants](../heading.md#visual-sizes-for-page-sections-and-subsections) such as `radio` or `page-heading`.
```vue-html
<Section h1="My title" />
```
<Spacer />
<Section h1="My title" />
```vue-html
<Section
h2="My title"
radio
/>
```
<Spacer />
<Section
h2="My title"
radio
/>
## Align the section
```vue-html
<Section h2="My title" alignLeft />
```
### Make the section header align with the section contents
The section aligns its title and items to a grid, following the designs. To make sure the header of a section exactly aligns with its contents, set the item width (in number of columns). For example,
<style module>
.table {
margin: 0 -184px;
transform: scale(80%);
}
.table div[class*='language-'] {
margin: -8px -16px !important;
}
</style>
<Layout grid :class="$style.table">
<Card title="Mixed content">
```vue-html
:columns-per-item="1"
```
</Card>
<Card title="Normal cards">
```vue-html
:columns-per-item="3"
```
</Card>
<Card title="Large cards, Activities">
```vue-html
:columns-per-item="4"
```
</Card>
</Layout>
For a complete overview of column widths for common funkwhale components, see [the table in using-width](../using-width.md#widths-in-the-grid)
### Move individual items within and across grid-cells
For child items, you can use all known CSS grid placement techniques:
<Layout grid :class="$style.table">
<Card title="Stretch over all columns">
```css
grid-column: 1 / -1;
```
Fill the whole grid, no matter how wide the screen is
</Card>
<Card title="Span multiple rows/columns">
```css
grid-row: span 3
```
</Card>
<Card title="Move within grid cell">
```css
align-self: start;
justify-self: center;
```
Place individual items to the edge of their current cell or cells
</Card>
</Layout>
## Provide an action
The link or button will be shown on the right side of the header. Use `action.text` to set the label (required).
You can use all [`Link` props](../link.md) or [`Button` props](../button.md) inside the `action` prop! Note that the button or link label will be in line with the heading.
```vue-html
<Spacer />
<Layout stack gap-64>
<Section
h2="With a link"
:action="{
text: 'My library',
to: '/',
icon: 'bi-star'
}"
/>
<Section
h2="With a button"
:action="{
text: 'Say hello!',
onClick: ()=>console.log('Hello'),
primary: true,
solid: true
}"
/>
</Layout>
```
<Spacer />
<Layout stack gap-64>
<Section
h2="With a link"
:action="{
text: 'My library',
to: '/',
icon: 'bi-star'
}"
/>
<Section
h2="With a button"
:action="{
text: 'Say hello!',
onClick: ()=>console.log('Hello'),
primary: true,
solid: true
}"
/>
</Layout>
## Add icons and slots
```vue-html
<Section
icon="bi-heart"
>
<template #topleft>
<Pill>#Audiology</Pill>
<Spacer size-12 />
<Pill>#Phonologics</Pill>
</template>
</Section>
```
<Spacer />
<Section
icon="bi-heart"
>
<template #topleft>
<Pill>#Audiology</Pill>
<Spacer size-12 />
<Pill>#Phonologics</Pill>
</template>
</Section>
## Set gaps between consecutive sections
Place consecutive sections into a [Layout stack](../layout) with a 64px gap (`gap-64`) to give them a regular vertical rhythm. Use different sizes for very small or very large headings.
Note the spacer above the layout. By default, sections begin at the baseline of the heading. This enables us to explicitly define the vertical rhythm, independently of the heading's line height.
## Mix sections of different item widths
```vue-html
<Layout flex>
<Toggle v-model="alignLeft" label="Left-align the layout"/>
</Layout>
<Spacer />
<Layout stack gap-64>
<Section
:alignLeft="alignLeft"
:columns-per-item="2"
h2="Cards (2-wide items)"
:action="{
text:'Documentation on Cards',
to:'../card'
}"
>
<Card small default solid raised title="Relatively Long Album Name">
Artist Name
</Card>
<Card small default solid raised title="Relatively Long Album Name">
Artist Name
</Card>
<Card small default solid raised title="Relatively Long Album Name">
Artist Name
</Card>
</Section>
<Section
:alignLeft="alignLeft"
:columns-per-item="3"
h2="Activities (3-wide items)"
:action="{
text:'Delete selected items',
onClick:()=>console.log('Deleted :-)')
}"
>
<Activity :track="track" :user="user" />
<Activity :track="track" :user="user" />
<Activity :track="track" :user="user" />
</Section>
</Layout>
```
<Layout flex>
<Toggle v-model="alignLeft" label="Left-align the layout"/>
</Layout>
---
<Spacer />
<Layout stack gap-64 class="preview" style="margin: 0 -40px; padding: 0 25px;">
<Section
:alignLeft="alignLeft"
:columns-per-item="3"
h2="Cards (2-wide items)"
:action="{
text:'Documentation on Cards',
to:'../card'
}"
>
<Card small default solid raised title="Relatively Long Album Name">
Artist Name
</Card>
<Card small default solid raised title="Relatively Long Album Name">
Artist Name
</Card>
<Card small default solid raised title="Relatively Long Album Name">
Artist Name
</Card>
</Section>
<Section
:alignLeft="alignLeft"
:columns-per-item="4"
h2="Activities (3-wide items)"
:action="{
text:'Delete selected items',
onClick:()=>console.log('Deleted :-)')
}"
>
<Activity :track="track" :user="user" />
<Activity :track="track" :user="user" />
<Activity :track="track" :user="user" />
</Section>
</Layout>
## Collapse and expand the section
By adding either `collapse` or `expand` to the props, you add Accordion behavior to the section.
The heading will become a clickable button.
```ts
const sections = ref([false, false, false])
```
```vue-html
<Section
v-for="(section, index) in sections"
:key="`${index}${section}`"
:h2="`Section ${index} (${section})`"
align-left
v-bind="
section
? { collapse: () => { sections[index] = false } }
: { expand: () => { sections[index] = true } }
"
>
Content {{ section }}
</Section>
```
<Section
v-for="(section, index) in sections"
:key="`${index}${section}`"
:h2="`Section ${index}`"
align-left
v-bind="
section
? { collapse: () => { sections[index] = false } }
: { expand: () => { sections[index] = true } }
"
>
<Card
title="Content"
full
/>
</Section>
## Responsivity
- Cards and Activities snap to the grid columns. They have intrinsic widths, expressed in the number of columns they span. For `Card`, it is `3` and for `Activity`, it is `4`.
- On a typical laptop screen, you may have 4 album cards or 3 activities side-by-side. On a typical mobile screen, you will have one medium card or two small ones in a row.
- The remaining space is evenly distributed.
- Title rows align with the content below them. The action on the right will always end with the last item in the grid. Resize the window to observe how the items move.

View File

@ -0,0 +1,186 @@
<script setup lang="ts">
import { ref } from 'vue';
import Alert from '~/components/ui/Alert.vue'
import Button from '~/components/ui/Button.vue'
import Layout from '~/components/ui/Layout.vue'
import Spacer from '~/components/ui/Spacer.vue'
import Input from '~/components/ui/Input.vue'
import Card from '~/components/ui/Card.vue'
const size = ref(32)
</script>
<style>
.preview {
padding:16px 0;
flex-grow:1;
}
</style>
```ts
import Spacer from '~/components/ui/Spacer.vue'
```
# Spacer
Add a 16px gap between adjacent items.
##### Without spacer
<Layout flex>
```vue-html
<Alert green">A</Alert>
<Alert red">B</Alert>
```
<div class="preview">
<Alert green>A</Alert>
<Alert purple>B</Alert>
</div>
</Layout>
##### With spacer
<Layout flex>
```vue-html{2}
<Alert green">A</Alert>
<Spacer/>
<Alert red">B</Alert>
```
<div class="preview">
<Alert green>A</Alert>
<Spacer/>
<Alert purple>B</Alert>
</div>
</Layout>
##### Spacers can also be added for horizontal space
<Layout flex>
```vue-html{4}
<Layout flex>
<Alert blue">A</Alert>
<Alert yellow">B</Alert>
<Spacer/>
<Alert red">C</Alert>
</Layout>
```
<div class="preview">
<Layout flex>
<Alert blue>A</Alert>
<Alert yellow>B</Alert>
<Spacer/>
<Alert red>C</Alert>
</Layout>
</div>
</Layout>
## Modify the size of a Spacer
<Layout flex>
```vue
<script setup>
const size = ref(1);
</script>
<template>
<Input v-model="size" type="range" />
<Alert yellow>A</Alert>
<Spacer :size="+size" />
<Alert purple>B</Alert>
</template>
```
<div class="preview">
<Input v-model="size" type="range" style="writing-mode: vertical-lr; direction: rtl"/>
{{ size }}px
</div>
<div class="preview">
<Alert yellow>A</Alert>
<Spacer :size="+size" />
<Alert purple>B</Alert>
</div>
</Layout>
Note the `+` before `size`. Some browsers will output a string for the `Input` value, and the JavaScript `+` prefix parses it into a number. Spacers need numeric `size` values because positive size values affect the dimensions of the element while negative sizes cause negative margins.
## Make the Spacer elastic (responsive)
<Layout flex>
<div style="width: min-content;">
```vue-html
<Layout stack no-gap
:style="{ height: size + '%' }"
>
<Button min-content outline>A</Button>
<Spacer grow title="grow" />
<Button min-content outline>B</Button>
<Button min-content outline>C</Button>
<Spacer shrink title="shrink" />
<Button min-content outline>D</Button>
<Spacer grow shrink title="grow shrink" />
<Button min-content outline>E</Button>
</Layout>
```
</div>
<div class="preview" style="flex-wrap:nowrap">
<Layout flex style="height:30em">
<Input v-model="size" type="range" style="writing-mode: vertical-lr; height:100%"><template #input-right>{{ size }}%</template></Input>
<Layout stack no-gap :style="{ height: size + '%'}">
<Button min-content outline>A</Button>
<Spacer grow title="grow" />
<Button min-content outline>B</Button>
<Button min-content outline>C</Button>
<Spacer shrink title="shrink" />
<Button min-content outline>D</Button>
<Spacer grow shrink title="grow shrink" />
<Button min-content outline>E</Button>
</Layout>
</Layout>
</div>
</Layout>
## Restrict the dimension of a spacer with the `v` and `h` props
By default, a spacer is square. You can make it horizontal with `h` and vertical with `v`. See the example in the following section.
## Use the Spacer to vary an element's dimensions
```vue-html
<Input v-model="size" type="range" />
<Card min-content title="h">
<Spacer h :size="size * 4" style="border:5px dashed;" />
</Card>
<Card min-content title="v">
<Spacer v :size="size * 4" style="border:5px dashed;" />
</Card>
```
<Layout flex style="align-items:flex-start">
<Input v-model="size" type="range" />
<Card min-content title="h">
<Spacer h :size="size * 4" style="border:5px dashed;" />
</Card>
<Card min-content title="v">
<Spacer v :size="size * 4" style="border:5px dashed;" />
</Card>
</Layout>

View File

@ -0,0 +1,45 @@
<script setup>
import { ref } from 'vue'
import Card from '~/components/ui/Card.vue'
import Alert from '~/components/ui/Alert.vue'
import Layout from '~/components/ui/Layout.vue'
import Spacer from '~/components/ui/Spacer.vue'
import Tab from '~/components/ui/Tab.vue'
import Tabs from '~/components/ui/Tabs.vue'
import Toggle from '~/components/ui/Toggle.vue'
import Button from '~/components/ui/Button.vue'
const isGrowing = ref(true)
const noGap = ref(true)
</script>
🡔 [Layout](../layout)
# Layout `stack`
Add space between vertically stacked items
<Layout flex>
```vue-html
<Layout stack>
<Card title="A"></Card>
<Card title="B"></Card>
<Card title="C"></Card>
<Card title="D"></Card>
<Card title="E"></Card>
</Layout>
```
<div class="preview">
<Layout stack>
<Card title="A"></Card>
<Card title="B"></Card>
<Card title="C"></Card>
<Card title="D"></Card>
<Card title="E"></Card>
</Layout>
</div>
</Layout>

View File

@ -0,0 +1,211 @@
<script setup lang="ts">
import Table from "~/components/ui/Table.vue";
import Link from "~/components/ui/Link.vue";
import Button from "~/components/ui/Button.vue"; // Need to import this, else vitepress will not load the stylesheet with the colors.
</script>
```ts
import Table from '~/components/ui/Table.vue'
```
# Table
Arrange cells in a grid.
For every row, add exactly one element per column.
<Link
to="https://design.funkwhale.audio/#/workspace/a4e0101a-252c-80ef-8003-918b4c2c3927/e3a187f0-0f5e-11ed-adb9-fff9e854a67c?page-id=6ca536f0-0f5f-11ed-adb9-fff9e854a67c">
Design [Penpot]
</Link>
**Prop** `grid-template-columns`: An array of the column widths. You can make a column either fixed-width or use part of the available space:
- `100px`: Exactly this width
- `auto`: The the widest cell in the column
- `1fr`: A certain fraction of the remaining space
The whole table always has a width of 100% (or `stretch`, if inside a flex or grid context). If the minimum sizes don't fit, all cells will shrink proportionally.
```vue-html
<Table :grid-template-columns="['100px', 'auto', '2fr', '1fr']">
<b>100px</b>
<b>auto</b>
<b>2fr</b>
<b>1fr</b>
</Table>
```
<Table :grid-template-columns="['100px', 'auto', '2fr', '1fr']">
<b>100px</b>
<b>auto</b>
<b>2fr</b>
<b>1fr</b>
</Table>
## Add a table header
Use the `#header` slot to add items to the header. Make sure to add one item per column.
```vue-html
<Table :grid-template-columns="['3fr', '1fr']">
<template #header>
<label >Column 1</label>
<label >Column 2</label>
</template>
<label>A</label>
<label>B</label>
</Table>
```
<Table :grid-template-columns="['3fr', '1fr']">
<template #header>
<label >Column 1</label>
<label >Column 2</label>
</template>
<label>A</label>
<label>B</label>
</Table>
## Let cells span multiple rows or columns
- Cells automatically expand into the **next column** if the following item is empty. Make sure the first element in each row is not empty!
- You can let elements span **multiple rows** by adding `style=grid-row: span 3`. In this case, you have to remove the corresponding cells directly below.
```vue-html
<Table :grid-template-columns="['48px', '48px', 'auto', 'auto', 'auto', '48px', '64px', '48px']">
<template #header>
<label></label>
<label></label>
<label>Title</label>
<label>Album</label>
<label>Artist</label>
<label></label>
<label></label>
<label></label>
</template>
<!-- Row 1 -->
<div> </div>
<div>C1</div>
<div>Title 1</div>
<div></div>
<div>Artist 1</div>
<div></div>
<div>D1</div>
<div></div>
<!-- Row 2 -->
<div>B2</div>
<div>C2</div>
<div>Title 2</div>
<div style="grid-row: span 2; height: auto; background: blue;">Album 2</div>
<div>Artist 2</div>
<div>F2</div>
<div>D2</div>
<div></div>
<!-- Row 3 -->
<div>B3</div>
<div>C3</div>
<div>Title 3</div>
<div>Artist 3</div>
<div>F3</div>
<div>D3</div>
<div></div>
<!-- Row 4 -->
<div>B4</div>
<div>C4</div>
<div>Title 4</div>
<div></div>
<div></div>
<div>F4</div>
<div>D4</div>
<div></div>
</Table>
```
<Table :grid-template-columns="['48px', '48px', 'auto', 'auto', 'auto', '48px', '64px', '48px']">
<template #header>
<label></label>
<label></label>
<label>Title</label>
<label>Album</label>
<label>Artist</label>
<label></label>
<label></label>
<label></label>
</template>
<!-- Row 1 -->
<div> </div>
<div>C1</div>
<div>Title 1</div>
<div></div>
<div>Artist 1</div>
<div></div>
<div>D1</div>
<div></div>
<!-- Row 2 -->
<div>B2</div>
<div>C2</div>
<div>Title 2</div>
<div style="grid-row: span 2; height: auto; background: blue;">Album 2</div>
<div>Artist 2</div>
<div>F2</div>
<div>D2</div>
<div></div>
<!-- Row 3 -->
<div>B3</div>
<div>C3</div>
<div>Title 3</div>
<div>Artist 3</div>
<div>F3</div>
<div>D3</div>
<div></div>
<!-- Row 4 -->
<div>B4</div>
<div>C4</div>
<div>Title 4</div>
<div></div>
<div></div>
<div>F4</div>
<div>D4</div>
<div></div>
</Table>
## Add props to the table header
To add class names and other properties to the table header, set the `header-props` prop. In the following example, the whole header section is made inert.
Note that `style` properties may not have an effect because the header section is `display: contents`. Instead, add a custom attribute and add scoped style rule targeting your attribute.
```vue-html
<Table
:grid-template-columns="['1fr', '1fr']"
:header-props="{ inert: '' }"
>
<template #header>
<label>
<Button low-height primary @click="console.log('clicked 1')">1</Button>
</label>
<label>
<Button low-height primary @click="console.log('clicked 2')">2</Button>
</label>
</template>
</Table>
```
<Table
:grid-template-columns="['1fr', '1fr']"
:header-props="{ inert: '' }"
>
<template #header>
<Button low-height primary @click="console.log('clicked 1')">1</Button>
<Button low-height primary @click="console.log('clicked 2')">2</Button>
</template>
</Table>

View File

@ -0,0 +1,190 @@
<script setup lang="ts">
import { useModal, fromProps, notUndefined } from '~/ui/composables/useModal.ts'
import Modal from '~/components/ui/Modal.vue'
import Link from '~/components/ui/Link.vue'
import Button from '~/components/ui/Button.vue'
import Layout from '~/components/ui/Layout.vue'
import Card from '~/components/ui/Card.vue'
import Alert from '~/components/ui/Alert.vue'
const { to, isOpen } = useModal('flag')
</script>
```ts
import Link from "~/components/ui/Link.vue"
```
# Link
Users can navigate by following Links. They expect that in contrast to clicking a [button](button), following a link does not manipulate items or trigger any action.
This component will render as [an `<a>` element [MDN]](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a).
```vue-html
<Link to="/">
Home
</Link>
```
<Link to="/">
Home
</Link>
Instead of a route, you can set the prop `to` to any web address starting with `http`.
## `Active` states
- If any ancestor path matches, the `.router-link-active` class is added
- If the whole path matches, the `.router-link-exact-active` class is added
See the [Vue docs](https://router.vuejs.org/guide/essentials/active-links) for a primer on Path matching.
In addition to the standard Vue `RouterLink` path matching function, we use this algorithm:
- If the destination of the link contains any query parameter _and_ none of these is set (i.e. they are all `undefined`), then the class `.router-link-no-matching-query-flags` is added.
This is particularly useful for modals.
<Link ghost :to="useModal('flag').to">
Open modal
</Link>
<Modal v-model="isOpen" title="Modal">
</Modal>
## Colors and Variants
See [Using color](/using-color)
<Layout grid solid default style="place-items: baseline;">
<Card full title="">
<p>
A paragraph of text with an inline (uncolored) link: <Link to="/"> no color </Link>
</p>
<Layout flex>
<Link default to="/">
default
</Link>
<Link primary to="/">
primary
</Link>
<Link secondary to="/">
secondary
</Link>
<Link destructive to="/">
destructive
</Link>
</Layout>
</Card>
<Card default solid raised title="Solid" style="grid-row: span 3;">
<Layout stack>
<Link default solid to="/">
default solid
</Link>
<Link primary solid to="/">
primary solid
</Link>
<Link secondary solid to="/">
secondary solid
</Link>
<Link destructive solid to="/">
destructive solid
</Link>
</Layout>
</Card>
<Card default solid raised title="Outline" style="grid-row: span 4;">
_Only use on top of solid surfaces, else the text may be unreadable!_
<Alert v-for="color in ['default', 'primary', 'secondary', 'destructive']" :class="color">
<Link outline to="/">
{{ color }} outline
</Link>
</Alert>
</Card>
<Card default solid raised title="Ghost">
<Layout stack>
<Link default ghost to="/">
default ghost
</Link>
<Link primary ghost to="/">
primary ghost
</Link>
<Link secondary ghost to="/">
secondary ghost
</Link>
<Link destructive ghost to="/">
destructive ghost
</Link>
</Layout>
</Card>
</Layout>
## Shapes
```vue-html
<Link primary solid round to="/">
Home
</Link>
```
<Link primary solid round to="/">
Home
</Link>
## Set width and alignment
See [Using width](/using-width) and [Using alignment](/using-alignment).
<Layout flex>
```vue-html
<Link solid primary min-content to="/">min-content</Link>
<Link solid primary tiny to="/">tiny</Link>
<Link solid primary buttonWidth to="/">buttonWidth</Link>
<Link solid primary small to="/">small</Link>
<Link solid primary medium to="/">medium</Link>
<Link solid primary full to="/">full</Link>
<Link solid primary auto to="/">auto</Link>
<hr />
<Link solid primary alignSelf="start" to="/">🐌</Link>
<Link solid primary alignSelf="center" to="/">🐌</Link>
<Link solid primary alignSelf="end" to="/">🐌</Link>
<hr />
<Link solid primary alignText="left" to="/">🐌</Link>
<Link solid primary alignText="center" to="/">🐌</Link>
<Link solid primary alignText="right" to="/">🐌</Link>
```
<Layout class="preview" stack style="--gap:4px;">
<Link solid primary min-content to="/">min-content</Link>
<Link solid primary tiny to="/">tiny</Link>
<Link solid primary buttonWidth to="/">buttonWidth</Link>
<Link solid primary small to="/">small</Link>
<Link solid primary medium to="/">medium</Link>
<Link solid primary full to="/">full</Link>
<Link solid primary auto to="/">auto</Link>
<hr />
<Link solid primary alignSelf="start" to="/">🐌</Link>
<Link solid primary alignSelf="center" to="/">🐌</Link>
<Link solid primary alignSelf="end" to="/">🐌</Link>
<hr />
<Link solid primary alignText="left" to="/">🐌</Link>
<Link solid primary alignText="center" to="/">🐌</Link>
<Link solid primary alignText="right" to="/">🐌</Link>
</Layout>
</Layout>

View File

@ -0,0 +1,56 @@
<script setup lang="ts">
import Loader from '~/components/ui/Loader.vue'
</script>
<style>
.docs-loader-container div[style^=width] {
border: 1px solid #666;
height: 2em;
}
</style>
```ts
import Loader from "~/components/ui/Loader.vue"
```
# Loader
Loaders visually indicate when an operation is loading. This makes it visually clear that the user can't interact with the element until the loading process is complete.
| Prop | Data type | Required? | Description |
| ----------- | --------- | --------- | -------------------------------------------- |
| `container` | Boolean | No | Whether to create a container for the loader |
## Normal loader
```vue-html
<div style="width: 50%">
<Loader />
</div>
```
<div class="docs-loader-container">
<div style="width: 50%">
<Loader />
</div>
</div>
## No container
By default the `<fw-loader />` component creates a container that takes up 100% of its parent's height. You can disable this by passing a `:container="false"` property. The loader renders centered in the middle of the first parent that has `position: relative` set.
```vue-html
<div style="position: relative">
<div style="width: 50%">
<Loader :container="false" />
</div>
</div>
```
<div class="docs-loader-container">
<div style="position: relative">
<div style="width: 50%">
<Loader :container="false" />
</div>
</div>
</div>

View File

@ -0,0 +1,348 @@
<script setup lang="ts">
import { ref, watchEffect } from 'vue'
import Alert from '~/components/ui/Alert.vue'
import Button from '~/components/ui/Button.vue'
import Input from '~/components/ui/Input.vue'
import Modal from '~/components/ui/Modal.vue'
import Layout from '~/components/ui/Layout.vue'
const isOpen = ref(false)
const isOpen2 = ref(false)
const isOpen3 = ref(false)
const alertOpen = ref(true)
watchEffect(() => {
if (isOpen3.value === false) {
alertOpen.value = true
}
})
const isOpen4 = ref(false)
const isOpen5 = ref(false)
const isOpen6 = ref(false)
const isOpen7 = ref(false)
const isOpen8 = ref(false)
const isOpen9 = ref(false)
const isOpen10 = ref(false)
const input = ref('Episcosaurus')
</script>
```ts
import Modal from "~/components/ui/Modal.vue"
```
# Modal
| Prop | Data type | Required? | Default | Description |
| --------- | ----------------- | --------- | ------- | ---------------------------------- |
| `title` | `string` | Yes | | The modal title |
| `v-model` | `true` \| `false` | No | | Whether the modal is isOpen or not |
<Layout flex>
```vue-html
<Button @click="isOpen = true">
Open modal
</Button>
<Modal v-model="isOpen" title="My modal">
Modal content
</Modal>
```
<div class="preview">
<Button primary @click="isOpen = true">
Open modal
</Button>
<Modal v-model="isOpen" title="My modal">
Modal content
</Modal>
</div>
</Layout>
## Add actions
Use the `#actions` slot to add actions to a modal. Actions typically take the form of [buttons](./button).
Make sure to add `autofocus` to the preferred button.
<Layout flex>
```vue-html
<Button @click="isOpen = true">
Open modal
</Button>
<Modal v-model="isOpen" title="My modal">
Modal content
<template #actions>
<Button secondary @click="isOpen2 = false" icon="bi-arrow-left"/>
<Button autofocus @click="isOpen = false">
Ok
</Button>
</template>
</Modal>
```
<div class="preview">
<Button primary @click="isOpen2 = true">
Open modal
</Button>
<Modal v-model="isOpen2" title="My modal">
Modal content
<template #actions>
<Button secondary @click="isOpen2 = false" icon="bi-arrow-left"/>
<Button primary autofocus @click="isOpen2 = false">
Ok
</Button>
</template>
</Modal>
</div>
</Layout>
### Add a cancel button
All modals can be closed so it makes sense to add a `cancel` button. Note that `Modal` also implements the `ESC` shortcut by default.
The `cancel` prop accepts a string and will add a cancel button.
Note that the Cancel button has `autofocus`. If you want another button to auto-focus, implement the Cancel button manually inside the `#action` slot.
## Customize the title bar
Use the `icon` prop and/or the `#topleft` slot for indicators such as the user's photo or a search input. You can hide the title by setting it to `""`.
<!-- prettier-ignore-start -->
<Button primary @click="isOpen10 = true">
Open modal
</Button>
<Modal v-model="isOpen10" title="" icon="bi-info-circle">
<template #topleft>
<Input ghost v-model="input" autofocus />
</template>
No information pages found for <code>{{ input }}</code>
</Modal>
<!-- prettier-ignore-end -->
## Add a main alert
You can nest [Funkwhale alerts](./alert) to visually highlight content within the modal.
<Layout flex>
```vue-html
<Button @click="isOpen = true">
Open modal
</Button>
<Modal v-model="isOpen8" title="My modal" cancel="Cancel">
Modal content
<template #alert v-if="alertOpen">
<Alert>
Alert content
<template #actions>
<Button autofocus @click="alertOpen = false">Close alert</Button>
</template>
</Alert>
</template>
</Modal>
```
<div class="preview">
<Button
primary
@click="isOpen8 = true"
>
Open modal
</Button>
<Modal v-model="isOpen8" title="My modal" cancel="Cancel">
Modal content
<template #alert v-if="alertOpen">
<Alert blue>
Alert content
<template #actions>
<Button autofocus @click="alertOpen = false">Close alert</Button>
</template>
</Alert>
</template>
<template #actions>
<Button primary @click="isOpen8 = false">
Ok
</Button>
</template>
</Modal>
</div>
</Layout>
## Confirm a dangerous action
Note that confirmation dialogs interrupt the user's workflow. Consider adding a recovery functionality such as "undo" instead.
::: tip Read more about designing user experiences around dangerous actions:
- [How to use visual signals and spacing to differentiate between benign and dangerous options](https://www.nngroup.com/articles/proximity-consequential-options/)
> If you need to implement dangerous actions, make sure to place them apart from other actions to prevent accidental clicks. Add contextual hints and information so that the user understands the consequences of the action.
- [How to design a confirmation dialog](https://www.nngroup.com/articles/confirmation-dialog/)
> 1. Let the user confirm potentially destructive actions
> 2. Do not use confirmation dialogs for routine tasks
> 3. Be specific about the action and its potential consequences
> 4. Label the response buttons with their result: "Delete my account" instead of "Yes"
> 5. Make sure to give the user all information they need to decide
:::
### Destructive Modal
The `destructive` prop is used to visually indicate a potentially dangerous or irreversible action. When set to `true`, the modal will have a distinctive red styling to draw the user's attention and highlight the critical nature of the action.
| Prop | Data type | Required? | Default | Description |
| ------------- | --------- | --------- | ------- | ---------------------------------------------------- |
| `destructive` | `true` | No | `false` | Applies a red styling to emphasize dangerous actions |
### Styling Effects
When the `destructive` prop is set to `true`, the modal will:
- Add a red border
- Style the title in red
To visually distinguish the modal from standard modals
### Best Practices
- Use the `destructive` prop sparingly
- Clearly explain the consequences of the action using `<Alert red>`
- Provide a clear way to cancel the action. If you use the `cancel` prop, the Cancel button will have autofocus, preventing accidental confirmation.
- Use descriptive action button labels
The example in the "Confirm a dangerous action" section demonstrates the recommended usage of the destructive modal.
<Layout flex>
```vue-html
<Button
@click="isOpen4 = true"
destructive
>
Delete my account
</Button>
<Modal
v-model="isOpen4"
title="Delete Account?"
destructive
cancel="Cancel"
>
<template #alert>
<Alert red>
Detailed consequences of the action
</Alert>
</template>
<template #actions>
<Button
destructive
@click="isOpen4 = false"
>
Confirm Deletion
</Button>
</template>
</Modal>
```
<!-- prettier-ignore-start -->
<Button
@click="isOpen4 = true"
destructive
>
Delete my account
</Button>
<Modal
v-model="isOpen4"
title="Delete Account?"
destructive
cancel="Cancel"
>
<template #alert>
<Alert red>
Detailed consequences of the action
</Alert>
</template>
<template #actions>
<Button
destructive
@click="isOpen4 = false"
>
Confirm Deletion
</Button>
</template>
</Modal>
<!-- prettier-ignore-end -->
</Layout>
## Nested modals
You can nest modals to allow users to isOpen a modal from inside another modal. This can be useful when creating a multi-step workflow.
<Layout flex>
```vue-html
<Button @click="isOpen = true">
Open modal
</Button>
<Modal v-model="isOpen" title="My modal">
<Modal v-model="isOpenNested" title="My modal">
Nested modal content
</Modal>
<Button autofocus @click="isOpenNested = true">
Open nested modal
</Button>
</Modal>
```
<div class="preview">
<Button
primary
@click="isOpen6 = true"
>
Open modal
</Button>
<Modal v-model="isOpen6" title="My modal">
<Modal v-model="isOpen7" title="My modal">
Nested modal content
</Modal>
<Button autofocus @click="isOpen7 = true">
Open nested modal
</Button>
</Modal>
</div>
</Layout>
## Auto-focus the close button
If there are no action slots and no cancel button, the close button is auto-focused.
The `autofocus` prop, when set to `off`, overrides this behavior.
## Responsivity
### Designing for small screens
On slim phones, with the default 32px paddings, the content may only be 260px wide (Galaxy S5). Make sure the content wraps accordingly.

View File

@ -0,0 +1,31 @@
<script setup lang="ts">
import { ref } from 'vue'
import Nav from '~/components/ui/Nav.vue'
const nav = ref([{ title: 'Go up', to: '../' }, { title: 'Home', to: './', badge: "2" }])
</script>
```ts
import Nav from "~/components/ui/Nav.vue"
```
# Nav
This is just a list of links, styled like tabs.
You can add a `badge` or an `icon` to each tab link.
<Link to="/">Hello</Link>
```ts
import { ref } from 'vue'
const nav = ref([{ title: 'Go up', to: '../' }, { title: 'Home', to: './', badge: "2" }])
```
```vue-html
<Nav v-model="nav" />
```
<Nav v-model="nav" />

View File

@ -0,0 +1,30 @@
<script setup lang="ts">
import { ref } from 'vue'
import Pagination from "~/components/ui/Pagination.vue"
const page = ref(1)
</script>
```ts
import Pagination from "~/components/ui/Pagination.vue"
```
# Pagination
The pagination component helps users navigate through large lists of results by splitting them up into pages.
| Prop | Data type | Required? | Description |
| -------------- | --------- | --------- | -------------------------------------- |
| `pages` | Number | Yes | The total number of pages to paginate. |
| `v-model:page` | Number | Yes | The page number of the current page. |
## Pagination model
Create a pagination bar by passing the number of pages to the `pages` prop. Use `v-model` to sync the selected page to your page data. Users can click on each button or input a specific page and hit `return`.
```vue-html
<Pagination :pages="8" v-model:page="page" />
```
<Pagination :pages="9" v-model:page="page" />

View File

@ -0,0 +1,257 @@
<script setup>
import { computed, ref } from 'vue'
import Pill from '~/components/ui/Pill.vue'
import Button from '~/components/ui/Button.vue'
import Spacer from '~/components/ui/Spacer.vue'
import Layout from '~/components/ui/Layout.vue'
const current = ref({ type: 'custom', label: 'I-am-custom.-Change-me!' })
const others = ref([
{ type: 'preset', label: 'Preset-1' },
{ type: 'preset', label: 'Preset-2' },
{ type: 'preset', label: 'Preset-3' },
])
</script>
```ts
import Pill from "~/components/ui/Pill.vue"
```
# Pill
Pills are decorative elements that display information about content they attach to. They can be links to other content or simple colored labels.
You can add text to pills by adding it between the `<Pill>` tags. Alternatively, you can set `v-model` and [make the pill editable](#editable-pill).
| Prop | Data type | Required? | Default | Description |
| ------- | ----------------------------------------------------------------------------------------------- | --------- | ----------- | ---------------------- |
| `color` | `primary` \| `secondary` \| `destructive` \| `blue` \| `red` \| `purple` \| `green` \| `yellow` | No | `secondary` | Renders a colored pill |
-> [Let the user create lists of pills](./pills)
### Primary
Primary pills convey **positive** information.
```vue-html
<Pill primary>
Primary pill
</Pill>
```
<Pill primary>
Primary pill
</Pill>
### Secondary
Secondary pills convey **neutral** or simple decorational information such as genre tags.
::: info
This is the default type for pills. If you don't specify a type, a **secondary** pill is rendered.
:::
```vue-html
<Pill>
Secondary pill
</Pill>
```
<Pill>
Secondary pill
</Pill>
### Destructive
Destructive pills convey **destructive** or **negative** information. Use these to indicate that information could cause issues such as data loss.
```vue-html
<Pill destructive>
Destructive pill
</Pill>
```
<Pill destructive>
Destructive pill
</Pill>
## Pill colors
Funkwhale pills support a range of pastel colors to create visually appealing interfaces.
### Blue
```vue-html
<Pill blue>
Blue pill
</Pill>
```
<Pill blue>
Blue pill
</Pill>
### Red
```vue-html
<Pill red>
Red pill
</Pill>
```
<Pill red>
Red pill
</Pill>
### Purple
```vue-html
<Pill purple>
Purple pill
</Pill>
```
<Pill purple>
Purple pill
</Pill>
### Green
```vue-html
<Pill green>
Green pill
</Pill>
```
<Pill green>
Green pill
</Pill>
### Yellow
```vue-html
<Pill yellow>
Yellow pill
</Pill>
```
<Pill yellow>
Yellow pill
</Pill>
## Image pill
Image pills contain a small circular image on their left. These can be used for decorative links such as artist links. To created an image pill, insert a link to the image between the pill tags as a `<template>`.
```vue-html{2-4}
<Pill>
<template #image>
<div style="background-color: #ff0" />
</template>
Awesome artist
</Pill>
```
<Pill>
<template #image>
<div style="background-color: #ff0" />
</template>
Awesome artist
</Pill>
### Reduce space between image/icon and text
```vue-html{5}
<Pill>
<template #image>
<div style="background-color: currentcolor; width: 8px; height: 8px; margin: 8px;" />
</template>
<span style="margin-left: -12px;">
Awesome artist
</span>
</Pill>
```
<Pill>
<template #image>
<div style="background-color: currentcolor; width: 8px; height: 8px; margin: 8px;" />
</template>
<span style="margin-left: -12px;">
Awesome artist
</span>
</Pill>
## Editable pill
Add `v-model="..."` to link the pill content to a `ref` with one `current` item and zero or more `others`. Set each item's `type` to `preset` or `custom`.
- The `current` item can be changed by the user.
- The `other` items can be selected instead of the `current`.
- Items with type `custom` can be edited and deleted by the user. `preset` items can only be selected or deselected.
```ts
import { ref } from "vue"
const current = ref({ type: 'custom', label: 'I-am-custom.-Change-me!' })
const others = ref([
{ type: 'preset', label: 'Preset-1' },
{ type: 'preset', label: 'Preset-2' },
{ type: 'preset', label: 'Preset-3' },
])
```
```vue-html
<Pill
v-model:current="current"
v-model:others="others"
/>
```
<Pill
v-model:current="current"
v-model:others="others"
/>
## Add an action
<Button primary ghost icon="bi-trash"/>
```vue-html
<Pill
v-model:current="current"
v-model:others="others"
>
<template #action>
<Button ghost primary round icon="bi-x"
title="Deselect"
@click.stop.prevent="() => {
if (customTag.current.type === 'custom')
customTag.others.push({...customTag.current});
customTag.current = {label: '', type: 'custom'}
}"
/>
</template>
</Pill>
```
<!-- prettier-ignore-start -->
<Pill
v-model:current="current"
v-model:others="others"
>
<template #action>
<Button ghost primary round icon="bi-x"
title="Deselect"
@click.stop.prevent="() => {
if (current.type === 'custom')
others.push({...current});
current = {label: '', type: 'custom'}
}"
/>
</template>
</Pill>
{{ current }} (+ {{ others.length }} other options)
<!-- prettier-ignore-end -->

View File

@ -0,0 +1,303 @@
<script setup lang="ts">
import { ref } from 'vue'
import { unionBy, union } from 'lodash-es'
import Pills from '~/components/ui/Pills.vue';
import Button from '~/components/ui/Button.vue';
import Spacer from '~/components/ui/Spacer.vue';
import Layout from '~/components/ui/Layout.vue';
type Item = { type: 'custom' | 'preset', label: string }
type Model = {
currents: Item[],
others?: Item[],
}
const nullModel = ref({
currents: []
} satisfies Model)
const staticModel = ref<Model>({
currents: [
{ label: "#noise", type: 'preset' },
{ label: "#fieldRecording", type: 'preset' },
{ label: "#experiment", type: 'preset' }
]
});
const simpleCustomModel = ref<Model>({
currents: [],
others: []
})
const customModel = ref<Model>({
...staticModel.value,
others: [
{ label: "#myTag1", type: 'custom' },
{ label: "#myTag2", type: 'custom' }
]
});
const sharedOthers = ref<Model['others']>(customModel.value.others)
const currentA = ref<Model['currents']>([{ label: 'A', type: 'preset' }])
const currentB = ref<Model['currents']>([])
const updateSharedOthers = (others: Item[]) => {
sharedOthers.value
= unionBy(sharedOthers.value, others, 'label')
.filter(item => [...currentA.value, ...currentB.value].every(({ label }) => item.label !== label ))
}
const tags = ref<string[]>(['1', '2'])
const sharedTags = ref<string[]>(['3'])
const setTags = (v: string[]) => {
sharedTags.value
= [...tags.value, ...sharedTags.value].filter(tag => !(v.includes(tag)))
tags.value
= v
}
</script>
```ts
import Pills from "~/components/ui/Pills.vue"
```
# Pills
Show a dense list of pills representing tags, categories or options.
Users can select a subset of given options and create new ones.
The model you provide will be mutated by this component:
- `currents`: these items are currently selected
- `others`: these items are currently not selected (but can be selected by the user). This prop is optional. By adding it, you allow users to change the selection.
Each item has a `label` of type `string` as well as a `type` of either:
- `custom`: the user can edit its label or
- `preset`: the user cannot edit its label
```ts
type Item = { type: 'custom' | 'preset', label: string }
type Model = {
currents: Item[],
others?: Item[],
}
```
## No pills
```ts
const nullModel = ref({
currents: []
}) satisfies Model;
```
```vue-html
<Pills v-model="nullModel" />
```
<Pills
:get="(v) => { return }"
:set="() => nullModel"
/>
## Static list of pills
```ts
const staticModel = ref({
currents: [
{ label: "#noise", type: 'preset' },
{ label: "#fieldRecording", type: 'preset' },
{ label: "#experiment", type: 'preset' }
]
} satisfies Model);
```
```vue-html
<Pills
:get="(v) => { return }"
:set="() => staticModel"
/>
```
<Pills
:get="(v) => { return }"
:set="() => staticModel"
/>
## Let users add, remove and edit custom pills
By adding `custom` options, you make the `Pills` instance interactive. Use [reactive](https://vuejs.org/guide/essentials/reactivity-fundamentals.html#reactive-variables-with-ref) methods [such as `computed(...)`](https://vuejs.org/guide/essentials/computed.html) and `watch(...)` to bind the model.
Note that this component will automatically add an empty pill to the end of the model because it made the implementation more straightforward. Use `filter(({ label }) => label !== '') to ignore it when reading the model.
### Minimal example
```ts
const simpleCustomModel = ref({
currents: [],
others: []
})
```
```vue-html
<Pills
:get="(v) => { simpleCustomModel = v }"
:set="() => staticModel"
/>
```
<Pills
:get="(v) => { simpleCustomModel = v }"
:set="() => staticModel"
/>
### Complex example
```ts
const customModel = ref({
...staticModel,
others: [
{ label: "#MyTag1", type: 'custom' },
{ label: "#MyTag2", type: 'custom' }
]
} satisfies Model);
```
```vue-html
<Pills
:get="(v) => { customModel = v }"
:set="() => customModel"
label="Custom Tags"
cancel="Cancel"
/>
```
<Spacer />
<Pills
:get="(v) => { customModel = v }"
:set="() => customModel"
label="Custom Tags"
cancel="Cancel"
/>
## Bind data with an external sink
In the following example, `others` are shared among two `Pills` lists.
```ts
const sharedOthers = ref<Model['others']>(customModel.value.others)
const currentA = ref<Model['currents']>([{ label: 'A', type: 'preset' }])
const currentB = ref<Model['currents']>([])
const updateSharedOthers = (others: Item[]) => {
sharedOthers.value
= unionBy(sharedOthers.value, others, 'label')
.filter(item =>
[...currentA.value, ...currentB.value].every(({ label }) =>
item.label !== label
))
}
```
<!-- prettier-ignore-start -->
<Spacer />
<Pills
:get="({ currents, others }) => {
currentA = currents;
updateSharedOthers(others || []);
}"
:set="({ currents, others }) => ({ currents: currentA, others: unionBy(sharedOthers, others, 'label') })"
label="A"
cancel="Cancel"
/>
<Spacer />
<Pills
:get="({ currents, others }) => {
currentB = currents;
updateSharedOthers(others || []);
}"
:set="({ currents, others }) => ({ currents: currentB, others: unionBy(sharedOthers, others, 'label') })"
label="B"
cancel="Cancel"
/>
<template
v-for="_ in [1]"
:key="[...sharedOthers].join(',')"
>
<pre> Shared among A and B:
{{ (sharedOthers || []).map(({ label }) => label).join(', ') }}
</pre>
</template>
<!-- prettier-ignore-end -->
## Bind data with an external source
You can use the same pattern to influence the model from an outside source:
```ts
const tags = ref<string[]>(['1', '2'])
const sharedTags = ref<string[]>(['3'])
const setTags = (v: string[]) => {
sharedTags.value
= [...tags.value, ...sharedTags.value].filter(tag => !(v.includes(tag)))
tags.value
= v
}
```
<!-- prettier-ignore-start -->
<Layout
flex
gap-8
>
<Button
secondary
@click="setTags([])"
>
Set tags=[]
</Button>
<Button
secondary
@click="setTags(['1', '2', '3'])"
>
Set tags=['1', '2', '3']
</Button>
</Layout>
<hr />
<template
v-for="_ in [1]"
:key="[...tags, '*', ...sharedTags].join(',')"
>
<Layout flex>
<pre>{{ tags.join(', ') }}</pre>
<Spacer grow />
<pre>{{ sharedTags.join(', ') }}</pre>
</Layout>
<hr />
<Spacer />
<Pills
:get="({ currents, others }) => {
setTags(currents.map(({ label }) => label));
sharedTags
= union(sharedTags, (others || []).map(({ label }) => label))
}"
:set="({ currents, others }) => ({
currents: tags.map(l => ({ type: 'custom', label: l})),
others: sharedTags
.filter(l => currents.every(({ label }) => label !== l))
.map(l => ({ type: 'custom', label: l}))
})"
label="Two-way binding with an array of strings"
cancel="Cancel"
/>
</template>
<!-- prettier-ignore-end -->

View File

@ -0,0 +1,781 @@
<script setup lang="ts">
import { ref } from 'vue'
import { SUPPORTED_LOCALES, setI18nLanguage } from '~/init/locale'
import Button from "~/components/ui/Button.vue"
import OptionsButton from "~/components/ui/button/Options.vue"
import Pill from "~/components/ui/Pill.vue"
import Popover from "~/components/ui/Popover.vue"
import PopoverCheckbox from "~/components/ui/popover/PopoverCheckbox.vue"
import PopoverItem from "~/components/ui/popover/PopoverItem.vue"
import PopoverRadio from "~/components/ui/popover/PopoverRadio.vue"
import PopoverSubmenu from "~/components/ui/popover/PopoverSubmenu.vue"
// String values
const privacyChoices = ['public', 'private', 'pod']
const bcPrivacy = ref('pod')
const ccPrivacy = ref('public')
// Boolean values
const bc = ref(false)
const cc = ref(false)
const share = ref(false)
// Alert control
const alert = (message: string) => window.alert(message)
// Menu controls
const emptyMenu = ref(false)
// const separator = ref(false)
const singleItemMenu = ref(false)
const checkboxMenu = ref(false)
const radioMenu = ref(false)
const seperatorMenu = ref(false)
const subMenu = ref(false)
const extraItemsMenu = ref(false)
const linksMenu = ref(false)
const fullMenu= ref(false)
const isOpen = ref(false)
</script>
```ts
import Popover from "~/components/ui/Popover.vue"
```
# Popover (Dropdown Menu)
Popovers (`Popover`) are dropdown menus activated by a button. Use popovers to create dropdown menus ranging from simple lists to complex nested menus.
::: warning A11y issues
This component has severe usability issues and cannot be used as-is.
### Add keyboard operability
> I can't operate the popup with a keyboard. Remove barrier for people not using a mouse.
- ✅ All items can be focused and activated by `Space`
(use `<button>` element instead of `<div>`)
- **Tab order:** Users can go to the next and previous items with `Tab` and `Shift+Tab`. When they have opened a sub-menu, the next focusable element is inside the sub-menu. When they have closed it, the focus jumps back to where it was.
- **Dismissal:** Users can close a menu or sub-menu with `ESC`
- **Arrow keys:** Users can move up and down a menu, open sub-menus with `->` and close them with `<-`. I have added something like this to `Tabs.vue`, for reference.
### Implement expected behavior
> Switching to submenus is error-prone. When moving cursor into freshly opened submenu, it should not close if the cursor crosses another menu item.
<img style="mix-blend-mode:multiply; width: 50%; float:right;" src="./popover/image.png" />
- **Dead triangle:** Add a triangular invisible node that covers the possible paths from the current mouse position to the menu items.
---
> Large menus disappear. I can't scroll to see all options.
- **Submenu-to-Modal:** Lists longer than 12 or so items are not recommended and should be replaced with modals.
---
> Submenus open without a delay, and they don't close unless I click somewhere outside them, which goes against established expectations.
- **Expansion delay:** Sub-menus open after 200ms
- **Auto-close:** Sub-menus close when the outside is hovered. There may be a delay of 200ms. Menus close when they lose focus.
---
Common UI libraries in the Vue ecosystem such as vuetify or shadcn-vue all implement these features. It may be prudent to use their components.
::: tip Quick mitigation tactics:
- Place complex interfaces into nested [`Modal`](./modal)s
- Place long lists into [native `<Select>` elements](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/select)
- Avoid sub-menus
:::
Common uses:
- "More actions" dropdown menus
- Navigation menus
- Settings menus
- Context menus (right-click menus)
| Prop | Data type | Required? | Description |
| ------ | --------- | --------- | ---------------------------------------------------------- |
| `open` | Boolean | No | Controls whether the popover is open. Defaults to `false`. |
[[toc]]
```vue-html
<Popover>
<template #default="{ toggleOpen }">
<OptionsButton @click="toggleOpen" />
</template>
</Popover>
```
<Popover>
<template #default="{ toggleOpen }">
<OptionsButton @click="toggleOpen" />
</template>
</Popover>
Destructure the function `toggleOpen` and let
a [default dropdown button: `OptionsButton`](./button/options.md) trigger it. This way, the state of the component is encapsulated.
## Bind to `isOpen`
If you want to process or influence the expansion of the menu in the containing component, you can bind it to a `ref`.
Use [Vue event handling](https://vuejs.org/guide/essentials/event-handling.html) to map the button to a boolean value.
```vue{7}
<script setup lang="ts">
const isOpen = ref(false)
</script>
<template>
<Popover v-model="isOpen">
<OptionsButton @click="isOpen = !isOpen" />
</Popover>
</template>
```
<Popover v-model="emptyMenu">
<OptionsButton @click="emptyMenu = !emptyMenu" />
</Popover>
## Customize the dropdown button
```vue
<script setup lang="ts">
const open = ref(false);
const privacyChoices = ["pod", "public", "private"];
const bcPrivacy = ref("pod");
</script>
<template>
<Popover v-model="isOpen">
<template #default="{ toggleOpen }">
<Pill
@click="
(e) => {
console.log('Pill clicked');
console.log('Before toggleOpen:', isOpen);
toggleOpen();
console.log('After toggleOpen:', isOpen);
}
"
:blue="bcPrivacy === 'pod'"
:red="bcPrivacy === 'public'"
>
{{ bcPrivacy }}
</Pill>
</template>
<template #items>
<PopoverRadio v-model="bcPrivacy" :choices="privacyChoices" />
</template>
</Popover>
</template>
```
<Popover v-model="isOpen">
<template #default="{ toggleOpen }">
<Pill
@click="() => {
console.log('Pill clicked');
console.log('Before toggleOpen:', isOpen);
toggleOpen();
console.log('After toggleOpen:', isOpen);
}"
:blue="bcPrivacy === 'pod'"
:red="bcPrivacy === 'public'"
>
{{ bcPrivacy }}
</Pill>
</template>
<template #items>
<PopoverRadio v-model="bcPrivacy" :choices="privacyChoices"/>
</template>
</Popover>
## Items
Popovers contain a list of menu items. Items can contain different information based on their type.
::: info
Lists of items must be nested inside a `<template #items>` tag directly under the `<Popover>` tag.
:::
### Popover item
The popover item (`PopoverItem`) is a simple button that uses [Vue event handling](https://vuejs.org/guide/essentials/event-handling.html). Each item contains a [slot](https://vuejs.org/guide/components/slots.html) which you can use to add a menu label and icon.
```vue{10-13}
<script setup lang="ts">
const alert = (message: string) => window.alert(message)
const open = ref(false)
</script>
<template>
<Popover v-model="open">
<OptionsButton @click="open = !open" />
<template #items>
<PopoverItem @click="alert('Report this object?')">
<i class="bi bi-exclamation" />
Report
</PopoverItem>
</template>
</Popover>
</template>
```
<Popover v-model="singleItemMenu">
<OptionsButton @click="singleItemMenu = !singleItemMenu" />
<template #items>
<PopoverItem @click="alert('Report this object?')">
<i class="bi bi-exclamation" />
Report
</PopoverItem>
</template>
</Popover>
### Checkbox
The checkbox (`PopoverCheckbox`) is an item that acts as a selectable box. Use [`v-model`](https://vuejs.org/api/built-in-directives.html#v-model) to bind the checkbox to a boolean value. Each checkbox contains a [slot](https://vuejs.org/guide/components/slots.html) which you can use to add a menu label.
```vue{11-16}
<script setup lang="ts">
const bc = ref(false)
const cc = ref(false)
const open = ref(false)
</script>
<template>
<Popover v-model="open">
<OptionsButton @click="open = !open" />
<template #items>
<PopoverCheckbox v-model="bc">
Bandcamp
</PopoverCheckbox>
<PopoverCheckbox v-model="cc">
Creative commons
</PopoverCheckbox>
</template>
</Popover>
</template>
```
<Popover v-model="checkboxMenu">
<OptionsButton @click="checkboxMenu = !checkboxMenu" />
<template #items>
<PopoverCheckbox v-model="bc">
Bandcamp
</PopoverCheckbox>
<PopoverCheckbox v-model="cc">
Creative commons
</PopoverCheckbox>
</template>
</Popover>
### Radio
The radio (`PopoverRadio`) is an item that acts as a radio selector.
| Prop | Data type | Required? | Description |
| ------------ | --------------- | --------- | -------------------------------------------------------------------------------------------------------------------------------- |
| `modelValue` | String | Yes | The current value of the radio. Use [`v-model`](https://vuejs.org/api/built-in-directives.html#v-model) to bind this to a value. |
| `choices` | Array\<String\> | Yes | A list of choices. |
```vue
<script setup lang="ts">
const open = ref(false);
const currentChoice = ref("pod");
const privacy = ["public", "private", "pod"];
</script>
<template>
<Popover v-model="open">
<OptionsButton @click="open = !open" />
<template #items>
<PopoverRadio v-model="currentChoice" :choices="choices" />
</template>
</Popover>
</template>
```
<Popover v-model="radioMenu">
<OptionsButton @click="radioMenu = !radioMenu" />
<template #items>
<PopoverRadio v-model="bcPrivacy" :choices="privacyChoices"/>
</template>
</Popover>
### Separator
Use a standard horizontal rule (`<hr>`) to add visual separators to popover lists.
```vue{14}
<script setup lang="ts">
const bc = ref(false)
const cc = ref(false)
const open = ref(false)
</script>
<template>
<Popover v-model="open">
<OptionsButton @click="open = !open" />
<template #items>
<PopoverCheckbox v-model="bc">
Bandcamp
</PopoverCheckbox>
<hr>
<PopoverCheckbox v-model="cc">
Creative commons
</PopoverCheckbox>
</template>
</Popover>
</template>
```
<Popover v-model="seperatorMenu">
<OptionsButton @click="seperatorMenu = !seperatorMenu" />
<template #items>
<PopoverCheckbox v-model="bc">
Bandcamp
</PopoverCheckbox>
<hr>
<PopoverCheckbox v-model="cc">
Creative commons
</PopoverCheckbox>
</template>
</Popover>
### Icon Prop
PopoverItem supports an `icon` prop to easily add icons to menu items. The icon prop accepts standard Bootstrap icon classes.
| Prop | Data type | Required? | Description |
| ------ | --------- | --------- | ----------------------------------------------- |
| `icon` | String | No | Bootstrap icon class to display before the item |
```vue-html
<PopoverItem icon="bi-music-note-list">
Play next
</PopoverItem>
<PopoverItem icon="right bi-share">
Share
</PopoverItem>
```
<Popover v-model="isOpen">
<OptionsButton @click="isOpen = !isOpen" />
<template #items>
<PopoverItem icon="bi-music-note-list">
Play next
</PopoverItem>
<PopoverItem icon="right bi-share">
Share
</PopoverItem>
</template>
</Popover>
```
## Submenus
To create more complex menus, you can use submenus (`PopoverSubmenu`). Submenus are menu items which contain other menu items.
```vue{10-18}
<script setup lang="ts">
const bc = ref(false)
const isOpen = ref(false)
</script>
<template>
<Popover v-model="isOpen">
<OptionsButton @click="isOpen = !isOpen" />
<template #items>
<PopoverSubmenu>
<i class="bi bi-collection" />
Organize and share
<template #items>
<PopoverCheckbox v-model="bc">
Bandcamp
</PopoverCheckbox>
</template>
</PopoverSubmenu>
</template>
</Popover>
</template>
```
<Popover v-model="subMenu">
<OptionsButton @click="subMenu = !subMenu" />
<template #items>
<PopoverSubmenu>
<i class="bi bi-collection" />
Organize and share
<template #items>
<PopoverCheckbox v-model="bc">
Bandcamp
</PopoverCheckbox>
</template>
</PopoverSubmenu>
</template>
</Popover>
## Extra items
You can add extra items to the right hand side of a popover item by nesting them in a `<template #after>` tag. Use this to add additional menus or buttons to menu items.
```vue{18-29,34-37}
<script setup lang="ts">
const bc = ref(false)
const privacyChoices = ['public', 'private', 'pod']
const bcPrivacy = ref('pod')
const isOpen = ref(false)
</script>
<template>
<Popover v-model="isOpen">
<OptionsButton @click="isOpen = !isOpen" />
<template #items>
<PopoverSubmenu>
<i class="bi bi-collection" />
Organize and share
<template #items>
<PopoverCheckbox v-model="bc">
Bandcamp
<template #after>
<Popover>
<template #default="{ toggleOpen }">
<Pill @click.stop="toggleOpen" :blue="bcPrivacy === 'pod'" :red="bcPrivacy === 'public'">
{{ bcPrivacy }}
</Pill>
</template>
<template #items>
<PopoverRadio v-model="bcPrivacy" :choices="privacyChoices"/>
</template>
</Popover>
</template>
</PopoverCheckbox>
<hr>
<PopoverCheckbox v-model="share">
Share by link
<template #after>
<Button ghost @click.stop="alert('Link copied to clipboard')" round icon="bi-link" />
<Button ghost @click.stop="alert('Here is your code')" round icon="bi-code" />
</template>
</PopoverCheckbox>
</template>
</PopoverSubmenu>
</template>
</Popover>
</template>
```
<Popover v-model="extraItemsMenu">
<OptionsButton @click="extraItemsMenu = !extraItemsMenu" />
<template #items>
<PopoverSubmenu>
<i class="bi bi-collection" />
Organize and share
<template #items>
<PopoverCheckbox v-model="bc">
Bandcamp
<template #after>
<Popover>
<template #default="{ toggleOpen }">
<Pill @click.stop="toggleOpen" :blue="bcPrivacy === 'pod'" :red="bcPrivacy === 'public'">
{{ bcPrivacy }}
</Pill>
</template>
<template #items>
<PopoverRadio v-model="bcPrivacy" :choices="privacyChoices"/>
</template>
</Popover>
</template>
</PopoverCheckbox>
<hr>
<PopoverCheckbox v-model="share">
Share by link
<template #after>
<Button @click.stop="alert('Link copied to clipboard')" secondary round icon="bi-link" />
<Button @click.stop="alert('Here is your code')" secondary round icon="bi-code" />
</template>
</PopoverCheckbox>
</template>
</PopoverSubmenu>
</template>
</Popover>
## Links
You can use `PopoverItem`s as Links by providing a `to` prop with the route object or and external Url (`http...`). Read more on the [Link component](./link) page.
<Popover v-model="linksMenu">
<OptionsButton @click="linksMenu = !linksMenu" />
<template #items>
<PopoverItem to="a">
<i class="bi bi-music-note-list" />
Hello
</PopoverItem>
<PopoverSubmenu>
<i class="bi bi-music-note-list" />
Change language
<template #items>
<PopoverItem
v-for="(language, key) in SUPPORTED_LOCALES"
:key="key"
:to="key"
>
<i class="bi bi-gear-fill" />
{{ language }}
</PopoverItem>
</template>
</PopoverSubmenu>
</template>
</Popover>
## Menu
Here is an example of a completed menu containing all supported features.
```vue
<script setup lang="ts">
const isOpen = ref(false);
const bc = ref(false);
const cc = ref(false);
const share = ref(false);
const bcPrivacy = ref("pod");
const ccPrivacy = ref("public");
const privacyChoices = ["private", "pod", "public"];
</script>
<template>
<Popover v-model="isOpen">
<OptionsButton @click="isOpen = !isOpen" />
<template #items>
<PopoverSubmenu>
<i class="bi bi-music-note-list" />
Change language
<template #items>
<PopoverItem
v-for="(language, key) in SUPPORTED_LOCALES"
:key="key"
@click="setI18nLanguage(key)"
>
{{ language }}
</PopoverItem>
</template>
</PopoverSubmenu>
<PopoverItem>
<i class="bi bi-arrow-up-right" />
Play next
</PopoverItem>
<PopoverItem>
<i class="bi bi-arrow-down-right" />
Append to queue
</PopoverItem>
<PopoverSubmenu>
<i class="bi bi-music-note-list" />
Add to playlist
<template #items>
<PopoverItem>
<i class="bi bi-music-note-list" />
Sample playlist
</PopoverItem>
<hr />
<PopoverItem>
<i class="bi bi-plus-lg" />
New playlist
</PopoverItem>
</template>
</PopoverSubmenu>
<hr />
<PopoverItem>
<i class="bi bi-heart" />
Add to favorites
</PopoverItem>
<PopoverSubmenu>
<i class="bi bi-collection" />
Organize and share
<template #items>
<PopoverCheckbox v-model="bc">
Bandcamp
<template #after>
<Popover>
<template #default="{ toggleOpen }">
<Pill
@click.stop="toggleOpen"
:blue="bcPrivacy === 'pod'"
:red="bcPrivacy === 'public'"
>
{{ bcPrivacy }}
</Pill>
</template>
<template #items>
<PopoverRadio v-model="bcPrivacy" :choices="privacyChoices" />
</template>
</Popover>
</template>
</PopoverCheckbox>
<PopoverCheckbox v-model="cc">
Creative Commons
<template #after>
<Popover v-model="isOpen">
<template #default="{ toggleOpen }">
<Pill
@click="toggleOpen"
:blue="ccPrivacy === 'pod'"
:red="ccPrivacy === 'public'"
>
{{ ccPrivacy }}
</Pill>
</template>
<template #items>
<PopoverRadio v-model="ccPrivacy" :choices="privacyChoices" />
</template>
</Popover>
</template>
</PopoverCheckbox>
<hr />
<PopoverItem>
<i class="bi bi-plus-lg" />
New library
</PopoverItem>
<hr />
<PopoverCheckbox v-model="share">
Share by link
<template #after>
<Button @click.stop ghost round icon="bi-link" />
<Button @click.stop ghost round icon="bi-code" />
</template>
</PopoverCheckbox>
</template>
</PopoverSubmenu>
<PopoverItem>
<i class="bi bi-cloud-download" />
Download
</PopoverItem>
<hr />
<PopoverItem>
<i class="bi bi-exclamation" />
Report
</PopoverItem>
</template>
</Popover>
</template>
```
<Popover v-model="fullMenu">
<OptionsButton @click="fullMenu = !fullMenu" />
<template #items>
<PopoverItem>
<i class="bi bi-arrow-up-right" />
Play next
</PopoverItem>
<PopoverItem>
<i class="bi bi-arrow-down-right" />
Append to queue
</PopoverItem>
<PopoverSubmenu>
<i class="bi bi-music-note-list" />
Change language
<template #items>
<PopoverItem v-for="(language, key) in SUPPORTED_LOCALES"
:key="key"
@click="setI18nLanguage(key)" >
{{ language }}
</PopoverItem>
</template>
</PopoverSubmenu>
<PopoverSubmenu>
<i class="bi bi-music-note-list" />
Add to playlist
<template #items>
<PopoverItem>
<i class="bi bi-music-note-list" />
Sample playlist
</PopoverItem>
<hr>
<PopoverItem>
<i class="bi bi-plus-lg" />
New playlist
</PopoverItem>
</template>
</PopoverSubmenu>
<hr>
<PopoverItem>
<i class="bi bi-heart" />
Add to favorites
</PopoverItem>
<PopoverSubmenu>
<i class="bi bi-collection" />
Organize and share
<template #items>
<PopoverCheckbox v-model="bc">
Bandcamp
<template #after>
<Popover v-model="isOpen">
<template #default="{ toggleOpen }">
<Pill @click.stop="toggleOpen" :blue="bcPrivacy === 'pod'" :red="bcPrivacy === 'public'">
{{ bcPrivacy }}
</Pill>
</template>
<template #items>
<PopoverRadio v-model="bcPrivacy" :choices="privacyChoices"/>
</template>
</Popover>
</template>
</PopoverCheckbox>
<PopoverCheckbox v-model="cc">
Creative Commons
<template #after>
<Popover v-model="isOpen">
<template #default="{ toggleOpen }">
<Pill @click="toggleOpen" :blue="ccPrivacy === 'pod'" :red="ccPrivacy === 'public'">
{{ ccPrivacy }}
</Pill>
</template>
<template #items>
<PopoverRadio v-model="ccPrivacy" :choices="privacyChoices"/>
</template>
</Popover>
</template>
</PopoverCheckbox>
<hr>
<PopoverItem>
<i class="bi bi-plus-lg" />
New library
</PopoverItem>
<hr>
<PopoverCheckbox v-model="share">
Share by link
<template #after>
<Button @click.stop ghost round icon="bi-link" />
<Button @click.stop ghost round icon="bi-code" />
</template>
</PopoverCheckbox>
</template>
</PopoverSubmenu>
<PopoverItem>
<i class="bi bi-cloud-download" />
Download
</PopoverItem>
<hr>
<PopoverItem>
<i class="bi bi-exclamation" />
Report
</PopoverItem>
</template>
</Popover>

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

View File

@ -0,0 +1,100 @@
<script setup lang="ts">
import { ref } from 'vue'
import Spacer from "~/components/ui/Spacer.vue"
import Slider from '~/components/ui/Slider.vue'
const options = {
me: "Only I can find and edit this",
pod: "Me and other users on the instance can find and edit this",
everyone: "Everyone can find and edit this"
} as const
const option = ref<keyof typeof options>('pod')
const optionWithUndefined = ref<keyof typeof options | undefined>(undefined)
</script>
```ts
import Slider from "~/components/ui/Slider.vue"
```
# Slider
Let a user select a value along a line.
For each option, provide a description. Markdown is supported.
Select a key from the `options` by setting `v-model`
```ts
const options = {
me: "Only I can find and edit this",
pod: "Me and other users on the instance can find and edit this",
everyone: "Everyone can find and edit this"
} as const;
const option = ref<keyof typeof options>("me");
```
```vue-html
<Slider :options="options" v-model="option" label="Privacy level" />
```
<Spacer />
<Slider :options="options" v-model="option" label="Privacy level" />
## Add a label
You can either specify the `label` prop or add custom Html into the `#label` slot.
## Autofocus
Add the prop `autofocus` to focus the slider as soon as it renders. Make sure to only autofocus one element per page.
## Undefined state
If you want to aggregate potentially mixed states, or start with no initial selection,
you can set v-model to `undefined`.
```ts
const optionWithUndefined = ref<keyof typeof options | undefined>(undefined)
```
```vue-html
<Slider
v-model="optionWithUndefined"
label="Privacy level?"
:options="options"
/>
```
<Spacer />
<Slider
v-model="optionWithUndefined"
label="Privacy level?"
:options="options"
/>
---
**Functionality**
- Define a possible values
- Select zero or one values as active (`v-model`)
**User interaction**
- It mimics the functionality of a single `range` input:
- to be operated with arrow keys or mouse
- focus is indicated
- ticks are indicated
**Design**
- A pin (same as in the toggle component)
- a range (very faint)
- Ticks?
- Constant dimensions, fitting the largest text box
- Not to be confused with a pearls navigation patterns (list of dots; indicates temporal range)

View File

@ -0,0 +1,89 @@
<script setup lang="ts">
import { ref } from 'vue'
import Input from '~/components/ui/Input.vue'
import Tabs from '~/components/ui/Tabs.vue'
import Tab from '~/components/ui/Tab.vue'
const search = ref('')
</script>
```ts
import Tabs from "~/components/ui/Tabs.vue"
```
# Tabs
Tabs are used to hide information until a user chooses to see it. You can use tabs to show two sets of information on the same page without the user needing to navigate away.
| Prop | Data type | Required? | Description |
| ------- | --------- | --------- | -------------------- |
| `title` | String | Yes | The title of the tab |
## Tabbed elements
::: warning
The `<Tab>` component must be nested inside a `<Tabs>` component.
:::
```vue-html
<Tabs>
<Tab title="Overview">Overview content</Tab>
<Tab title="Activity">Activity content</Tab>
</Tabs>
```
<Tabs>
<Tab title="Overview">Overview content</Tab>
<Tab title="Activity">Activity content</Tab>
</Tabs>
::: info
If you add the same tab multiple times, the tab is rendered once with the combined content from the duplicates.
:::
```vue-html{2,4}
<Tabs>
<Tab title="Overview">Overview content</Tab>
<Tab title="Activity">Activity content</Tab>
<Tab title="Overview">More overview content</Tab>
</Tabs>
```
<Tabs>
<Tab title="Overview">Overview content</Tab>
<Tab title="Activity">Activity content</Tab>
<Tab title="Overview">More overview content</Tab>
</Tabs>
## Tabs-right slot
You can add a template to the right side of the tabs using the `#tabs-right` directive.
```vue-html{5-7}
<Tabs>
<Tab title="Overview">Overview content</Tab>
<Tab title="Activity">Activity content</Tab>
<template #tabs-right>
<Input icon="bi-search" placeholder="Search" />
</template>
</Tabs>
```
<Tabs>
<Tab title="Overview">Overview content</Tab>
<Tab title="Activity">Activity content</Tab>
<template #tabs-right>
<Input v-model="search" icon="bi-search" placeholder="Search" />
</template>
</Tabs>
## Tabs and routes
If the tab content covers most of the page, users will expect that they can navigate to the previous tab with the "back" button of the browser ([See Navigation](./navigation.md)).
In this case, add the `:to` prop to each tab, and place a `RouterView` with the intended props of the route's component into its default slot.
Note that this only compares the name. Make sure to add a `name` field to identify paths in your router config!

View File

@ -0,0 +1,160 @@
<script setup lang="ts">
import { ref } from 'vue'
import Textarea from '~/components/ui/Textarea.vue'
import Button from '~/components/ui/Button.vue'
import Spacer from '~/components/ui/Spacer.vue'
const text = ref('# Funk\nwhale')
const text1 = ref('# Funk\nwhale')
const text2 = ref('0123456789abcdefghij')
const text3 = ref('')
const reset = () => { text.value = '' }
</script>
```ts
import Textarea from "~/components/ui/Textarea.vue"
```
# Textarea
Textareas are input blocks that enable users to write formatted text (format: Markdown). These blocks are used throughout the Funkwhale interface for entering item descriptions, moderation notes, and custom notifications.
::: details Props
```ts
const {
charLimit = 5000,
placeholder = "",
minLines = 3,
...props
} = defineProps<{
label?: string;
placeholder?: string;
charLimit?: number;
minLines?: number | string;
autofocus?: true;
required?: true;
}>();
```
:::
Create a textarea and attach its input to a value using a `v-model` of type `string` (required):
```ts
const text = ref("# Funk\nwhale");
```
```vue-html{2}
<Textarea v-model="text" />
```
<Textarea v-model="text1" />
## Add a label
```vue-html{3-5}
<Spacer size="16" />
<Textarea>
<template #label>
About my music <span style="color:red; float:right;">*required</span>
</template>
</Textarea>
```
<Spacer size="16" />
<Textarea v-model="text">
<template #label>
About my music <span style="color:red; float:right;">*required</span>
</template>
</Textarea>
If you just have a string, we have a convenience prop, so instead you can write:
```vue-html
<Spacer size="16" />
<Textarea v-model="text" label="About my music" />
```
<Spacer size="16" />
<Textarea v-model="text" label="About my music" />
Note that the label text sits atop of the upper end of the component. This way, you can define the distance between baselines (the vertical rhythm) with spacers or gaps.
## Add a placeholder
```vue-html{3}
<Textarea
v-model="text"
placeholder="Describe this track here…"
/>
```
<Textarea v-model="text3" placeholder="Describe this track here…" />
## Limit the number of characters
You can set the maximum length (in characters) that a user can enter in a textarea by passing a `charLimit` prop.
```vue-html{3}
<Textarea v-model="text" :charLimit="20" />
```
<Textarea v-model="text2" :charLimit="20" />
::: warning Caveats
- You can still set the model longer than allowed by changing it via a script or another Textarea
- A line break counts as one character, which may confuse users
- The character counting algorithm has not been tested on non-Latin based languages
:::
## Set the initial height
Specify the number of lines with the `initial-lines` prop (the default is 5):
```vue-html
<Textarea v-model="text" initial-lines="1" />
```
<Textarea v-model="text3" initialLines="1" />
## Autofocus
If you add the `autofocus` attribute, the text input field will receive focus as soon as it is rendered on the page. Make sure to only add this prop to a single component per page!
## Require this form to be filled before submitting
The `required` attribute prevents the `submit` button of this form.
Make sure to also add an indicator such as the text "required" or a star to prevent confusion.
See [mdn on the `required` attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/required) if you want to know more.
## Additional attributes
Any prop that is not declared in the `Props` type will be added to the `<textarea>` element directly. See [mdn on `textarea` attributes here](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/textarea). Examples: `autocomplete`, `cols`, `minlength`, `readonly`, `wrap`, `disabled` etc.
## Add custom toolbar items
By default, there are text formatting buttons and a 'preview' toggle button in the toolbar. You can add custom content there by adding them into the default slot. They will be drawn before the `preview` button.
```vue-html
<Textarea v-model="text">
<Button ghost low-height min-content
v-if="text !== ''"
icon="bi-arrow-counterclockwise"
@click.prevent="reset()">Reset</Button>
</Textarea>
```
<Textarea v-model="text">
<Spacer no-size grow />
<Button ghost low-height min-content
v-if="text !== ''"
icon="bi-arrow-counterclockwise"
@click.prevent="reset()">Reset</Button>
</Textarea>

View File

@ -0,0 +1,106 @@
<script setup lang="ts">
import Toc from '~/components/ui/Toc.vue'
</script>
```ts
import Toc from "~/components/ui/Toc.vue"
```
# Table of Contents
The table of contents component renders a navigation bar on the right of the screen. Users can click on the items in the contents bar to skip to specific headers.
| Prop | Data type | Required? | Description |
| --------- | -------------- | --------- | --------------------------------------------------- |
| `heading` | Enum\<String\> | No | The heading level rendered in the table of contents |
::: details Supported headings
- `h1`
- `h2`
- `h3`
- `h4`
- `h5`
- `h6`
:::
## Default
By default table of contents only renders `<h1>` tags
```vue-html
<Toc>
<h1>This is a Table of Contents</h1>
Content...
<h1>It automatically generates from headings</h1>
More content...
</Toc>
```
<ClientOnly>
<Toc>
<h1>This is a Table of Contents</h1>
<p>
In expedita ratione consequatur rerum et ullam architecto. Qui ut doloremque laboriosam perferendis corporis voluptatibus voluptates. Ad ducimus adipisci vitae mollitia quis. Aut placeat quaerat maxime velit et eius voluptas fugit. Omnis et et perspiciatis mollitia occaecati.
</p>
<p>
Unde praesentium voluptates esse in placeat. Quis qui sint illo tempore omnis sed. Ab dicta omnis aut dolor voluptate maxime repudiandae ea. Aspernatur alias et architecto asperiores in. A sunt necessitatibus voluptatem veniam at dolore. Dolorum saepe est eveniet dignissimos laborum.
</p>
<p>
Qui impedit dicta earum. Qui repudiandae est magnam. Illum sit ratione exercitationem fugiat aut tempore. Ut sit deserunt ratione ut architecto deleniti ea magnam. Voluptatibus dignissimos voluptatem rem fugiat.
</p>
<h1>It automatically generates from headings</h1>
<p>
In expedita ratione consequatur rerum et ullam architecto. Qui ut doloremque laboriosam perferendis corporis voluptatibus voluptates. Ad ducimus adipisci vitae mollitia quis. Aut placeat quaerat maxime velit et eius voluptas fugit. Omnis et et perspiciatis mollitia occaecati.
</p>
<p>
Unde praesentium voluptates esse in placeat. Quis qui sint illo tempore omnis sed. Ab dicta omnis aut dolor voluptate maxime repudiandae ea. Aspernatur alias et architecto asperiores in. A sunt necessitatibus voluptatem veniam at dolore. Dolorum saepe est eveniet dignissimos laborum.
</p>
<p>
Qui impedit dicta earum. Qui repudiandae est magnam. Illum sit ratione exercitationem fugiat aut tempore. Ut sit deserunt ratione ut architecto deleniti ea magnam. Voluptatibus dignissimos voluptatem rem fugiat.
</p>
</Toc>
</ClientOnly>
## Custom headings
You can specify the heading level you want to render in the table of contents by passing it to the `heading` prop.
```vue-html{2}
<Toc
heading="h2"
>
<h1>This is a Table of Contents</h1>
Content...
<h2>It automatically generates from headings</h2>
More content...
</Toc>
```
<ClientOnly>
<Toc heading="h2">
<h1>This is a Table of Contents</h1>
<p>
In expedita ratione consequatur rerum et ullam architecto. Qui ut doloremque laboriosam perferendis corporis voluptatibus voluptates. Ad ducimus adipisci vitae mollitia quis. Aut placeat quaerat maxime velit et eius voluptas fugit. Omnis et et perspiciatis mollitia occaecati.
</p>
<p>
Unde praesentium voluptates esse in placeat. Quis qui sint illo tempore omnis sed. Ab dicta omnis aut dolor voluptate maxime repudiandae ea. Aspernatur alias et architecto asperiores in. A sunt necessitatibus voluptatem veniam at dolore. Dolorum saepe est eveniet dignissimos laborum.
</p>
<p>
Qui impedit dicta earum. Qui repudiandae est magnam. Illum sit ratione exercitationem fugiat aut tempore. Ut sit deserunt ratione ut architecto deleniti ea magnam. Voluptatibus dignissimos voluptatem rem fugiat.
</p>
<h2>It automatically generates from headings</h2>
<p>
In expedita ratione consequatur rerum et ullam architecto. Qui ut doloremque laboriosam perferendis corporis voluptatibus voluptates. Ad ducimus adipisci vitae mollitia quis. Aut placeat quaerat maxime velit et eius voluptas fugit. Omnis et et perspiciatis mollitia occaecati.
</p>
<p>
Unde praesentium voluptates esse in placeat. Quis qui sint illo tempore omnis sed. Ab dicta omnis aut dolor voluptate maxime repudiandae ea. Aspernatur alias et architecto asperiores in. A sunt necessitatibus voluptatem veniam at dolore. Dolorum saepe est eveniet dignissimos laborum.
</p>
<p>
Qui impedit dicta earum. Qui repudiandae est magnam. Illum sit ratione exercitationem fugiat aut tempore. Ut sit deserunt ratione ut architecto deleniti ea magnam. Voluptatibus dignissimos voluptatem rem fugiat.
</p>
</Toc>
</ClientOnly>

View File

@ -0,0 +1,70 @@
<script setup lang="ts">
import Toggle from '~/components/ui/Toggle.vue'
import Layout from '~/components/ui/Layout.vue'
import Button from '~/components/ui/Button.vue' // needs to be imported so that we can use colors...
import { ref } from 'vue'
const toggle = ref(false)
</script>
```ts
import Toggle from "~/components/ui/Toggle.vue"
```
# Toggle
Toggles are basic form inputs that visually represent a boolean value. Toggles can be **on** (`true`) or **off** (`false`). For actions with more than 2 states or delayed, fallible, or effectful actions, [consider using a Button with `aria-pressed` logic instead](button#on-off).
| Prop | Data type | Required? | Description |
| --------------- | --------- | --------- | ---------------------------------------- |
| `big` | Boolean | No | Controls whether a toggle is big or not. |
| `v-model:value` | Boolean | Yes | The value controlled by the toggle. |
## Normal toggle
Link your toggle to an input using the `v-model` directive.
<Layout flex class="preview">
```vue-html
<Toggle v-model="toggle" />
```
<Toggle v-model="toggle" />
</Layout>
## Add label
<Layout flex class="preview">
```vue-html{2}
<Toggle v-model="toggle" label="Option 358" />
```
<Toggle v-model="toggle" label="Option 358" />
</Layout>
## Big toggle
Pass a `big` prop to create a larger toggle.
<Layout flex class="preview">
```vue-html{2}
<Toggle
big
v-model="toggle"
label="I am big"
/>
```
<Toggle
big
v-model="toggle"
label="I am big"
/>
</Layout>

View File

@ -0,0 +1,31 @@
# Contributing
[[toc]]
## Setting up your IDE
If you are using vscode, [enable `Vue` code hints in the `.md`
docs](https://vitepress.dev/guide/using-vue#vs-code-intellisense-support):
```json
// .vscode/settings.json
"vue.server.includeLanguages": ["vue", "markdown"]
```
## Adding new UI components
::: tip Prerequisites
✔ I am using the same pattern in many different places in the app
✔ The pattern is not coupled with any funkwhale types
✔ It's a conventional UI pattern
:::
1. Create a file `Xyz.vue` at `src/components/ui` and code the component
2. Add a file `xyz.md` at `ui-docs/components` with exhaustive examples
3. In `ui-docs/.vitepress/config.ts`, add the component to the sidebar links
Make sure to follow the [anatomy of a Component](./using-components#anatomy-of-a-component-file)!

View File

@ -0,0 +1,66 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { type Track, type User } from '~/types'
import Card from '~/components/ui/Card.vue'
import Layout from '~/components/ui/Layout.vue'
import Toggle from '~/components/ui/Toggle.vue'
import Spacer from '~/components/ui/Spacer.vue'
import Button from '~/components/ui/Button.vue'
import Activity from '~/components/ui/Activity.vue'
import Section from '~/components/ui/Section.vue'
const alignLeft = ref(false)
</script>
# Designing Pages
## Grid
The page grid consists of 46px wide tracks, separated by 32px wide gaps. [See examples on design.funkwhale.audio](https://design.funkwhale.audio/#/workspace/582e7be1-0cc7-11ed-87a1-ae44a720651d/e3a00150-0f5e-11ed-adb9-fff9e854a67c?page-id=e7a90671-0f5e-11ed-adb9-fff9e854a67c)
## Page sections
Use the [Layout Section component](/components/ui/layout/section) to structure the page into separate sections, each with a heading. Make sure the heading level hierarchy makes sense.
```vue-html
<Section
:alignLeft="alignLeft"
:columns-per-item="3"
h2="My albums"
:action="{
text:'Go to library',
to:'/'
}"
>
<Card small solid yellow title="Album 1" />
<Card small solid green title="Album 2" />
<Card small solid blue title="Album 3" />
</Section>
```
<Spacer :size="32" />
<Section
:alignLeft="alignLeft"
:columns-per-item="3"
h2="My albums"
:action="{
text:'Go to library',
to:'/'
}"
>
<Card small solid yellow title="Album 1" />
<Card small solid green title="Album 2" />
<Card small solid blue title="Album 3" />
</Section>
Sections and their contents are automatically aligned to the default grid.
---
<Card to="navigation"
title="Designing user navigation and routes"
min-content
/>

BIN
front/ui-docs/image-1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

BIN
front/ui-docs/image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

62
front/ui-docs/index.md Normal file
View File

@ -0,0 +1,62 @@
<script setup lang="ts">
import Layout from '../src/components/ui/Layout.vue'
import Card from '../src/components/ui/Card.vue'
import Spacer from '../src/components/ui/Spacer.vue'
</script>
# Funkwhale design component library
## Plan
<Layout flex>
<Card default raised to="/designing-pages"
title="Designing pages"
min-content
/>
<Card default raised to="https://design.funkwhale.audio"
title="UI designs" >
Check out the design system on our Penpot.
</Card>
</Layout>
## Use
<Layout flex>
<Card default raised to='/using-components'
title="Using components"
min-content
/>
<Card default raised to="/using-color"
title="Adding Color"
min-content
/>
<Card default raised to="/using-width"
title="Setting width and height"
min-content
/>
<Card default raised to="/using-alignment"
title="Aligning elements"
min-content
/>
</Layout>
## Contribute
- [Improve the component library](./contributing)
- [Found a bug? Report it here](https://dev.funkwhale.audio/funkwhale/funkwhale/-/issues/?sort=created_date&state=opened&label_name%5B%5D=Type%3A%3AUX%2FUI&first_page_size=20)
::: warning
vitepress loads some stylesheets on its own, so the styles you see here may differ from those in the funkwhale app or on the homepage and the blog.
You can find these stylesheets in the directory `front/node_modules/vitepress/dist/client/theme-default/styles/`.
:::

View File

@ -0,0 +1,27 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { type Track, type User } from '~/types'
import Card from '~/components/ui/Card.vue'
import Layout from '~/components/ui/Layout.vue'
import Toggle from '~/components/ui/Toggle.vue'
import Spacer from '~/components/ui/Spacer.vue'
import Button from '~/components/ui/Button.vue'
import Activity from '~/components/ui/Activity.vue'
import Section from '~/components/ui/Section.vue'
const alignLeft = ref(false)
const attributes = computed(() => ({
style: alignLeft.value ? 'justify-content: start' : ''
}))
</script>
# Navigation
When most of the screen changes, users perceive it as a page navigation. They will expect the "back" button to bring them to the precious screen. In addition, they may expect the URL to contain the current page for sharing.
This makes a component a "page". In this sense, modals are pages.
Not everything we want to share with the Url replaces most of the screen. What about switching a filter?

View File

@ -0,0 +1,61 @@
<script setup>
import Card from "~/components/ui/Card.vue"
import Layout from "~/components/ui/Layout.vue"
import Button from "~/components/ui/Button.vue"
</script>
# Using alignments
You can align items inside `flex` and `grid` layouts.
## Align a component relative to its container with `align-self`
<Layout grid class="preview">
<template
v-for="alignment in ['start', 'center', 'end', 'auto', 'baseline', 'stretch']"
:key="alignment">
<div style="position:relative;place-self:stretch; grid-area: span 2 / span 2; min-height: 72px; border:.5px solid red; display:grid; grid: 1fr / 1fr; grid-auto-flow: column;"
>
<Button auto primary :align-self="alignment">🐌</Button>
_
<span style="position:absolute; right: 0; margin-left:-20px; bottom:0;">align-self={{ alignment }}</span>
</div>
</template>
<template
v-for="alignment in ['center', 'stretch']"
:key="alignment">
<div style="position:relative;place-self:stretch; grid-area: span 2 / span 2; min-height: 72px; border:.5px solid red; display:grid; grid: 1fr / 1fr; grid-auto-flow: column;"
>
<Button auto primary v-bind="{[alignment]:true}">🐌</Button>
_
<span style="position:absolute; right: 0; bottom:0;">{{ alignment }}</span>
</div>
</template>
</Layout>
## Align the content of a component with `align-text`
<Layout flex class="preview">
```vue-html
<Button align-text="start">🐌</Button>
<Button align-text="center">🐌</Button>
<Button align-text="end">🐌</Button>
<Button icon="bi-star" align-text="stretch">🐌</Button>
<Button icon="bi-star" align-text="space-out">🐌</Button>
```
<Layout class="preview solid primary" stack no-gap>
<Button align-text="start">🐌</Button>
<Button align-text="center">🐌</Button>
<Button align-text="end">🐌</Button>
<Button icon="bi-star" align-text="stretch">🐌</Button>
<Button icon="bi-star" align-text="space-out">🐌</Button>
</Layout>
</Layout>

View File

@ -0,0 +1,816 @@
<script setup lang="ts">
import { color, setColors } from "~/composables/color"
import { useRoute } from "vue-router"
import Button from "~/components/ui/Button.vue"
import Card from "~/components/ui/Card.vue"
import Link from "~/components/ui/Link.vue"
import Layout from "~/components/ui/Layout.vue"
import Alert from "~/components/ui/Alert.vue"
import Spacer from "~/components/ui/Spacer.vue"
const route = useRoute();
const here = route.path
</script>
<Alert blue style="margin: 0 -48px">
Want to fix colors?
<Spacer h />
<Layout flex no-gap>
<Link solid primary to="#change-a-color-value">Change a color value</Link>
<Link solid primary to="#alter-the-shade-of-a-color">Alter the shade of a color</Link>
<Link solid primary to="#choose-a-different-style-for-a-specific-variant">Modify a specific variant</Link>
</Layout>
</Alert>
<Spacer />
# Using Color
## Add color via props
[Alerts](components/ui/alert) support [Pastel](#pastel) attributes. [Buttons](components/ui/button) accept [Color](#color) and [Variant](#variant) attributes. [Cards](components/ui/card) accept [Pastel](#pastel), [Variant](#variant), [Neutral](#neutral) and [Raised](#raised) attributes.
<Layout flex>
```vue-html
<Card solid red title='🌈' />
```
<div class="preview">
<Card solid red title='🌈' />
</div>
</Layout>
## Add color to a any component or Html tag
1. Choose either:
- [base color](#colors) (`primary | secondary | destructive`) or
- [pastel color](#pastel) (`blue | red | green | yellow`) or
- [neutral beige or gray](#neutral) (`default`) for surfaces
2. Choose a [variant](#color-variants) (`solid | ghost | outline`)
3. Add [interactivity and raise the surface](#interactive-andor-raised)
<Layout flex>
```vue
<script setup>
import { color } from "~/composables/color.ts";
</script>
<template>
<div v-bind="color({}, ['primary', 'solid', 'interactive', 'raised'])()" />
</template>
```
<div class="preview">
<div :class="$style.swatch" v-bind="color({}, ['primary', 'solid', 'interactive', 'raised'])()" />
</div>
</Layout>
## Make your component accept color props
`color` accepts two parameters. The first is your props object, the second is a list of defaults.
In this case, if the user of your component doesn't specify any color, it will be 'primary'.
Since the user cannot specify the variant, it will always be 'default'.
```vue
<script setup>
import { type ColorProps, color } from "~/composables/colors.ts";
const props = defineProps<{
...
} & ColorProps>()
</script>
<template>
<div v-bind="color(props, ['primary', 'solid'])()" />
</template>
```
## Base colors
Keep most of the screen neutral. Secondary surfaces indicate actionability. Use them sparingly. Primary and destructive surfaces immediately catch the eye. Use them only once or twice per screen.
### Neutral
Use neutral colors for non-interactive surfaces
<Layout flex>
<div class="force-dark-theme">
<div v-bind="setColors('default solid')">
<div :class="$style.swatch" v-bind="setColors('default solid')" />
<div :class="[$style.swatch, $style.deemphasized]" v-bind="setColors('default solid interactive')" />
</div><div v-bind="setColors('default solid raised')">
<div :class="$style.swatch" v-bind="setColors('default solid raised')" />
<div :class="[$style.swatch, $style.deemphasized]" v-bind="setColors('default solid raised interactive')" />
</div>
</div>
<div class="force-light-theme">
<div v-bind="setColors('default solid')">
<div :class="$style.swatch" v-bind="setColors('default solid')" />
<div :class="[$style.swatch, $style.deemphasized]" v-bind="setColors('default solid interactive')" />
</div><div v-bind="setColors('default solid raised')">
<div :class="$style.swatch" v-bind="setColors('default solid raised')" />
<div :class="[$style.swatch, $style.deemphasized]" v-bind="setColors('default solid raised interactive')" />
</div>
</div>
</Layout>
### Color
#### Primary
Only use for at most one call-to-action on a screen
<Layout flex>
<div class="force-dark-theme">
<div v-bind="setColors('default solid')">
<div :class="[$style.swatch, $style.deemphasized]" v-bind="setColors('primary solid')" />
<div :class="[$style.swatch]" v-bind="setColors('primary solid interactive')" />
</div><div v-bind="setColors('default solid raised')">
<div :class="[$style.swatch, $style.deemphasized]" v-bind="setColors('primary solid raised')" />
<div :class="[$style.swatch]" v-bind="setColors('primary solid raised interactive')" />
</div>
</div>
<div class="force-light-theme">
<div v-bind="setColors('default solid')">
<div :class="[$style.swatch, $style.deemphasized]" v-bind="setColors('primary solid')" />
<div :class="[$style.swatch]" v-bind="setColors('primary solid interactive')" />
</div><div v-bind="setColors('default solid raised')">
<div :class="[$style.swatch, $style.deemphasized]" v-bind="setColors('primary solid raised')" />
<div :class="[$style.swatch]" v-bind="setColors('primary solid raised interactive')" />
</div>
</div>
</Layout>
#### Secondary
Use for interactive items and non-permanent surfaces such as menus and modals
<Layout flex>
<div class="force-dark-theme">
<div v-bind="setColors('default solid')">
<div :class="$style.swatch" v-bind="setColors('secondary solid')" />
<div :class="[$style.swatch]" v-bind="setColors('secondary solid interactive')" />
</div><div v-bind="setColors('default solid raised')">
<div :class="$style.swatch" v-bind="setColors('secondary solid raised')" />
<div :class="[$style.swatch]" v-bind="setColors('secondary solid raised interactive')" />
</div>
</div>
<div class="force-light-theme">
<div v-bind="setColors('default solid')">
<div :class="$style.swatch" v-bind="setColors('secondary solid')" />
<div :class="[$style.swatch]" v-bind="setColors('secondary solid interactive')" />
</div><div v-bind="setColors('default solid raised')">
<div :class="$style.swatch" v-bind="setColors('secondary solid raised')" />
<div :class="[$style.swatch]" v-bind="setColors('secondary solid raised interactive')" />
</div>
</div>
</Layout>
#### Destructive
Use for dangerous actions
<Layout flex>
<div class="force-dark-theme">
<div v-bind="setColors('default solid')">
<div :class="[$style.swatch, $style.deemphasized]" v-bind="setColors('destructive solid')" />
<div :class="[$style.swatch]" v-bind="setColors('destructive solid interactive')" />
</div><div v-bind="setColors('default solid raised')">
<div :class="[$style.swatch, $style.deemphasized]" v-bind="setColors('destructive solid raised')" />
<div :class="[$style.swatch]" v-bind="setColors('destructive solid raised interactive')" />
</div>
</div>
<div class="force-light-theme">
<div v-bind="setColors('default solid')">
<div :class="[$style.swatch, $style.deemphasized]" v-bind="setColors('destructive solid')" />
<div :class="[$style.swatch]" v-bind="setColors('destructive solid interactive')" />
</div><div v-bind="setColors('default solid raised')">
<div :class="[$style.swatch, $style.deemphasized]" v-bind="setColors('destructive solid raised')" />
<div :class="[$style.swatch]" v-bind="setColors('destructive solid raised interactive')" />
</div>
</div>
</Layout>
### Pastel
Use `Blue`, `Red`, `Purple`, `Green`, `Yellow` for user-defined tags and for friendly messages such as
Alerts
<div :class="$style.swatch" v-bind="setColors('blue solid interactive')" />
<div :class="$style.swatch" v-bind="setColors('red solid interactive')" />
<div :class="$style.swatch" v-bind="setColors('purple solid interactive')" />
<div :class="$style.swatch" v-bind="setColors('green solid interactive')" />
<div :class="$style.swatch" v-bind="setColors('yellow solid interactive')" />
### Variant
You can de-emphasize interactive elements by hiding their background and/or outline.
`Solid` (default), `Ghost`, `Outline`:
<Button round shadow icon="bi-x" solid />
<div :class="$style.swatch" v-bind="setColors('solid raised')">
<Button round icon="bi-x" ghost />
<Spacer />
<Button round icon="bi-x" outline />
</div>
<br/>
<Button round shadow icon="bi-x" primary solid />
<div :class="$style.swatch" v-bind="setColors('primary solid')">
<Button round icon="bi-x" primary ghost />
<Spacer />
<Button round icon="bi-x" primary outline />
</div>
### Interactive and/or Raised
Use raised surfaces sparingly, about ¼ of the screen. Only use raised surfaces to highlight one area of a component over others. Good examples are `aside`s such as the sidebar or info-boxes.
Space out interactive surfaces.
<Layout>
<div class="force-light-theme">
<Layout flex>
<div>
<div v-bind="setColors('default solid')">
<div :class="[$style.swatch]" v-bind="setColors('solid')" />
<div :class="[$style.swatch]" v-bind="setColors('solid interactive')" />
<div :class="[$style.swatch]" v-bind="setColors('solid interactive')" disabled/>
<div :class="[$style.swatch]" v-bind="setColors('solid interactive')" aria-pressed />
</div>
<div v-bind="setColors('secondary solid')">
<div :class="[$style.swatch]" v-bind="setColors('solid')" />
<div :class="[$style.swatch]" v-bind="setColors('solid interactive')" />
<div :class="[$style.swatch]" v-bind="setColors('solid interactive')" disabled/>
<div :class="[$style.swatch]" v-bind="setColors('solid interactive')" aria-pressed />
</div>
<div v-bind="setColors('primary solid')">
<div :class="[$style.swatch]" v-bind="setColors('solid')" />
<div :class="[$style.swatch]" v-bind="setColors('solid interactive')" />
<div :class="[$style.swatch]" v-bind="setColors('solid interactive')" disabled/>
<div :class="[$style.swatch]" v-bind="setColors('solid interactive')" aria-pressed />
</div>
</div><div>
<div v-bind="setColors('default raised solid')">
<div :class="[$style.swatch]" v-bind="setColors('solid raised')" />
<div :class="[$style.swatch]" v-bind="setColors('solid interactive raised')" />
<div :class="[$style.swatch]" v-bind="setColors('solid interactive raised')" disabled/>
<div :class="[$style.swatch]" v-bind="setColors('solid interactive raised')" aria-pressed />
</div>
<div v-bind="setColors('secondary raised solid')">
<div :class="[$style.swatch]" v-bind="setColors('solid raised')" />
<div :class="[$style.swatch]" v-bind="setColors('solid interactive raised')" />
<div :class="[$style.swatch]" v-bind="setColors('solid interactive raised')" disabled/>
<div :class="[$style.swatch]" v-bind="setColors('solid interactive raised')" aria-pressed />
</div>
<div v-bind="setColors('primary raised solid')">
<div :class="[$style.swatch]" v-bind="setColors('solid raised')" />
<div :class="[$style.swatch]" v-bind="setColors('solid interactive raised')" />
<div :class="[$style.swatch]" v-bind="setColors('solid interactive raised')" disabled/>
<div :class="[$style.swatch]" v-bind="setColors('solid interactive raised')" aria-pressed />
</div>
</div>
</Layout>
</div>
<div class="force-dark-theme">
<Layout flex>
<div>
<div v-bind="setColors('default solid')">
<div :class="[$style.swatch]" v-bind="setColors('solid')" />
<div :class="[$style.swatch]" v-bind="setColors('solid interactive')" />
<div :class="[$style.swatch]" v-bind="setColors('solid interactive')" disabled/>
<div :class="[$style.swatch]" v-bind="setColors('solid interactive')" aria-pressed />
</div>
<div v-bind="setColors('secondary solid')">
<div :class="[$style.swatch]" v-bind="setColors('solid')" />
<div :class="[$style.swatch]" v-bind="setColors('solid interactive')" />
<div :class="[$style.swatch]" v-bind="setColors('solid interactive')" disabled/>
<div :class="[$style.swatch]" v-bind="setColors('solid interactive')" aria-pressed />
</div>
<div v-bind="setColors('primary solid')">
<div :class="[$style.swatch]" v-bind="setColors('solid')" />
<div :class="[$style.swatch]" v-bind="setColors('solid interactive')" />
<div :class="[$style.swatch]" v-bind="setColors('solid interactive')" disabled/>
<div :class="[$style.swatch]" v-bind="setColors('solid interactive')" aria-pressed />
</div><br/>Normal
</div><div>
<div v-bind="setColors('default raised solid')">
<div :class="[$style.swatch]" v-bind="setColors('solid raised')" />
<div :class="[$style.swatch]" v-bind="setColors('solid interactive raised')" />
<div :class="[$style.swatch]" v-bind="setColors('solid interactive raised')" disabled/>
<div :class="[$style.swatch]" v-bind="setColors('solid interactive raised')" aria-pressed />
</div>
<div v-bind="setColors('secondary raised solid')">
<div :class="[$style.swatch]" v-bind="setColors('solid raised')" />
<div :class="[$style.swatch]" v-bind="setColors('solid interactive raised')" />
<div :class="[$style.swatch]" v-bind="setColors('solid interactive raised')" disabled/>
<div :class="[$style.swatch]" v-bind="setColors('solid interactive raised')" aria-pressed />
</div>
<div v-bind="setColors('primary raised solid')">
<div :class="[$style.swatch]" v-bind="setColors('solid raised')" />
<div :class="[$style.swatch]" v-bind="setColors('solid interactive raised')" />
<div :class="[$style.swatch]" v-bind="setColors('solid interactive raised')" disabled/>
<div :class="[$style.swatch]" v-bind="setColors('solid interactive raised')" aria-pressed />
</div><br/><strong>Raised</strong>
</div>
</Layout>
</div>
</Layout>
<Layout flex style="align-items: center;">
| neutral |
| --------- |
| secondary |
| primary |
| (normal) | interactive | disabled | pressed |
| -------- | ----------- | -------- | ------- |
</Layout>
## Palette
The color palette consists of Blues, Reds, Grays, Beiges, as well as pastel blue/red/green/yellow.
`fw-blue-010 - 100 - 400..900`
<div :class="[$style.swatch, $style.tiny]" style="background:var(--fw-blue-010)" />
<div :class="[$style.swatch, $style.small]" style="background:var(--fw-blue-100)" />
   
<div :class="[$style.swatch, $style.small]" style="background:var(--fw-blue-400)" />
<div :class="[$style.swatch, $style.small]" style="background:var(--fw-blue-500)" />
<div :class="[$style.swatch, $style.small]" style="background:var(--fw-blue-600)" />
<div :class="[$style.swatch, $style.small]" style="background:var(--fw-blue-700)" />
<div :class="[$style.swatch, $style.small]" style="background:var(--fw-blue-800)" />
<div :class="[$style.swatch, $style.small]" style="background:var(--fw-blue-900)" />
`fw-red-010 - 100 - 400..900`
<div :class="[$style.swatch, $style.tiny]" style="background:var(--fw-red-010)" />
<div :class="[$style.swatch, $style.small]" style="background:var(--fw-red-100)" />
   
<div :class="[$style.swatch, $style.small]" style="background:var(--fw-red-400)" />
<div :class="[$style.swatch, $style.small]" style="background:var(--fw-red-500)" />
<div :class="[$style.swatch, $style.small]" style="background:var(--fw-red-600)" />
<div :class="[$style.swatch, $style.small]" style="background:var(--fw-red-700)" />
<div :class="[$style.swatch, $style.small]" style="background:var(--fw-red-800)" />
<div :class="[$style.swatch, $style.small]" style="background:var(--fw-red-900)" />
`fw-gray-100..800 - 850 - 900 - 950 - 960 - 970`
<div :class="[$style.swatch, $style.small]" style="background:var(--fw-gray-100)" />
<div :class="[$style.swatch, $style.small]" style="background:var(--fw-gray-200)" />
<div :class="[$style.swatch, $style.small]" style="background:var(--fw-gray-300)" />
<div :class="[$style.swatch, $style.small]" style="background:var(--fw-gray-400)" />
<div :class="[$style.swatch, $style.small]" style="background:var(--fw-gray-500)" />
<div :class="[$style.swatch, $style.small]" style="background:var(--fw-gray-600)" />
<div :class="[$style.swatch, $style.small]" style="background:var(--fw-gray-700)" />
<div :class="[$style.swatch, $style.small]" style="background:var(--fw-gray-800)" />
<div :class="[$style.swatch, $style.tiny]" style="background:var(--fw-gray-850)" />
<div :class="[$style.swatch, $style.small]" style="background:var(--fw-gray-900)" />
<div :class="[$style.swatch, $style.tiny]" style="background:var(--fw-gray-950)" />
<div :class="[$style.swatch, $style.tiny]" style="background:var(--fw-gray-960)" />
<div :class="[$style.swatch, $style.tiny]" style="background:var(--fw-gray-970)" />
`fw-beige-100..400`
<div :class="[$style.swatch, $style.small]" style="background:var(--fw-beige-100)" />
<div :class="[$style.swatch, $style.small]" style="background:var(--fw-beige-200)" />
<div :class="[$style.swatch, $style.small]" style="background:var(--fw-beige-300)" />
<div :class="[$style.swatch, $style.small]" style="background:var(--fw-beige-400)" />
---
`fw-pastel-blue-1..4`
<div :class="[$style.swatch, $style.small]" style="background:var(--fw-pastel-blue-1)" />
<div :class="[$style.swatch, $style.small]" style="background:var(--fw-pastel-blue-2)" />
<div :class="[$style.swatch, $style.small]" style="background:var(--fw-pastel-blue-3)" />
<div :class="[$style.swatch, $style.small]" style="background:var(--fw-pastel-blue-4)" />
`fw-pastel-red-1..4`
<div :class="[$style.swatch, $style.small]" style="background:var(--fw-pastel-red-1)" />
<div :class="[$style.swatch, $style.small]" style="background:var(--fw-pastel-red-2)" />
<div :class="[$style.swatch, $style.small]" style="background:var(--fw-pastel-red-3)" />
<div :class="[$style.swatch, $style.small]" style="background:var(--fw-pastel-red-4)" />
`fw-pastel-purple-1..4`
<div :class="[$style.swatch, $style.small]" style="background:var(--fw-pastel-purple-1)" />
<div :class="[$style.swatch, $style.small]" style="background:var(--fw-pastel-purple-2)" />
<div :class="[$style.swatch, $style.small]" style="background:var(--fw-pastel-purple-3)" />
<div :class="[$style.swatch, $style.small]" style="background:var(--fw-pastel-purple-4)" />
`fw-pastel-green-1..4`
<div :class="[$style.swatch, $style.small]" style="background:var(--fw-pastel-green-1)" />
<div :class="[$style.swatch, $style.small]" style="background:var(--fw-pastel-green-2)" />
<div :class="[$style.swatch, $style.small]" style="background:var(--fw-pastel-green-3)" />
<div :class="[$style.swatch, $style.small]" style="background:var(--fw-pastel-green-4)" />
`fw-pastel-yellow-1..4`
<div :class="[$style.swatch, $style.small]" style="background:var(--fw-pastel-yellow-1)" />
<div :class="[$style.swatch, $style.small]" style="background:var(--fw-pastel-yellow-2)" />
<div :class="[$style.swatch, $style.small]" style="background:var(--fw-pastel-yellow-3)" />
<div :class="[$style.swatch, $style.small]" style="background:var(--fw-pastel-yellow-4)" />
---
In addition, we have a single shade of orange used for secondary indicators such as active tabs: `fw-secondary`
<div :class="$style.swatch" style="background:var(--fw-secondary)" />
## Theme
Many browsers automatically turn on "night mode" to lower the light emission of the screen. Users can override the browser theme in their settings menu.
In both "dark mode" and "light mode", the colors must provide adequate contrast and consistency.
Read more about [style modules in the corresponding vue docs](https://vuejs.org/api/sfc-css-features.html#css-modules).
For testing purposes and side-by-side comparisons, you can add the classes `force-dark-theme` and `force-light-theme`.
## Semantic colors
We use semantic color variables that can mean a different shade depending on the currently chosen theme
- primary
- secondary (default)
- destructive
## Color variants
For each semantic color, we set a foreground and a background. In addition, we need to check context: A button should have a stronger shade when the mouse is hovering over it. When a surface is raised over another surface, it should have a stronger shade, too.
**Variants:**
- ghost (default for most things except buttons)
> affects text color but leaves the border and background transparent.
> Make sure to provide a surface beneath with adequate contrast.
- outline
> affects text color and border and leaves the background transparent
> Make sure to provide a surface beneath with adequate contrast.
- solid (default for buttons)
> affects text color, border color and background color
[-> Example: #variant](#variant)
**Variants can optionally be made interactive and/or raised:**
- no alteration (default)
- raised
> affects text color, border color and background color
- interactive
> user interaction affects text color, border color and background color
>
> - can be disabled
> - can be active
> - can be exact-active
> - can be hovering
- interactive-raised
> combine `raised` and `interactive`
[-> Example: #interactive-and-or-raised](#interactive-and-or-raised)
## Theme color definitions
All colors are defined in `front/src/style/colors.scss`.
Its structure is as follows:
<Layout stack style="--width:100%; background:#0002;">
<Card full solid purple title="(1) Palette">
Defining [all funkwhale color values](#palette)
</Card>
<Card full solid blue title="(2) Choosing the semantic colors from the palette">
both for light and dark theme, and both normal and raised:
- default
- secondary
- primary
- destructive
- blue
- red
- purple
- green
- yellow
</Card>
<Card full solid green title="(3) Applying colors">
(for all elements not managed by vitepress)
- (a) Applying colors to things with no explicit Variant props, such as headings and paragraphs, simple links, focusable elements
- (b) Applying colors to things with explicit Variant props: - solid (and default buttons) - ghost - outline
</Card>
</Layout>
## Change a color value
For example, you want to make all blue hues more cyan-ish or mute all reds.
- Find the color value you want to change in **section 1 (Palette)**
- Change it
- Keep this page open and check under [Palette](#palette) if the colors are still in harmony with each other. Also make sure all recommended color combinations still meet the relevant WCGA2 recommendations for contrast
## Alter the shade of a color
In funkwhale components, we use semantic color names such as `primary`, `destructive`, `default` (neutral). Now, you find, for example, that a secondary button has low contrast in light mode, and you decide to alter the text color.
- Find the relevant section in **section 2 (hoosing the semantic colors from the palette)**. In our example, it's `.secondary` under `theme-light`.
- You see that the values are `--color: var(--fw-gray-700);` and `--background-color: var(--fw-gray-200);`, and you change `--color` to `var(--fw-gray-900)`.
- Test your changes. If nothing changes, then there is a Css rule with higher precedence. Find and eliminate these sorts of competing rules.
## Choose a different style for a specific variant
For example, you want to add visible lines around ghost links and buttons when the user hovers over them.
- Find the variant class in **section 3 (Applying colors)**. In our example, it is `.ghost`.
- In our example, we would add the line `border-color: var(--hover-border-color);` under `&:hover` to make the outline on interactive items visible on hover.
- Make sure to test all affected components before committing and merging the changes
## Overview of current styles
Make sure that:
- Contrasts meet WCAG2 requirements
- Colors and outlines communicate the correct dose of prominence
- All styles are different enough from each other to not be confused
Here is the meaning the styles should convey:
- **active**: The user has chosen this option
- **Here**: The user is exactly here
- **raised**: Things on this surface complement the main area (sidebar, aside, ...)
- **default**: Background of the app
- **secondary**: This is interactive! As of now, secondary things need a secondary background.
- **primary**: Important!
### Links and buttons
---
<Layout flex style="--gap:4px;">
<Card min-content title="Default" solid default>
<span>
Inline <Link to=""> Link </Link> and <Link to=""> Link </Link>
</span>
---
<Layout flex style="margin: 0 -22px; justify-content:space-evenly;">
<Layout >
Here
<Link ghost :to="here"> ghost </Link>
<Link outline :to="here"> outline </Link>
<Link solid raised :to="here"> solid raised </Link>
</Layout>
<Layout >
Elsewhere
<Link ghost to="-"> ghost </Link>
<Link outline to="-"> outline </Link>
<Link solid raised to="-"> solid raised </Link>
</Layout>
</Layout>
---
<Button> Button </Button>
<Button ghost> Button ghost </Button>
<Button outline> Button outline </Button>
<Button solid> Button solid </Button>
<Button solid aria-pressed > Button active </Button>
<Button disabled> Button disabled </Button>
<Button primary> Button primary </Button>
---
<Button raised> Button raised </Button>
<Button raised ghost> Button raised ghost </Button>
<Button raised outline> Button raised outline </Button>
<Button raised solid> Button raised solid </Button>
<Button raised aria-pressed > Button active </Button>
<Button raised disabled> Button disabled </Button>
</Card>
<Card min-content title="Default raised" solid default raised>
<span>
Inline <Link to=""> Link </Link> and <Link to=""> Link </Link>
</span>
---
<Layout flex style="margin: 0 -22px; justify-content:space-evenly;">
<Layout >
Here
<Link ghost :to="here"> ghost </Link>
<Link outline :to="here"> outline </Link>
<Link solid raised :to="here"> solid raised </Link>
</Layout>
<Layout >
Elsewhere
<Link ghost to="-"> ghost </Link>
<Link outline to="-"> outline </Link>
<Link raised to="-"> solid raised </Link>
</Layout>
</Layout>
---
<Button> Button </Button>
<Button ghost> Button ghost </Button>
<Button outline> Button outline </Button>
<Button solid> Button solid </Button>
<Button aria-pressed > Button active </Button>
<Button disabled> Button disabled </Button>
<Button primary> Button primary </Button>
---
<Button raised> Button raised </Button>
<Button raised ghost> Button raised ghost </Button>
<Button raised outline> Button raised outline </Button>
<Button raised solid> Button raised solid </Button>
<Button raised aria-pressed > Button active </Button>
<Button raised disabled> Button disabled </Button>
</Card>
<Card min-content title="Secondary" solid secondary>
<span>
Inline <Link to=""> Link </Link> and <Link to=""> Link </Link>
</span>
---
<Layout flex style="margin: 0 -22px; justify-content:space-evenly;">
<Layout >
Here
<Link ghost :to="here"> ghost </Link>
<Link outline :to="here"> outline </Link>
<Link solid raised :to="here"> solid raised </Link>
</Layout>
<Layout >
Elsewhere
<Link ghost to="-"> ghost </Link>
<Link outline to="-"> outline </Link>
<Link solid raised to="-"> solid raised </Link>
</Layout>
</Layout>
---
<Button> Button </Button>
<Button ghost> Button ghost </Button>
<Button outline> Button outline </Button>
<Button solid> Button solid </Button>
<Button aria-pressed > Button active </Button>
<Button disabled> Button disabled </Button>
<Button primary> Button primary </Button>
---
<Button raised> Button raised </Button>
<Button raised ghost> Button raised ghost </Button>
<Button raised outline> Button raised outline </Button>
<Button raised solid> Button raised solid </Button>
<Button raised aria-pressed > Button raised active </Button>
<Button raised disabled> Button raised disabled </Button>
</Card>
<Card min-content title="Secondary raised" solid secondary raised>
<span>
Inline <Link to=""> Link </Link> and <Link to=""> Link </Link>
</span>
---
<Layout flex style="margin: 0 -22px; justify-content:space-evenly;">
<Layout >
Here
<Link ghost :to="here"> ghost </Link>
<Link outline :to="here"> outline </Link>
<Link solid raised :to="here"> solid raised </Link>
</Layout>
<Layout >
Elsewhere
<Link ghost to="-"> ghost </Link>
<Link outline to="-"> outline </Link>
<Link solid raised to="-"> solid raised </Link>
</Layout>
</Layout>
---
<Button> Button </Button>
<Button ghost> Button ghost </Button>
<Button outline> Button outline </Button>
<Button solid> Button solid </Button>
<Button solid aria-pressed > Button active </Button>
<Button disabled> Button disabled </Button>
<Button primary> Button primary </Button>
---
<Button raised> Button raised </Button>
<Button raised ghost> Button raised ghost </Button>
<Button raised outline> Button raised outline </Button>
<Button raised solid> Button raised solid </Button>
<Button raised aria-pressed > Button active </Button>
<Button raised disabled> Button raised disabled </Button>
</Card>
</Layout>
<style module>
.swatch {
transition:all .15s, filter 0s;
border-radius: 2em;
min-width: 3.2em;
min-height: 3.2em;
margin: 1ch;
display: inline-flex;
box-shadow: 1px 2px 7px #0003, 0px 0px 1px #0009;
align-items:center;
justify-items:center;
position:relative;
&::after {
position:absolute;
inset:13%;
content:"fw";
line-height:2.68em;
font-size:80%;
text-align: center;
transform:rotate(15deg);
letter-spacing:-.02em;
font-weight:800;
}
&:is(:not(:global(.solid)):not(:hover),:has(*))::after {
opacity:0
}
}
:not(:hover)>.deemphasized {
filter:blur(4px);
opacity:.5;
&::after {
opacity:0;
}
}
.small, .tiny {
margin: .25rem;
min-width: 1.6em;
min-height: 1.6em;
box-shadow: 1px 2px 4px #0002, 0px 0px 1px #0007;
&::after{
font-size:43%;
outline: none;
}
}
.tiny{
margin: .5rem .25rem;
min-width: 1em;
min-height: 1em;
box-shadow: 1px 2px 4px #0002, 0px 0px 1px #0007;
&::after{
font-size:27%;
}
}
</style>

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,104 @@
<script setup>
import Card from "~/components/ui/Card.vue"
import Button from "~/components/ui/Button.vue"
import Link from "~/components/ui/Link.vue"
import Layout from "~/components/ui/Layout.vue"
import Spacer from "~/components/ui/Spacer.vue";
</script>
# Using widths
## Add width via prop
<Layout flex class="preview" style="flex-grow: 1">
```vue-html
<Card min-content title='min-content' />
<Card tiny title='tiny' />
<Card buttonWidth title='buttonWidth' />
<Card small title='small' />
<Card medium title='medium' />
<Card auto title='auto' />
<Card width="170.5px" title='width=170.5px' />
<Card full title='full' />
```
<Card min-content title='min-content' />
<Card tiny title='tiny' />
<Card buttonWidth title='buttonWidth' />
<Card small title='small' />
<Card medium title='medium' />
<Card auto title='auto' />
<Card width="170.5px" title='width=170.5px' />
<Card full title='full' />
</Layout>
## Small height and square aspect ratio
<Layout grid class="preview">
<div style="grid-column: 1 / 7; grid-row: span 2">
```vue-html
<Button outline icon="bi-star"/>
<Button outline icon="bi-star large"/>
<Button outline square-small icon="bi-star" />
<Button outline square-small icon="bi-star large" />
<Button primary square >b</Button>
<Button primary >c</Button>
<Button primary square-small >a</Button>
<Button primary low-height >e</Button>
```
</div>
<Button outline icon="bi-star"/>
<Button outline icon="bi-star large"/>
<Spacer />
<Button outline square-small icon="bi-star" />
<Button outline square-small icon="bi-star large" />
<Spacer />
<Button primary square >b</Button>
<Button primary >c</Button>
<Spacer />
<Button primary square-small >a</Button>
<Button primary low-height >e</Button>
</Layout>
<Layout grid class="preview">
<div style="grid-column: -1 / -6; grid-row: span 4">
```vue-html
<Link icon="bi-star" to="."/>
<Link square-small icon="bi-star" to="."/>
<Link square-small to=".">g</Link>
<Link square to=".">h</Link>
<Link to=".">i</Link>
<Link square-small to=".">j</Link>
<Link low-height to=".">k</Link>
<Link square low-height to=".">l</Link>
```
</div>
<Link icon="bi-star" to="."/>
<Link square-small icon="bi-star" to="."/>
<Link square-small to=".">g</Link>
<Link square to=".">h</Link>
<Link to=".">i</Link>
<Link square-small to=".">j</Link>
<Link low-height to=".">k</Link>
<Link square low-height to=".">l</Link>
</Layout>
## Widths in the grid
::: details Default widths
![alt text](image-1.png)
:::
[Designing Pages — The grid](designing-pages#grid)

View File

@ -0,0 +1,40 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vueDevTools from 'vite-plugin-vue-devtools'
import path from 'node:path'
export default defineConfig({
plugins: [vueDevTools()],
publicDir: false,
resolve: {
alias: {
'~': fileURLToPath(new URL('../src', import.meta.url)),
'#': fileURLToPath(new URL('../src/ui/workers', import.meta.url)),
'/node_modules': fileURLToPath(new URL('../node_modules', import.meta.url))
}
},
css: {
devSourcemap: true,
preprocessorOptions: {
scss: {
additionalData: `
$docs: ${!!process.env.VP_DOCS};
@import "~/style/inc/theme.scss";
@import "~/style/inc/docs.scss";
@import "~/style/funkwhale.scss";
`
}
}
},
build: {
rollupOptions: {
external: ['vue', 'vue-i18n', '@vueuse/core', 'vue-router', 'vue-devtools'],
output: {
globals: {
Vue: 'vue'
}
}
}
}
})