refactor(ui): factor out alignment (like width and color)

This commit is contained in:
upsiflu 2025-01-02 00:31:20 +01:00
parent c9f59cbd26
commit 8240856630
19 changed files with 379 additions and 266 deletions

View File

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { usePastel } from '~/composables/colors' import { usePastel } from '~/composables/color'
import { FwCard, FwPlayButton } from '~/components' import { FwCard, FwPlayButton } from '~/components'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'

View File

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { type PastelProps, color } from '~/composables/colors' import { type PastelProps, color } from '~/composables/color'
type Props = PastelProps type Props = PastelProps
@ -9,7 +9,7 @@ const props = defineProps<Props>()
<template> <template>
<div <div
class="funkwhale is-colored solid alert" class="funkwhale is-colored solid alert"
v-bind="color(props)" v-bind="color(props)()"
> >
<slot /> <slot />

View File

@ -1,8 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, useSlots, onMounted } from 'vue' import { ref, computed, useSlots, onMounted } from 'vue'
import { type ColorProps, type VariantProps, type DefaultProps, type RaisedProps, color } from '~/composables/colors'; import { type ColorProps, type VariantProps, type DefaultProps, type RaisedProps, color } from '~/composables/color';
import { type WidthProps, width } from '~/composables/widths' import { type WidthProps, width } from '~/composables/width'
import { type AlignmentProps, align } from '~/composables/alignment'
import Loader from '~/components/ui/Loader.vue' import Loader from '~/components/ui/Loader.vue'
@ -25,7 +26,8 @@ const props = defineProps<{
} & (ColorProps | DefaultProps) } & (ColorProps | DefaultProps)
& VariantProps & VariantProps
& RaisedProps & RaisedProps
& WidthProps>() & WidthProps
& AlignmentProps>()
const slots = useSlots() const slots = useSlots()
const isIconOnly = computed(() => !!props.icon && !slots.default) const isIconOnly = computed(() => !!props.icon && !slots.default)
@ -37,10 +39,11 @@ const fontWeight = props.thin ? 400 : 900
const button = ref() const button = ref()
const attributes = computed(() => ({ const attributes = computed(() =>
...color(props, ['interactive']), color(props, ['interactive'])(
...width(props, [isIconOnly.value ? 'minContent' : 'buttonWidth']) width(props, [isIconOnly.value ? 'minContent' : 'buttonWidth'])(
})) align(props, { alignSelf:'start', alignText:'center' })()
)))
const click = async (...args: any[]) => { const click = async (...args: any[]) => {
internalLoader.value = true internalLoader.value = true
@ -58,21 +61,18 @@ onMounted(() => {
<template> <template>
<button ref="button" <button ref="button"
:aria-pressed="props.ariaPressed"
v-bind="attributes" v-bind="attributes"
class="funkwhale button" class="funkwhale button"
:class="[ :aria-pressed="props.ariaPressed"
'is-text-aligned-' + (alignText ?? 'center'), :class="{
'is-self-aligned-' + (alignSelf ?? 'start'),
{
'is-active': isActive, 'is-active': isActive,
'is-loading': isLoading, 'is-loading': isLoading,
'is-icon-only': isIconOnly, 'is-icon-only': isIconOnly,
'has-icon': !!icon, 'has-icon': !!icon,
'is-round': round, 'is-round': round,
'is-shadow': shadow 'is-shadow': shadow
} }"
]" @click="click" @click="click"
> >
<i v-if="icon" :class="['bi', icon]" /> <i v-if="icon" :class="['bi', icon]" />
@ -87,12 +87,22 @@ onMounted(() => {
<style lang="scss"> <style lang="scss">
.funkwhale { .funkwhale {
&.button { &.button {
// Layout
--padding: 10px; --padding: 10px;
position: relative; position: relative;
display: inline-flex; display: inline-flex;
align-items: center;
white-space: nowrap; white-space: nowrap;
justify-content: space-between;
padding: 9px var(--padding) 11px var(--padding);
&.is-icon-only {
padding: var(--padding);
}
// Font
font-family: $font-main; font-family: $font-main;
font-weight: v-bind(fontWeight); font-weight: v-bind(fontWeight);
@ -100,60 +110,22 @@ onMounted(() => {
line-height: 1em; line-height: 1em;
// Padding // Decoration
padding: 9px var(--padding) 11px var(--padding);
&.is-icon-only {
padding: 10px;
}
border-radius: var(--fw-border-radius);
transform: translateX(var(--fw-translate-x)) translateY(var(--fw-translate-y)) scale(var(--fw-scale)); transform: translateX(var(--fw-translate-x)) translateY(var(--fw-translate-y)) scale(var(--fw-scale));
transition: all .2s ease; transition: all .2s ease;
&.is-text-aligned-center {
justify-content: center
}
&.is-text-aligned-left {
justify-content: flex-start
}
&.is-text-aligned-right {
justify-content: flex-end;
}
&.is-shadow { &.is-shadow {
box-shadow: 0px 2px 4px 0px rgba(0, 0, 0, 0.2); box-shadow: 0px 2px 4px 0px rgba(0, 0, 0, 0.2);
} }
align-self: start; border-radius: var(--fw-border-radius);
&.is-self-aligned-start {
align-self: flex-start;
}
&.is-self-aligned-center {
align-self: center;
}
&.is-self-aligned-end {
align-self: flex-end;
}
&.is-self-aligned-baseline {
align-self: baseline;
}
&.is-full {
align-self: stretch;
}
&.is-round { &.is-round {
border-radius: 100vh; border-radius: 100vh;
} }
// States
&[disabled] { &[disabled] {
font-weight: normal; font-weight: normal;
pointer-events: none; pointer-events: none;
@ -167,6 +139,8 @@ onMounted(() => {
} }
} }
// Icon
i.bi { i.bi {
font-size: 18px; font-size: 18px;
margin: -2px 0; margin: -2px 0;

View File

@ -2,8 +2,8 @@
import { computed } from 'vue' import { computed } from 'vue'
import { type RouterLinkProps, RouterLink } from 'vue-router'; import { type RouterLinkProps, RouterLink } from 'vue-router';
import { type ColorProps, type DefaultProps, type PastelProps, type RaisedProps, type VariantProps, color } from '~/composables/colors' import { type ColorProps, type DefaultProps, type PastelProps, type RaisedProps, type VariantProps, color } from '~/composables/color'
import { type WidthProps, width } from '~/composables/widths' import { type WidthProps, width } from '~/composables/width'
import Pill from './Pill.vue' import Pill from './Pill.vue'
import Alert from './Alert.vue' import Alert from './Alert.vue'
@ -30,10 +30,10 @@ const isExternalLink = computed(() => {
return typeof props.to === 'string' && props.to.startsWith('http') return typeof props.to === 'string' && props.to.startsWith('http')
}) })
const attributes = computed(() => ({ const attributes = computed(() =>
...color(props, props.to ? ['interactive', 'solid'] : []), color(props, props.to ? ['interactive', 'solid'] : [])(
...width(props, ['medium']) width(props, ['medium'])()
})) ))
</script> </script>
<template> <template>
@ -103,7 +103,7 @@ const attributes = computed(() => ({
<style module> <style module>
.card { .card {
--fw-card-padding: v-bind("props.small ? '16px' : '24px'"); --fw-card-padding: v-bind("'small' in props ? '16px' : '24px'");
position: relative; position: relative;

View File

@ -1,84 +1,97 @@
<script setup lang="ts"> <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<{ const props = defineProps<{
columnWidth?: number, columnWidth?: string,
noGap?:true, noGap?: true,
noRule?:true, noRule?: true,
noWrap?:true noWrap?: true
} }
& { [P in "stack" | "grid" | "flex" | "columns" | "row" | "page"]?: true | string } & { [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 }>() & { [C in "nav" | "aside" | "header" | "footer" | "main" | "label" | "form" | "h1" | "h2" | "h3" | "h4" | "h5"]?: true }
& (PastelProps | ColorProps | DefaultProps)
& RaisedProps
& VariantProps
& WidthProps>()
const columnWidth = props.columnWidth ?? 46 const columnWidth = props.columnWidth ?? '46px'
const attributes = computed(() => ({
...color(props)(width(props)()),
layout:
props.grid === true ? 'grid' :
props.grid ? 'grid-custom' :
props.flex ? 'flex' :
props.columns ? 'columns' :
'stack'
}))
</script> </script>
<template> <template>
<component <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'" :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="[ :class="[
$style.layout, $style.layout,
noGap || $style.gap, noGap || $style.gap,
noWrap || $style.wrap, noWrap || $style.wrap,
props.grid ? $style[props.grid===true ? 'grid' : 'grid-custom'] ]" v-bind="attributes">
: props.flex ? $style.flex <slot />
: props.columns? $style.columns </component>
: $style.stack
]">
<slot />
</component>
</template> </template>
<style module> <style module>
.layout{ .layout {
transition:all .15s; transition: all .15s;
/* Override --gap with your preferred value */ /* Override --gap with your preferred value */
&.gap { gap: var(--gap, 32px);
gap: var(--gap, 32px);
}
&:not(.gap) { &:not(.gap) {
gap: 0; gap: 0;
} }
/* Growth */ /* Growth */
&:has(:global(>.grow)){ &:has(:global(>.grow)) {
>:not(:global(.grow)){ >:not(:global(.grow)) {
flex-grow:0; flex-grow: 0;
} }
} }
/* Layout strategy */ /* Layout strategy */
&.columns { &[layout=columns] {
column-count: auto; column-count: auto;
column-width: calc(v-bind(columnWidth) * 1px); column-width: v-bind(columnWidth);
display: block; display: block;
column-rule: 1px solid v-bind("noRule ? 'transparent' : 'var(--border-color)'"); column-rule: 1px solid v-bind("noRule ? 'transparent' : 'var(--border-color)'");
} }
&.grid { &[layout=grid] {
display: grid; display: grid;
grid-template-columns: grid-template-columns:
repeat(auto-fit, calc(v-bind(columnWidth) * 1px)); repeat(auto-fit, v-bind(columnWidth));
grid-auto-flow: row dense; grid-auto-flow: row dense;
place-content: center; /* If the grid has a fixed size smaller than its container, center it */ place-content: center;
/* If the grid has a fixed size smaller than its container, center it */
} }
&.grid-custom { &[layout=grid-custom] {
display: grid; display: grid;
grid: v-bind("props.grid"); grid: v-bind(grid);
grid-auto-flow: row dense; grid-auto-flow: row dense;
place-content: center; /* If the grid has a fixed size smaller than its container, center it */ place-content: center;
/* If the grid has a fixed size smaller than its container, center it */
} }
&.stack { &[layout=stack] {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
&.flex { &[layout=flex] {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
flex-wrap: v-bind('props.noWrap ? "nowrap" : "wrap"'); flex-wrap: v-bind('props.noWrap ? "nowrap" : "wrap"');

View File

@ -1,13 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { type RouterLinkProps, RouterLink } from 'vue-router' import { type RouterLinkProps } from 'vue-router'
import { type ColorProps, type DefaultProps, type VariantProps, color, isNoColors } from '~/composables/colors'; import { type ColorProps, type DefaultProps, type VariantProps, color, isNoColors } from '~/composables/color';
import { type WidthProps, width } from '~/composables/widths' import { type WidthProps, width } from '~/composables/width'
import { type AlignmentProps, align } from '~/composables/alignment'
const props = defineProps<{ const props = defineProps<{
alignText?: 'left' | 'center' | 'right' | 'stretch'
alignSelf?: 'start' | 'center' | 'end'
thickWhenActive?: true thickWhenActive?: true
thin?: true thin?: true
@ -17,41 +16,37 @@ const props = defineProps<{
} & RouterLinkProps } & RouterLinkProps
& (ColorProps | DefaultProps) & (ColorProps | DefaultProps)
& VariantProps & VariantProps
& WidthProps>() & WidthProps
& AlignmentProps>()
const isExternalLink = computed(() => { const isExternalLink = computed(() =>
return typeof props.to === 'string' && props.to.startsWith('http') typeof props.to === 'string' && props.to.startsWith('http')
}) )
const [fontWeight, activeFontWeight] = props.thickWhenActive ? [600, 900] : [400, 400] const [fontWeight, activeFontWeight] = props.thickWhenActive ? [600, 900] : [400, 400]
const isIconOnly = computed(() => !!props.icon) const isIconOnly = computed(() => !!props.icon)
const isSimple = isNoColors(props)
const attributes = computed(() => ({
...color(props, ['interactive']),
...width(props, ['auto'])
}))
</script> </script>
<template> <template>
<component :is="isExternalLink ? 'a' : 'RouterLink'" <component :is="isExternalLink ? 'a' : 'RouterLink'"
v-bind="attributes" v-bind="color(props, ['interactive'])(
width(props, ['auto'])(
align(props, { alignText:'center' })
()))"
:class="[ :class="[
$style.link, $style.link,
$style['is-' + width],
$style['is-text-aligned-' + (alignText ?? 'left')],
$style['is-self-aligned-' + (alignSelf ?? 'auto')],
round && $style['is-round'], round && $style['is-round'],
isIconOnly && $style['is-icon-only'], isIconOnly && $style['is-icon-only'],
isSimple && $style['force-underline'], isNoColors(props) && $style['force-underline'],
isSimple && $style['no-spacing'], isNoColors(props) && $style['no-spacing'],
]" ]"
:href="isExternalLink ? to.toString() : undefined" :href="isExternalLink ? to.toString() : undefined"
:to="isExternalLink ? undefined : to" :to="isExternalLink ? undefined : to"
:target="isExternalLink ? '_blank' : undefined" :target="isExternalLink ? '_blank' : undefined"
> >
<i v-if="icon" :class="['bi', icon]" /> <i v-if="icon" :class="['bi', icon]" />
<span> <span>
<slot /> <slot />
</span> </span>
@ -60,47 +55,16 @@ const attributes = computed(() => ({
<style module lang="scss"> <style module lang="scss">
.link { .link {
// Layout
--padding: 10px;
position: relative;
display: inline-flex;
white-space: nowrap; white-space: nowrap;
justify-content: space-between; justify-content: space-between;
font-family: $font-main;
font-weight: v-bind(fontWeight);
&:global(.router-link-exact-active) {
font-weight: v-bind(activeFontWeight);
}
font-size: 0.875em;
line-height: 1em;
transform: translateX(var(--fw-translate-x)) translateY(var(--fw-translate-y)) scale(var(--fw-scale));
transition:background-color .2s, border-color .3s;
// Icon
i:global(.bi) {
font-size: 1.2rem;
&.large {
font-size:2rem;
}
}
i:global(.bi) + span:not(:empty) {
margin-left: 1ch;
}
&:not(.force-underline) {
text-decoration: none;
background-color: transparent;
border-color: transparent;
}
/* Shape */
border-radius: var(--fw-border-radius);
margin: 0 0.5ch;
padding: 9px 10px 11px 10px; padding: 9px 10px 11px 10px;
&.is-icon-only { &.is-icon-only {
padding: 10px; padding: 10px;
@ -111,54 +75,49 @@ const attributes = computed(() => ({
font-size: 1em; font-size: 1em;
} }
// Font
font-family: $font-main;
font-weight: v-bind(fontWeight);
font-size: 14px;
line-height: 1em;
// Decoration
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 { &.is-round {
border-radius: 100vh; border-radius: 100vh;
} }
/* Alignment */ // States
display: inline-flex; &:global(.router-link-exact-active) {
align-items: center; font-weight: v-bind(activeFontWeight);
justify-content: flex-start;
&.is-text-aligned-center {
justify-content: center
} }
&.is-text-aligned-left { // Icon
justify-content: flex-start
}
&.is-text-aligned-right { > i:global(.bi) {
justify-content: flex-end; font-size: 1.2rem;
} &.large {
font-size:2rem;
&.is-text-aligned-stretch { }
justify-content: stretch; &+span:not(:empty) {
align-content: stretch; margin-left: 1ch;
> span { width:100%; } }
}
&.is-self-aligned-start {
align-self: flex-start;
}
&.is-self-aligned-center {
align-self: center;
}
&.is-self-aligned-auto {
align-self: auto;
}
&.is-self-aligned-end {
align-self: flex-end;
}
/* Width */
&.is-full {
align-self: stretch;
} }
} }
</style> </style>

View File

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { type ColorProps, type DefaultProps, color } from '~/composables/colors'; import { type ColorProps, type DefaultProps, color } from '~/composables/color';
import { watchEffect } from 'vue'; import { watchEffect } from 'vue';
import onKeyboardShortcut from '~/composables/onKeyboardShortcut'; import onKeyboardShortcut from '~/composables/onKeyboardShortcut';

View File

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { type ColorProps, type PastelProps, color } from '~/composables/colors' import { type ColorProps, type PastelProps, color } from '~/composables/color'
const emit = defineEmits<{ const emit = defineEmits<{
click: [event: MouseEvent] click: [event: MouseEvent]
@ -13,7 +13,7 @@ const props = defineProps<PastelProps | ColorProps>()
<template> <template>
<button <button
class="funkwhale pill outline" class="funkwhale pill outline"
v-bind="color(props, ['interactive'])" v-bind="color(props, ['interactive'])()"
@click.stop="handleClick" @click.stop="handleClick"
> >
<div v-if="!!$slots.image" class="pill-image"> <div v-if="!!$slots.image" class="pill-image">

View File

@ -4,7 +4,7 @@ import { whenever, useElementBounding, onClickOutside } from '@vueuse/core'
import { isMobileView, useScreenSize } from '~/composables/screen' import { isMobileView, useScreenSize } from '~/composables/screen'
import { POPOVER_INJECTION_KEY, POPOVER_CONTEXT_INJECTION_KEY } from '~/injection-keys' import { POPOVER_INJECTION_KEY, POPOVER_CONTEXT_INJECTION_KEY } from '~/injection-keys'
import { type ColorProps, type DefaultProps, type RaisedProps, color } from '~/composables/colors'; import { type ColorProps, type DefaultProps, type RaisedProps, color } from '~/composables/color';
/* TODO: Basic accessibility /* TODO: Basic accessibility
@ -144,7 +144,7 @@ watch(open, (isOpen) => {
:style="position" :style="position"
:class="{ 'is-mobile': isMobile }" :class="{ 'is-mobile': isMobile }"
class="funkwhale popover" class="funkwhale popover"
v-bind="color(colorProps)" v-bind="color(colorProps)()"
style="display:flex; flex-direction:column;" style="display:flex; flex-direction:column;"
> >
<slot name="items" /> <slot name="items" />

View File

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { color } from "~/composables/colors.ts"; import { color } from "~/composables/color";
const { big } = defineProps<{ const { big } = defineProps<{
big?: boolean big?: boolean

View File

@ -0,0 +1,90 @@
import type { Entries, Entry, KeysOfUnion } from "type-fest"
import type { HTMLAttributes } from "vue";
export type AlignmentProps = {
alignText?: 'left' | 'center' | 'right' | 'stretch' | 'space-out',
alignSelf?: 'start' | 'center' | 'end' | 'auto' | 'baseline' | 'stretch'
} & {
[T in 'center' | 'stretch']?: true
}
export type Key = KeysOfUnion<AlignmentProps>
const styles = {
center: 'place-content: center center; place-self: center center;',
stretch: 'place-content: stretch stretch; place-self: stretch stretch;',
alignText: (a:AlignmentProps['alignText'])=>({
left: 'justify-content: flex-start;',
center: 'justify-content: center;',
baseline: 'align-items: baseline;',
right: 'justify-content: flex-end;',
stretch: 'place-content: stretch;',
'space-out': 'place-content: space-between;'
}[a!]),
alignSelf: (a:AlignmentProps['alignSelf'])=> ({
start: 'align-self: flex-start;',
center: 'align-self: center;',
end: 'align-self: flex-end;',
auto: 'align-self: auto;',
baseline: 'align-self: baseline;',
stretch: 'align-self: stretch;'
}[a!])
} as const;
const getStyle = (props : Partial<AlignmentProps>) => ([key, value]: Entry<AlignmentProps>) =>
(trace(`getStyle: key=${key}, value=${value}`),
typeof styles[key] === 'function' ?
(trace(`getStyle: key=${key}, value=${value}`), styles[key](
// We know that props[key] is a value accepted by styles[key]. The ts compiler is not so smart.
// @ts-ignore
(key in props && props[key]) ?
props[(trace(props[key]), trace(key))]
: value
))
: styles[key]
)
const merge = (rules: string[]) => (attributes: HTMLAttributes = {}) =>
rules.length === 0 ? attributes : ({
...attributes,
style: rules.join(' ')+('style' in attributes ? attributes.style + ' ' : '')
})
// All keys are exclusive
const conflicts: Set<Key>[] = [
new Set(['center', 'stretch']),
new Set(['alignText']),
new Set(['alignSelf']),
]
/**
* Add alignment styles to your component.
* Alignments are designed to work both in a grid and flex contexts but may fail in normal context.
*
* (1) Add `& AlignmentProps` to your `Props` type
* (2) Call `v-bind="alignment(props)"` on your component template
* (3) Now your component accepts width props such as `align-text="center"`.
*
* @param props Your component's props (or ...rest props if you have destructured them already)
* @param defaults These props are applied immediately and can be overridden by the user
* @param attributes Optional: To compose width, color, alignment, etc.
* @returns the corresponding `{ style }` object
*/
export const align = <TProps extends Partial<AlignmentProps>>(
props: TProps,
defaults: Partial<AlignmentProps> = {}
) => merge (
trace((Object.entries(props) as Entries<TProps>).reduce(
((acc, [key, value]) =>
value && key in styles ?
acc.filter(([accKey, _]) => !conflicts.find(set => set.has(accKey) && set.has(key)))
.concat([[key, value]])
: acc
),
trace(Object.entries(defaults)) as Entries<Partial<AlignmentProps>>
)).map(getStyle(props))
)
const trace = <T extends unknown>(a:T):T => {
console.log(a);
return a;
}

View File

@ -1,4 +1,5 @@
import type { KeysOfUnion } from "type-fest" import type { KeysOfUnion } from "type-fest"
import type { HTMLAttributes } from "vue"
export type DefaultProps = export type DefaultProps =
| { default?: true } | { default?: true }
@ -68,7 +69,13 @@ const classes = {
* @returns the number of actually applied classes (if there are no defaults) * @returns the number of actually applied classes (if there are no defaults)
*/ */
export const isNoColors = (props: Partial<Props>) => export const isNoColors = (props: Partial<Props>) =>
color(props).class==='' !color(props)().class
const merge = (classes: string[]) => (attributes: HTMLAttributes = {}) =>
classes.length === 0 ? attributes : ({
...attributes,
class: classes.join(' ') + ('class' in attributes ? attributes.class + ' ' : '')
})
/** /**
* Add color classes to your component. * Add color classes to your component.
@ -79,10 +86,12 @@ export const isNoColors = (props: Partial<Props>) =>
* (3) Now your component accepts color props such as `secondary outline raised`. * (3) Now your component accepts color props such as `secondary outline raised`.
* *
* @param props Your component's props (or ...rest props if you have destructured them already) * @param props Your component's props (or ...rest props if you have destructured them already)
* @param defaults These props are applied immediately and can be overridden by the user
* @param attributes Optional: To compose width, color, alignment, etc.
* @returns the corresponding `class` object * @returns the corresponding `class` object
*/ */
export const color = (props: Partial<Props>, defaults?: Key[]) => ({ export const color = (props: Partial<Props>, defaults?: Key[]) =>
class: merge(
Object.entries(props).reduce( Object.entries(props).reduce(
((acc, [key, value]) => ((acc, [key, value]) =>
value && key in classes ? value && key in classes ?
@ -91,8 +100,8 @@ export const color = (props: Partial<Props>, defaults?: Key[]) => ({
: acc : acc
), ),
defaults || [] defaults || []
).join(' ') )
}) )
type ColorSelector = type ColorSelector =
`${Color | Pastel | Default}${'' | ` ${Variant}${'' | ' interactive'}${'' | ' raised'}`}` `${Color | Pastel | Default}${'' | ` ${Variant}${'' | ' interactive'}${'' | ' raised'}`}`

View File

@ -1,4 +1,5 @@
import type { Entries, KeysOfUnion } from "type-fest" import type { Entries, KeysOfUnion } from "type-fest"
import type { HTMLAttributes } from "vue";
export type WidthProps = export type WidthProps =
| { minContent?: true } | { minContent?: true }
@ -14,16 +15,17 @@ export type Key = KeysOfUnion<WidthProps>
const styles = { const styles = {
minContent: 'width: min-content;', minContent: 'width: min-content;',
tiny: "width: 124px; grid-column: span 2;", tiny: "width: 124px; grid-column: span 2;",
buttonWidth: "width: 136px; grid-column: span 2; flex-grow: 0; align-self: start;", buttonWidth: "width: max(136px, min-content); grid-column: span 2; flex-grow: 0; align-self: start;",
small: "width: 202px; grid-column: span 3;", small: "width: 202px; grid-column: span 3;",
medium: "width: 280px; grid-column: span 4;", medium: "width: 280px; grid-column: span 4;",
auto: "width: auto;", auto: "width: auto;",
full: "width: auto; grid-column: 1 / -1; align-self: auto; flex-grow:1;", full: "width: auto; grid-column: 1 / -1; place-self: stretch; flex-grow: 1;",
width: (w:string)=>`width: ${w}; flex-grow:0;`, width: (w:string)=>`width: ${w}; flex-grow:0;`,
} as const satisfies Record<Key, string|((w:string)=>string)>; } as const satisfies Record<Key, string|((w:string)=>string)>;
const getStyle = (props : Partial<WidthProps>) => (key : Key) => const getStyle = (props : Partial<WidthProps>) => (key : Key):string =>
(typeof styles[key] !== 'string' && key in props) ? // @ts-ignore
(typeof styles[key] === 'function' && key in props) ?
styles[key]( styles[key](
// TODO: Make the typescript compiler understand `key in props` // TODO: Make the typescript compiler understand `key in props`
// @ts-ignore // @ts-ignore
@ -31,13 +33,17 @@ const getStyle = (props : Partial<WidthProps>) => (key : Key) =>
) )
: styles[key] : styles[key]
// All keys are exclusive // All keys are exclusive
const conflicts: Set<Key>[] = [ const conflicts: Set<Key>[] = [
new Set(Object.keys(styles) as Key[]) new Set(Object.keys(styles) as Key[])
] ]
const merge = (rules: string[]) => (attributes: HTMLAttributes = {}) =>
rules.length === 0 ? attributes : ({
...attributes,
style: rules.join(' ') + ('style' in attributes ? attributes.style + ' ' : '')
})
/** /**
* Add a width style to your component. * Add a width style to your component.
* Widths are designed to work both in a page-grid context and in a flex or normal context. * Widths are designed to work both in a page-grid context and in a flex or normal context.
@ -47,21 +53,21 @@ const conflicts: Set<Key>[] = [
* (3) Now your component accepts width props such as `small`, `medium`, `stretch`. * (3) Now your component accepts width props such as `small`, `medium`, `stretch`.
* *
* @param props Your component's props (or ...rest props if you have destructured them already) * @param props Your component's props (or ...rest props if you have destructured them already)
* @param defaults These props are applied immediately and can be overridden by the user
* @param attributes Optional: To compose width, color, alignment, etc.
* @returns the corresponding `{ style }` object * @returns the corresponding `{ style }` object
*/ */
export const width = <TProps extends Partial<WidthProps>>( export const width = <TProps extends Partial<WidthProps>>(
props: TProps, props: TProps,
defaults: Key[] = [] defaults: Key[] = []
) => ({ ) => merge (
style: (Object.entries(props) as Entries<TProps>).reduce(
(Object.entries(props) as Entries<TProps>).reduce( ((acc, [key, value]) =>
((acc, [key, value]) => value && key in styles ?
value && key in styles ? acc.filter(accKey => !conflicts.find(set => set.has(accKey) && set.has(key)))
acc.filter(accKey => !conflicts.find(set => set.has(accKey) && set.has(key))) .concat([key])
.concat([key]) : acc
: acc ),
), defaults
defaults ).map(getStyle(props))
).map(getStyle(props)) )
.join(' ')
})

View File

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, nextTick } from 'vue' import { onMounted, nextTick } from 'vue'
import { color } from '~/composables/colors.ts'; import { color } from '~/composables/color';
import Sidebar from '~/ui/components/Sidebar.vue' import Sidebar from '~/ui/components/Sidebar.vue'
import ShortcutsModal from './modals/Shortcuts.vue' import ShortcutsModal from './modals/Shortcuts.vue'

View File

@ -3,7 +3,7 @@ import { ref, onMounted, watch, computed } from 'vue'
import { useUploadsStore } from '../stores/upload' import { useUploadsStore } from '../stores/upload'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useStore } from '~/store' import { useStore } from '~/store'
import { color } from '~/composables/colors' import { color } from '~/composables/color'
import Logo from '~/components/Logo.vue' import Logo from '~/components/Logo.vue'
import Input from '~/components/ui/Input.vue' import Input from '~/components/ui/Input.vue'

View File

@ -7,12 +7,10 @@ const click = ():Promise<void> => new Promise(resolve => setTimeout(resolve, 100
# Button # Button
Buttons are UI elements that users can interact with to perform actions. Funkwhale uses buttons in many contexts. 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 ```ts
{ {
alignText?: 'left' | 'center' | 'right'
alignSelf?: 'start' | 'center' | 'end'
thin?: true thin?: true
isActive?: boolean isActive?: boolean
@ -30,6 +28,7 @@ Buttons are UI elements that users can interact with to perform actions. Funkwha
& VariantProps & VariantProps
& RaisedProps & RaisedProps
& WidthProps & WidthProps
& AlignmentProps
``` ```
## Button colors ## Button colors
@ -240,26 +239,30 @@ Round buttons have fully rounded edges.
You can pass a state to indicate whether a user can interact with a button. You can pass a state to indicate whether a user can interact with a button.
### Active ### On/Off
You can force an active state by passing an `aria-pressed` prop. You can force an active state by passing an `aria-pressed` prop.
This can be useful for toggle buttons (if you don't want to use a [Toggle component](toggle)) This can be useful for toggle buttons (if you don't want to use a [Toggle component](toggle))
```vue-html ```vue-html
<Button>
Off
</Button>
<Button aria-pressed> <Button aria-pressed>
Active button On
</Button> </Button>
``` ```
**Default:** **Default:**
<Button> <Button>
Inactive button Off
</Button> </Button>
<Button aria-pressed> <Button aria-pressed>
Active button On
</Button> </Button>
--- ---
@ -267,11 +270,11 @@ This can be useful for toggle buttons (if you don't want to use a [Toggle compo
**Secondary:** **Secondary:**
<Button secondary> <Button secondary>
Inactive button Off
</Button> </Button>
<Button secondary aria-pressed> <Button secondary aria-pressed>
Active button On
</Button> </Button>
--- ---
@ -279,17 +282,25 @@ This can be useful for toggle buttons (if you don't want to use a [Toggle compo
**Primary:** **Primary:**
<Button primary> <Button primary>
Inactive button Off
</Button> </Button>
<Button primary aria-pressed> <Button primary aria-pressed>
Active button On
</Button> </Button>
### Disabled ### Disabled
Disabled buttons are non-interactive and inherit a less bold color than the one provided. You can apply a disabled state by passing a `disabled` prop. Disabled buttons are non-interactive and inherit a less bold color than the one provided. You can apply a disabled state by passing a `disabled` prop.
::: tip When should 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 ```vue-html
<Button disabled> <Button disabled>
Disabled button Disabled button
@ -314,10 +325,6 @@ If a user can't interact with a button until something has finished loading, you
Loading button Loading button
</Button> </Button>
<Button primary is-loading>
Loading button
</Button>
### Promise handling in `@click` ### 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. 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.
@ -352,7 +359,7 @@ You can override the promise state by passing a false `is-loading` prop.
Click me Click me
</Button> </Button>
## Icons ## Add an icon
You can use [Bootstrap Icons](https://icons.getbootstrap.com/) in your button component You can use [Bootstrap Icons](https://icons.getbootstrap.com/) in your button component
@ -378,7 +385,9 @@ Icon buttons shrink down to the icon size if you don't pass any content. If you
</Button> </Button>
</Layout> </Layout>
## Width and alignment ## Set width and alignment
See [Using width](/using-width) and [Using alignment](/using-alignment)
<Layout flex> <Layout flex>

View File

@ -0,0 +1,63 @@
<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="left">🐌</Button>
<Button align-text="center">🐌</Button>
<Button align-text="right">🐌</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="left">🐌</Button>
<Button align-text="center">🐌</Button>
<Button align-text="right">🐌</Button>
<Button icon="bi-star" align-text="stretch">🐌</Button>
<Button icon="bi-star" align-text="space-out">🐌</Button>
</Layout>
</Layout>

View File

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { color, setColors } from "~/composables/colors.ts" import { color, setColors } from "~/composables/color"
import { useRoute } from "vue-router" import { useRoute } from "vue-router"
import Button from "~/components/ui/Button.vue" import Button from "~/components/ui/Button.vue"
@ -59,12 +59,12 @@ import { color } from "~/composables/colors.ts";
</script> </script>
<template> <template>
<div v-bind="color({}, ['primary', 'solid', 'interactive', 'raised'])" /> <div v-bind="color({}, ['primary', 'solid', 'interactive', 'raised'])()" />
</template> </template>
``` ```
<div class="preview"> <div class="preview">
<div :class="$style.swatch" v-bind="color({}, ['primary', 'solid', 'interactive', 'raised'])" /> <div :class="$style.swatch" v-bind="color({}, ['primary', 'solid', 'interactive', 'raised'])()" />
</div> </div>
</Layout> </Layout>
@ -86,7 +86,7 @@ const props = defineProps<{
</script> </script>
<template> <template>
<div v-bind="color(props, ['primary', 'solid'])" /> <div v-bind="color(props, ['primary', 'solid'])()" />
</template> </template>
``` ```

View File

@ -1,16 +1,6 @@
<script setup> <script setup>
import { color, setColors } from "~/composables/colors.ts"
import { useRoute } from "vue-router"
import Button from "~/components/ui/Button.vue"
import Card from "~/components/ui/Card.vue" import Card from "~/components/ui/Card.vue"
import Link from "~/components/ui/Link.vue"
import Layout from "~/components/ui/Layout.vue" import Layout from "~/components/ui/Layout.vue"
import Alert from "~/components/ui/Alert.vue"
import Spacer from "~/components/ui/layout/Spacer.vue"
const route = useRoute();
const here = route.path
</script> </script>
# Using widths # Using widths