Skip to content

feat(List): new component #996

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions docs/components/content/examples/ListExample.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<template>
<UList>
<div v-for="(item, key) in items" :key="key" class="flex items-center gap-2">
<UIcon :name="item.icon" /> {{ item.label }}
</div>

<template #separator-after>
<UDivider class="my-2" />
</template>
</UList>
</template>

<script setup>
const items = [
{ label: 'Home', icon: 'i-heroicons-home' },
{ label: 'Profile', icon: 'i-heroicons-user-circle' },
{ label: 'Security', icon: 'i-heroicons-shield-check' },
{ label: 'Password Reset', icon: 'i-heroicons-key' }
]
</script>
20 changes: 20 additions & 0 deletions docs/components/content/examples/ListExampleGap.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<template>
<UList gap>
<div v-for="(item, key) in items" :key="key" class="rounded bg-gray-200 dark:bg-gray-800 px-2 flex items-center gap-2">
<UIcon :name="item.icon" /> {{ item.label }}
</div>

<template #separator-after>
<UDivider class="mt-4" />
</template>
</UList>
</template>

<script setup>
const items = [
{ label: 'Home', icon: 'i-heroicons-home' },
{ label: 'Users', icon: 'i-heroicons-users' },
{ label: 'Carts', icon: 'i-heroicons-shopping-bag' },
{ label: 'Shipments', icon: 'i-heroicons-truck' }
]
</script>
25 changes: 25 additions & 0 deletions docs/components/content/examples/ListExampleItemOrientation.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<template>
<UList item-orientation="horizontal">
<template #separator-before="{ index }">
<div
class="flex items-center text-right text-green-500 pr-1"
:style="`padding-left: ${Math.max(0, index - 1)}em`"
>
<UIcon name="i-heroicons-arrow-uturn-right" class="-scale-y-100" />
</div>
</template>

<div v-for="(item, key) in items" :key="key" class="flex items-center gap-2">
{{ item.label }}
</div>
</UList>
</template>

<script setup>
const items = [
{ label: 'Home' },
{ label: 'Profile' },
{ label: 'Security' },
{ label: 'Password Reset' }
]
</script>
22 changes: 22 additions & 0 deletions docs/components/content/examples/ListExampleOrientation.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<template>
<UList orientation="horizontal">
<div v-for="(item, key) in items" :key="key" class="flex items-center gap-2">
{{ item.label }}
</div>

<template #separator-after>
<div class="flex items-center px-1 text-gray-400 dark:text-gray-600">
<UIcon name="i-heroicons-chevron-right" />
</div>
</template>
</UList>
</template>

<script setup>
const items = [
{ label: 'Home' },
{ label: 'Profile' },
{ label: 'Security' },
{ label: 'Password Reset' }
]
</script>
20 changes: 20 additions & 0 deletions docs/components/content/examples/ListExamplePaddingClass.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<template>
<UList class="gap-y-1">
<div v-for="(item, key) in items" :key="key" class="rounded bg-gray-200 dark:bg-gray-800 px-2 flex items-center gap-2">
<UIcon :name="item.icon" /> {{ item.label }}
</div>

<template #separator-after>
<UDivider class="mt-1" />
</template>
</UList>
</template>

<script setup>
const items = [
{ label: 'Home', icon: 'i-heroicons-home' },
{ label: 'Users', icon: 'i-heroicons-users' },
{ label: 'Carts', icon: 'i-heroicons-shopping-bag' },
{ label: 'Shipments', icon: 'i-heroicons-truck' }
]
</script>
20 changes: 20 additions & 0 deletions docs/components/content/examples/ListExampleSlotSeparatorAfter.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<template>
<UList>
<div v-for="(item, key) in items" :key="key" class="flex items-center gap-2">
<UIcon :name="item.icon" /> {{ item.label }}
</div>

<template #separator-after>
<hr class="border-gray-200 dark:border-gray-800 my-2">
</template>
</UList>
</template>

<script setup>
const items = [
{ label: 'Home', icon: 'i-heroicons-home' },
{ label: 'Profile', icon: 'i-heroicons-user-circle' },
{ label: 'Security', icon: 'i-heroicons-shield-check' },
{ label: 'Password Reset', icon: 'i-heroicons-key' }
]
</script>
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<template>
<UList>
<template #separator-before>
<hr class="border-gray-200 dark:border-gray-800 my-2">
</template>

<div v-for="(item, key) in items" :key="key" class="flex items-center gap-2">
<UIcon :name="item.icon" /> {{ item.label }}
</div>
</UList>
</template>

<script setup>
const items = [
{ label: 'Home', icon: 'i-heroicons-home' },
{ label: 'Profile', icon: 'i-heroicons-user-circle' },
{ label: 'Security', icon: 'i-heroicons-shield-check' },
{ label: 'Password Reset', icon: 'i-heroicons-key' }
]
</script>
20 changes: 20 additions & 0 deletions docs/components/content/examples/ListExampleWrap.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<template>
<UList class="w-64" orientation="horizontal" wrap gap>
<div
v-for="(item, key) in items"
:key="key"
class="w-30 rounded bg-gray-200 dark:bg-gray-800 px-2 flex items-center gap-2"
>
<UIcon :name="item.icon" /> {{ item.label }}
</div>
</UList>
</template>

<script setup>
const items = [
{ label: 'Home', icon: 'i-heroicons-home' },
{ label: 'Users', icon: 'i-heroicons-users' },
{ label: 'Carts', icon: 'i-heroicons-shopping-bag' },
{ label: 'Shipments', icon: 'i-heroicons-truck' }
]
</script>
87 changes: 87 additions & 0 deletions docs/content/7.layout/5.list.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
---
description: Create horizontal o vertical lists with separators.
links:
- label: GitHub
icon: i-simple-icons-github
to: https://github.com/nuxt/ui/blob/dev/src/runtime/components/layout/List.vue
---

## Usage

Add items to the `UList` component, and a separator using the [`separator-before`](#separator-before) or [`separator-after`](#separator-after) slots.

List items can be from simple HTML elements, to a `v-for` loop of components.

:component-example{component="list-example"}

::callout{icon="i-heroicons-light-bulb"}
Lists are unordered by default, and are created using the `ul` tag. You can change the tag to `ol` for semantically ordered list by setting the `ordered` prop to `true`. This does not make any visual change.
::

### Orientation

By default, list items are stacked vertically. To stack the list items horizontally, set the `orientation` prop to `horizontal`.

:component-example{component="list-example-orientation"}

::callout{icon="i-heroicons-light-bulb"}
You can also change the default orientation for all list through the [global UI config](#/getting-started/theming#appconfigts).
::

### Item Orientation

Both content and separators have the same stacking orientation than the list. For example, if a list is stacked vertically, the container holding the content and the separators will also be stacked vertically.

The `itemOrientation` allows to change the stacking orientation of the item regardless of the list orientation.

:component-example{component="list-example-item-orientation"}

### Gap

To add a default space in between each list item, set the `gap` prop.

:component-example{component="list-example-gap"}

Alternatively, you may set a custom gap using the `class` attribute like any other HTML Element.

:component-example{component="list-example-padding-class"}

::callout{icon="i-heroicons-light-bulb"}
Items are listed using Tailwind CSS [`flex`](https://tailwindcss.com/docs/flex). You can change this through the [UI configuration](#config).
::

### Wrapping

When the items exceed the container height or width, these will not be wrapped into another line. To enable this behaviour, set the `wrap` prop.

:component-example{component="list-example-wrap"}

## Slots

### `separator-before`

Use this slot to set a separator **before** the item **contents**. It receives the current `index` of the item where is located, and both `isFirst` and `isLast` boolean if is the first or last item of the list, respectively.

:component-example{component="list-example-slot-separator-before"}

::callout{icon="i-heroicons-exclamation-triangle"}
Both `isFirst` and `isLast` booleans always returns `true` if there is only one item.
::

### `separator-after`

Use this slot to set a separator **after** the item **contents**. It receives the current `index` of the item where is located, and both `isFirst` and `isLast` boolean if is the first or last item of the list, respectively.

:component-example{component="list-example-slot-separator-after"}

::callout{icon="i-heroicons-exclamation-triangle"}
Both `isFirst` and `isLast` booleans always returns `true` if there is only one item.
::

## Props

:component-props

## Config

:component-preset
107 changes: 107 additions & 0 deletions src/runtime/components/layout/List.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { h, computed, toRef, defineComponent } from 'vue'
import type { PropType, SlotsType } from 'vue'
import type { RequireAtLeastOne } from 'type-fest'
import { twMerge, twJoin } from 'tailwind-merge'
import { useUI } from '../../composables/useUI'
import type { Strategy } from '../../types'
// @ts-expect-error
import appConfig from '#build/app.config'
import { mergeConfig, getSlotsChildren } from '#ui/utils'
import { list } from '#ui/ui.config'

const config = mergeConfig<typeof list>(appConfig.ui.strategy, appConfig.ui.list, list)

export default defineComponent({
inheritAttrs: false,
props: {
ordered: {
type: Boolean,
default: false
},
orientation: {
type: String as PropType<'horizontal' | 'vertical'>,
default: () => config.orientation,
validator (value: string) {
return ['horizontal', 'vertical'].includes(value)
}
},
gap: {
type: Boolean,
default: false
},
wrap: {
type: Boolean,
default: () => config.wrapItems
},
itemOrientation: {
type: String as PropType<'horizontal' | 'vertical'>,
default: undefined,
validator (value: string|undefined) {
return value === undefined ? true : ['horizontal', 'vertical'].includes(value)
}
},
class: {
type: [String, Object, Array] as PropType<any>,
default: undefined
},
ui: {
type: Object as PropType<Partial<typeof config & { strategy?: Strategy }>>,
default: undefined
}
},
slots: Object as SlotsType<
RequireAtLeastOne<{
default: undefined,
'separator-before'?: { index: number, isFirst: boolean, isLast: boolean },
'separator-after'?: { index: number, isFirst: boolean, isLast: boolean },
}, 'separator-before' | 'separator-after'>
>,
setup (props, { slots }) {
const { ui, attrs } = useUI('list', toRef(props, 'ui'), config)

const listClass = computed(() => {
return twMerge(twJoin(
ui.value[props.orientation].base,
props.wrap ? ui.value.wrap : ui.value.nowrap,
props.gap ? ui.value[props.orientation].gap : ''
), props.class)
})

const itemClass = computed(() => {
return twJoin(
ui.value[props.itemOrientation ?? props.orientation].base,
ui.value.nowrap
)
})

function addSeparator (array) {
return array.map((item, index) => {
const children = []

const isFirst = array.length === 1
|| ((slots['separator-before'] && index === 1) || (slots['separator-after'] && index === 0))

const isLast = array.length === 1
|| ((slots['separator-before'] && index === (array.length - 1)) || (slots['separator-after'] && index === (array.length - 2)))

if (slots['separator-before'] && (index > 0)) {
children.push(slots['separator-before']({ index, isFirst, isLast }))
}

children.push(item)

if (slots['separator-after'] && (index < (array.length - 1))) {
children.push(slots['separator-after']({ index, isFirst, isLast }))
}

return h('li', { class: itemClass.value }, children)
})
}

const children = computed(() => addSeparator(getSlotsChildren(slots)))

const orderedElement = computed(() => props.ordered ? 'ol' : 'ul')

return () => h(orderedElement.value, { ...attrs.value, class: listClass.value, ref: 'list' }, children.value)
}
})
15 changes: 15 additions & 0 deletions src/runtime/ui.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -996,6 +996,21 @@ export const divider = {
label: 'text-sm'
}

export const list = {
orientation: 'vertical',
vertical: {
base: 'flex flex-col',
gap: 'gap-4'
},
horizontal: {
base: 'flex flex-row',
gap: 'gap-4'
},
wrapItems: false,
wrap: 'flex-wrap',
nowrap: 'flex-nowrap'
}

// Navigation

export const verticalNavigation = {
Expand Down