Skip to content
Open
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
95 changes: 95 additions & 0 deletions datagouv-components/src/components/Form/Listbox.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
<template>
<Listbox
v-slot="{ activeOption }"
v-model="model"
>
<div class="relative">
<div
ref="floatingReference"
class="relative w-full cursor-default overflow-hidden bg-white text-left shadow-md focus:outline-none focus-visible:ring-2 focus-visible:ring-white/75 focus-visible:ring-offset-2 focus-visible:ring-offset-teal-300 sm:text-sm"
>
<ListboxButton class="input shadow-input text-sm flex items-center gap-2">
<slot name="button">
<div class="w-full flex items-center justify-between gap-2">
{{ model ? displayValue(model) : '' }}
<RiArrowDownSLine class="size-4 justify-self-end" />
</div>
</slot>
</ListboxButton>
</div>

<ListboxOptions
ref="popover"
:style="floatingStyles"
class="z-10 mt-1 absolute max-h-60 min-w-80 w-full overflow-auto rounded-md bg-white text-base shadow-lg ring-1 ring-black/5 focus:outline-none sm:text-sm pl-0"
>
<ListboxOption
v-for="option in options"
:key="getOptionId(toValue(option))"
v-slot="{ selected }"
as="template"
:value="option"
>
<li
class="relative cursor-default select-none py-2 pr-4 list-none flex items-center gap-2 text-gray-900"
:class="{
'bg-gray-lower': isActive(activeOption, option),
'pl-2': selected,
'pl-6': !selected,
}"
>
<div class="flex items-center justify-center aspect-square">
<RiCheckLine
v-if="selected"
class="size-4 text-new-primary"
/>
</div>
<slot
name="option"
v-bind="{ option, active: isActive(activeOption, option) }"
>
{{ displayValue(option) }}
</slot>
</li>
</ListboxOption>
</ListboxOptions>
</div>
</Listbox>
</template>

<script setup lang="ts" generic="T extends string | number | object">
import { Listbox, ListboxButton, ListboxOption, ListboxOptions } from '@headlessui/vue'
import { useFloating, autoUpdate, autoPlacement } from '@floating-ui/vue'
import { toValue, useTemplateRef } from 'vue'
import { RiArrowDownSLine, RiCheckLine } from '@remixicon/vue'

const props = withDefaults(defineProps<{
options?: Array<T>
getOptionId?: (option: T) => string | number
displayValue: (option: T) => string
}>(), {
getOptionId: (option: T): string | number => {
if (typeof option === 'string') return option
if (typeof option === 'number') return option
if (typeof option === 'object' && 'id' in option) return option.id as string

throw new Error('Please set getOptionId()')
},
})

const model = defineModel<T | null>()

const referenceRef = useTemplateRef('floatingReference')
const floatingRef = useTemplateRef<InstanceType<typeof ListboxOptions>>('popover')
const { floatingStyles } = useFloating(referenceRef, floatingRef, {
middleware: [autoPlacement({
allowedPlacements: ['bottom-start', 'bottom', 'bottom-end'],
crossAxis: true,
})],
whileElementsMounted: autoUpdate,
})

function isActive(activeOption: T, currentOption: T) {
return activeOption ? props.getOptionId(activeOption) === props.getOptionId(currentOption) : false
}
</script>
2 changes: 2 additions & 0 deletions datagouv-components/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ import GlobalSearch from './components/Search/GlobalSearch.vue'
import SearchInput from './components/Search/SearchInput.vue'
import SearchableSelect from './components/Form/SearchableSelect.vue'
import SelectGroup from './components/Form/SelectGroup.vue'
import Listbox from './components/Form/Listbox.vue'
import type { UseFetchFunction } from './functions/api.types'
import { configKey, useComponentsConfig, type PluginConfig } from './config.js'

Expand Down Expand Up @@ -318,4 +319,5 @@ export {
SearchInput,
SearchableSelect,
SelectGroup,
Listbox,
}
7 changes: 6 additions & 1 deletion pages/design.vue
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,11 @@
:label="$t('Traductions')"
to="/design/translations"
/>
<AdminSidebarLink
:icon="RiListCheck"
:label="$t('Listbox')"
to="/design/listbox"
/>
</ul>
</Sidemenu>
<div class="w-8/12 space-y-8 px-8 bg-gray-50 pb-32">
Expand All @@ -77,7 +82,7 @@
</template>

<script setup lang="ts">
import { RiEyeLine, RiExternalLinkLine, RiFileSearchLine, RiIdCardLine, RiListView, RiRadioButtonLine, RiSearch2Line, RiSearchEyeLine, RiTranslate, RiUserSearchLine } from '@remixicon/vue'
import { RiEyeLine, RiExternalLinkLine, RiFileSearchLine, RiIdCardLine, RiListView, RiRadioButtonLine, RiSearch2Line, RiSearchEyeLine, RiTranslate, RiUserSearchLine, RiListCheck } from '@remixicon/vue'
import AdminSidebarLink from '~/components/AdminSidebar/AdminSidebarLink/AdminSidebarLink.vue'
import LogoOnly from '~/components/LogoOnly.vue'
import Sidemenu from '~/components/Sidemenu/Sidemenu.global.vue'
Expand Down
176 changes: 176 additions & 0 deletions pages/design/listbox.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
<template>
<div>
<Breadcrumb>
<BreadcrumbItem to="/design">
{{ $t('Système de design') }}
</BreadcrumbItem>
<BreadcrumbItem to="/design/listbox">
{{ $t('Listbox') }}
</BreadcrumbItem>
</Breadcrumb>
<h1 class="mb-3">
Listbox
</h1>

<div class="space-y-8 py-8">
<section>
<h2 class="!mb-3">
Basic usage
</h2>
<p class="mb-4 text-gray-600">
Simple listbox with string options.
</p>
<div class="w-80">
<Listbox
v-model="selectedCondition"
:options="conditions"
:display-value="(option: string) => option"
/>
</div>
</section>

<section>
<h2 class="!mb-3">
With object options
</h2>
<p class="mb-4 text-gray-600">
Listbox with object options using custom display value.
</p>
<div class="w-80">
<Listbox
v-model="selectedFruit"
:options="fruits"
:display-value="(option: Fruit) => option.label"
/>
</div>
</section>

<section>
<h2 class="!mb-3">
With custom option slot
</h2>
<p class="mb-4 text-gray-600">
Listbox with custom option rendering using the #option slot.
</p>
<div class="w-80">
<Listbox
v-model="selectedStatus"
:options="statuses"
:display-value="(option: Status) => option.label"
>
<template #option="{ option, active }">
<span
class="size-2 rounded-full"
:class="{
'bg-gray-400': option.id === 'draft',
'bg-yellow-400': option.id === 'review',
'bg-green-500': option.id === 'published',
}"
/>
{{ option.label }}
<span
v-if="active"
class="text-xs text-gray-500"
>(active)</span>
</template>
</Listbox>
</div>
</section>

<section>
<h2 class="!mb-3">
With button and option slots
</h2>
<p class="mb-4 text-gray-600">
Listbox using both #button and #option slots for full customization.
</p>
<div class="w-80">
<Listbox
v-model="selectedPriority"
:options="priorities"
:display-value="(option: Priority) => option.label"
>
<template #button>
<span class="flex items-center gap-2">
<span
class="size-3 rounded-full"
:class="{
'bg-red-500': selectedPriority?.id === 'high',
'bg-yellow-500': selectedPriority?.id === 'medium',
'bg-green-500': selectedPriority?.id === 'low',
}"
/>
{{ selectedPriority?.label || 'Select priority' }}
</span>
</template>
<template #option="{ option, active }">
<span
class="size-3 rounded-full"
:class="{
'bg-red-500': option.id === 'high',
'bg-yellow-500': option.id === 'medium',
'bg-green-500': option.id === 'low',
}"
/>
{{ option.label }}
<span
v-if="active"
class="ml-auto text-xs text-gray-500"
>(active)</span>
</template>
</Listbox>
</div>
</section>
</div>
</div>
</template>

<script setup lang="ts">
import { Listbox } from '@datagouv/components-next'
import BreadcrumbItem from '~/components/Breadcrumbs/BreadcrumbItem.vue'

type Fruit = {
id: string
label: string
}

type Status = {
id: string
label: string
}

type Priority = {
id: string
label: string
}

const conditions = ['equal', 'greater', 'less', 'contains', 'starts_with', 'really long option should not push the content like the default select tag and should be truncated when selected']

const selectedCondition = ref<string | null>('equal')

const fruits: Fruit[] = [
{ id: 'apple', label: '🍎 Apple' },
{ id: 'banana', label: '🍌 Banana' },
{ id: 'orange', label: '🍊 Orange' },
{ id: 'grape', label: '🍇 Grape' },
{ id: 'strawberry', label: '🍓 Strawberry' },
]

const selectedFruit = ref<Fruit | null>(fruits[0])

const statuses: Status[] = [
{ id: 'draft', label: 'Draft' },
{ id: 'review', label: 'In Review' },
{ id: 'published', label: 'Published' },
]

const selectedStatus = ref<Status | null>(statuses[0])

const priorities: Priority[] = [
{ id: 'high', label: 'High' },
{ id: 'medium', label: 'Medium' },
{ id: 'low', label: 'Low' },
]

const selectedPriority = ref<Priority | null>(priorities[0])
</script>
Loading