feat(ui): add layout and spacing options

This commit is contained in:
upsiflu 2024-12-15 15:06:27 +01:00
parent 7f293d4143
commit 3865cbac93
9 changed files with 346 additions and 83 deletions

View File

@ -88,6 +88,7 @@ const isExternalLink = computed(() => {
&.is-category>.title { &.is-category>.title {
font-size: 1.75em; font-size: 1.75em;
padding-bottom: .25em;
} }
>.alert { >.alert {

View File

@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref } from 'vue'
const { icon, placeholder } = defineProps<{ icon?: string, placeholder:string }>() const { icon, placeholder } = defineProps<{ icon?: string, placeholder?:string }>()
const model = defineModel<string|number>() const model = defineModel<string|number>()
@ -33,5 +33,9 @@ const input = ref()
</template> </template>
<style lang="scss"> <style lang="scss">
@import './input.scss' @import './input.scss';
input[type=number]::-webkit-inner-spin-button {
opacity: 1;
}
</style> </style>

View File

@ -1,14 +1,15 @@
<script setup lang="ts"> <script setup lang="ts">
import { useCssModule } from 'vue' import { useCssModule } from 'vue'
const props = defineProps<{ [P in "stack" | "grid" | "flex"]?: true } & { columnWidth?: number }>() const props = defineProps<{ [P in "stack" | "grid" | "flex"]?: true } & { columnWidth?: number, noGap?:true }>()
const classes = useCssModule() const classes = useCssModule()
const columnWidth = props.columnWidth ?? 320 const columnWidth = props.columnWidth ?? 320
</script> </script>
<template> <template>
<div :class="[ <div :class="[
classes.gap, classes.layout,
noGap || classes.gap,
props.grid ? classes.grid props.grid ? classes.grid
: props.flex ? classes.flex : props.flex ? classes.flex
: classes.stack : classes.stack
@ -18,12 +19,22 @@ const columnWidth = props.columnWidth ?? 320
</template> </template>
<style module> <style module>
.layout{
transition:gap .15s;
/* Override --gap with your preferred value */ /* Override --gap with your preferred value */
.gap { &.gap {
gap: var(--gap, 32px); gap: var(--gap, 32px);
} }
&:not(.gap) {
.grid { gap: 0;
}
/* Growth */
&:has(:global(>.grow)){
>:not(:global(.grow)){
flex-grow:0;
}
}
&.grid {
display: grid; display: grid;
grid-template-columns: grid-template-columns:
repeat(auto-fit, minmax(calc(v-bind(columnWidth) * 1px), min-content)); repeat(auto-fit, minmax(calc(v-bind(columnWidth) * 1px), min-content));
@ -67,14 +78,17 @@ const columnWidth = props.columnWidth ?? 320
} }
} }
.stack { &.stack {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height:100%;
} }
.flex { &.flex {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
flex-wrap: wrap; flex-wrap: wrap;
} }
}
</style> </style>

View File

@ -2,9 +2,9 @@
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, useColor } from '~/composables/colors'; import { type ColorProps, useColor } from '~/composables/colors';
const { to, icon, color } = defineProps<RouterLinkProps & ColorProps & { const { to, icon, color, inline } = defineProps<RouterLinkProps & ColorProps & {
icon?: string; icon?: string;
variant?: 'solid' | 'outline' | 'ghost' inline?: true
}>() }>()
const colorClass = useColor(() => color) const colorClass = useColor(() => color)
@ -15,10 +15,10 @@ const isExternalLink = computed(() => {
</script> </script>
<template> <template>
<a v-if="isExternalLink" :class="[$style.external, colorClass, 'is-colored']" :href="to?.toString()" target="_blank"> <a v-if="isExternalLink" :class="[$style.external, colorClass, color && 'is-colored', inline && $style.inline]" :href="to?.toString()" target="_blank">
<slot /> <slot />
</a> </a>
<RouterLink v-if="to && !isExternalLink" :to="to" :class="[colorClass, 'is-colored']"> <RouterLink v-if="to && !isExternalLink" :to="to" :class="[colorClass, color && 'is-colored', inline && $style.inline]">
<i v-if="icon" :class="['bi', icon]" /> <i v-if="icon" :class="['bi', icon]" />
<slot /> <slot />
</RouterLink> </RouterLink>
@ -27,12 +27,12 @@ const isExternalLink = computed(() => {
<style module lang="scss"> <style module lang="scss">
.active { outline: 3px solid red; } .active { outline: 3px solid red; }
.external { outline: 3px dotted blue; } .external { outline: 3px dotted blue; }
.inline { display:inline-flex; }
a { a {
background-color: var(--fw-bg-color); background-color: var(--fw-bg-color);
color: var(--fw-text-color); color: var(--fw-text-color);
border: 1px solid var(--fw-bg-color); border: 1px solid var(--fw-bg-color);
position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
white-space: nowrap; white-space: nowrap;
@ -49,7 +49,7 @@ const isExternalLink = computed(() => {
margin: 0 0.5ch; margin: 0 0.5ch;
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:background-color .3s, border-color .2s;
i { i {
margin-right:1ch; margin-right:1ch;
@ -61,10 +61,5 @@ const isExternalLink = computed(() => {
background-color:transparent; background-color:transparent;
border-color:transparent; border-color:transparent;
} }
transition:background-color .3s, border-color .2s;
} }
</style> </style>

View File

@ -74,6 +74,7 @@
align-items: center; align-items: center;
font-size: 14px; font-size: 14px;
pointer-events: none; pointer-events: none;
writing-mode: horizontal-tb;
} }
.prefix { .prefix {

View File

@ -1,18 +1,54 @@
<script setup lang="ts"> <script setup lang="ts">
import { useCssModule } from 'vue' import { ref, watchEffect } from 'vue';
const classes = useCssModule() const { grow, shrink, title, size = 16 } = defineProps<{ grow?:true, shrink?:true, title?:string, size?:number }>()
const minSize = 32
const measure = ref()
watchEffect(() => { measure.value = {
size: `${Math.max(size, minSize)}px`,
margin: `${Math.min(size/2-minSize/2, 0)}px`
}
})
</script> </script>
<template> <template>
<div :class="classes.spacer">  <div :class="[$style.spacer, grow && 'grow', title && $style['has-title']]">
<slot />
</div> </div>
</template> </template>
<style module> <style module lang="scss">
.spacer { .spacer {
--size: var(--size, 32px); width: v-bind('measure.size');
width: var(--size); height: v-bind('measure.size');
height: var(--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> </style>

View File

@ -7,9 +7,7 @@ import Input from "~/components/ui/Input.vue"
Inputs are areas in which users can enter information. In Funkwhale, these mostly take the form of search fields. Inputs are areas in which users can enter information. In Funkwhale, these mostly take the form of search fields.
| Prop | Data type | Required? | Description | | Prop | Data type | Required? | Description |
| ---- | --------- | --------- | ----------- | | --------------- | --------- | --------- | --------------------------------------------------------------------------- |
|
| `placeholder` | String | No | The placeholder text that appears when the input is empty. | | `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. | | `icon` | String | No | The [Bootstrap icon](https://icons.getbootstrap.com/) to show on the input. |
| `v-model:value` | String | Yes | The text entered in the input. | | `v-model:value` | String | Yes | The text entered in the input. |

View File

@ -2,8 +2,14 @@
import Card from '~/components/ui/Card.vue' import Card from '~/components/ui/Card.vue'
import Alert from '~/components/ui/Alert.vue' import Alert from '~/components/ui/Alert.vue'
import Layout from '~/components/ui/Layout.vue' import Layout from '~/components/ui/Layout.vue'
import Spacer from '~/components/ui/layout/Spacer.vue'
import Tab from '~/components/ui/Tab.vue' import Tab from '~/components/ui/Tab.vue'
import Tabs from '~/components/ui/Tabs.vue' import Tabs from '~/components/ui/Tabs.vue'
import Toggle from '~/components/ui/Toggle.vue'
import { ref } from 'vue'
const isGrowing = ref(true)
const noGap = ref(true)
</script> </script>
# Layout # Layout
@ -145,3 +151,129 @@ Add space between vertically stacked items
</Tab> </Tab>
</Tabs> </Tabs>
## Common props
### `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" style="width:100px; min-width:100px" />
<Card title="B" />
<Card title="C" style="width:100px; min-width:100px" />
<Card title="D" />
</Layout>
</template>
```
<div class="preview">
<Toggle v-model="noGap" /> {{ noGap ? 'no-gap' : '-' }}
---
<Layout flex :no-gap="noGap || undefined">
<Card title="A" style="width:100px; min-width:100px" />
<Card title="B" />
<Card title="C" style="width:100px; min-width:100px" />
<Card title="D" />
</Layout>
</div>
</Layout>
### Add fixed or flexible Spacers
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" />
<div style="height:30em; /* Space to grow into */">
<Layout stack>
<Alert>A</Alert>
<Alert>B</Alert>
<Spacer :grow="isGrowing || undefined" />
<Alert>C (footer)</Alert>
</Layout>
</div>
</template>
```
<div class="preview">
<Toggle v-model="isGrowing" /> {{ isGrowing ? 'grow' : '-' }}
---
<div style="height:30em; background:rgba(255,250,20,.3)">
<Layout stack>
<Alert>A</Alert>
<Alert>B</Alert>
<Spacer :grow="isGrowing || undefined" />
<Alert>C (footer)</Alert>
</Layout>
</div>
</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`:
<Layout flex>
```vue
<script setup>
const isGrowing = ref(true);
</script>
<template>
<Toggle v-model="isGrowing" />
<div style="height:20em; /* Space to grow into */">
<Layout>
<Alert>A</Alert>
<Spacer :size="-32" :grow="isGrowing || undefined" />
<Alert>B1</Alert>
<Alert>B2</Alert>
<Spacer :size="-32" :grow="isGrowing || undefined" />
<Alert>C (footer)</Alert>
</Layout>
</div>
</template>
```
<div class="preview" style="width:0">
<Toggle v-model="isGrowing" />
---
<div style="height:20em;background:rgba(255,250,20,.3)">
<Layout>
<Alert>A</Alert>
<Spacer :size="-32" :grow="isGrowing || undefined" />
<Alert>B1</Alert>
<Alert>B2</Alert>
<Spacer :size="-32" :grow="isGrowing || undefined" />
<Alert>C (footer)</Alert>
</Layout>
</div>
</div>
</Layout>

View File

@ -1,7 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue';
import Alert from '~/components/ui/Alert.vue' import Alert from '~/components/ui/Alert.vue'
import Layout from '~/components/ui/Layout.vue' import Layout from '~/components/ui/Layout.vue'
import Spacer from '~/components/ui/layout/Spacer.vue' import Spacer from '~/components/ui/layout/Spacer.vue'
import Input from '~/components/ui/Input.vue'
import Card from '~/components/ui/Card.vue'
const size = ref(32)
</script> </script>
<style> <style>
@ -73,3 +78,80 @@ Add a 16px gap between adjacent items.
</Layout> </Layout>
</div> </div>
</Layout> </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 color="blue">A</Alert>
<Alert color="green">A</Alert>
<Spacer :size="size" />
<Alert color="red">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 color="blue">A</Alert>
<Spacer :size="size" />
<Alert color="red">B</Alert>
</div>
</Layout>
## Make the Spacer elastic
<Layout flex>
```vue-html
<Layout flex style="height:30em;">
<Input v-model="size"
type="range"
style="writing-mode: vertical-lr; height:100%"
/>
<Layout stack no-gap
:style="{ height: size + '%' }"
>
<Alert>A</Alert>
<Spacer grow title="grow" />
<Alert>B</Alert>
<Alert>C</Alert>
<Spacer shrink title="shrink" />
<Alert>D</Alert>
<Spacer grow shrink title="grow shrink" />
<Alert>E</Alert>
</Layout>
</Layout>
```
<div class="preview" style="flex-wrap:no-wrap">
<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 + '%'}">
<Alert>A</Alert>
<Spacer grow title="grow" />
<Alert>B</Alert>
<Alert>C</Alert>
<Spacer shrink title="shrink" />
<Alert>D</Alert>
<Spacer grow shrink title="grow shrink" />
<Alert>E</Alert>
</Layout>
</Layout>
</div>
</Layout>