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:
parent
90b853b722
commit
b485f05264
|
@ -5,3 +5,4 @@ networks:
|
|||
include:
|
||||
- path: compose/docs.sphinx.yml
|
||||
- path: compose/docs.openapi.yml
|
||||
- path: compose/docs.ui.yml
|
||||
|
|
|
@ -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'
|
|
@ -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",
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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 }} {{ `​${''}` }}
|
||||
<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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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>
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
})
|
|
@ -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>
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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" />
|
|
@ -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>
|
|
@ -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>
|
|
@ -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 />
|
|
@ -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 />
|
|
@ -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)
|
|
@ -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>
|
|
@ -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"
|
||||
/>
|
|
@ -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>
|
|
@ -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;"
|
||||
```
|
|
@ -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/).
|
|
@ -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.
|
||||
|
||||

|
||||
|
||||
And read [the illustrated overview of all grid properties on css-tricks.com](https://css-tricks.com/snippets/css/complete-guide-grid/).
|
|
@ -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 |
|
@ -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.
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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.
|
|
@ -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" />
|
|
@ -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" />
|
|
@ -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 -->
|
|
@ -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 -->
|
|
@ -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 |
|
@ -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)
|
|
@ -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!
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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)!
|
|
@ -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
|
||||
/>
|
Binary file not shown.
After Width: | Height: | Size: 117 KiB |
Binary file not shown.
After Width: | Height: | Size: 42 KiB |
|
@ -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/`.
|
||||
|
||||
:::
|
|
@ -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?
|
|
@ -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>
|
|
@ -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
|
@ -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
|
||||
|
||||

|
||||
|
||||
:::
|
||||
|
||||
[Designing Pages — The grid](designing-pages#grid)
|
|
@ -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'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
Loading…
Reference in New Issue