2
0
mirror of https://github.com/tenrok/vue-select.git synced 2026-06-22 10:30:34 +03:00

Merge branch '@beta/listbox-poc' into @beta/dev

This commit is contained in:
Jeff Sagal
2024-03-22 20:56:45 -07:00
17 changed files with 451 additions and 137 deletions
+84 -28
View File
@@ -1,43 +1,99 @@
<template> <template>
<div id="app"> <div class="flex flex-col items-center space-y-10 justify-center pt-40">
<v-select v-model="selected" v-bind="config" /> <StyledComboBox
:label="({ label }) => label"
v-model="selected"
:options="config.options"
>
<ComboBoxOption
@click="selected = country"
v-for="country in config.options"
:key="country.id"
:class="['px-2 py-1']"
:value="country"
#default="{ isSelected }"
>
<span :class="{ 'text-indigo-600': isSelected }">
{{ country.label }}
</span>
</ComboBoxOption>
</StyledComboBox>
<ComboBox v-model="selected" class="text-left w-64 relative">
<ComboBoxButton
as="div"
class="px-2 border flex rounded items-center justify-center h-12 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
>
<div class="flex-1">
{{ selected?.label }}
</div>
<div>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-6 h-6"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M8.25 15L12 18.75 15.75 15m-7.5-6L12 5.25 15.75 9"
/>
</svg>
</div>
</ComboBoxButton>
<ComboBoxMenu
class="absolute mt-1 left-0 w-full h-64 overflow-y-scroll space-y-1 border rounded"
>
<ComboBoxOption
@click="selected = country"
v-for="country in config.options"
as="button"
:key="country.id"
:class="['px-2 py-1 flex text-left hover:bg-gray-100 w-full']"
:value="country"
#default="{ isSelected }"
>
<span :class="{ 'text-indigo-600': isSelected }">
{{ country.label }}
</span>
</ComboBoxOption>
</ComboBoxMenu>
</ComboBox>
</div> </div>
</template> </template>
<script> <script>
import vSelect from '@/components/Select.vue'
import countries from '../docs/.vuepress/data/countryCodes.js' import countries from '../docs/.vuepress/data/countryCodes.js'
import StyledComboBox from '@/components/ComboBox/StyledComboBox.vue'
import ComboBoxOption from '@/components/ComboBox/ComboBoxOption.vue'
import ComboBox from '@/components/ComboBox/ComboBox.vue'
import ComboBoxMenu from '@/components/ComboBox/ComboBoxMenu.vue'
import ComboBoxButton from '@/components/ComboBox/ComboBoxButton.vue'
import ComboBoxInput from '@/components/ComboBox/ComboBoxInput.vue'
export default { export default {
components: { vSelect }, components: {
ComboBoxInput,
ComboBoxButton,
ComboBoxMenu,
ComboBox,
ComboBoxOption,
StyledComboBox,
},
data: () => ({ data: () => ({
selected: null, selected: {
styled: null,
composed: null,
},
open: false,
config: { config: {
options: countries, options: countries,
}, },
}), }),
} }
</script> </script>
<style>
html,
body {
margin: 0;
height: 100%;
font-family: -apple-system, sans-serif;
}
#app {
height: 100%;
max-width: 20rem;
margin: 10rem auto 0;
}
hr {
border: none;
border-bottom: 1px solid #cacaca;
margin-bottom: 1em;
padding-top: 1em;
width: 90%;
}
</style>
View File
+2 -5
View File
@@ -3,12 +3,9 @@
By default, the dropdown will open anytime the underlying search input has focus. The dropdown will By default, the dropdown will open anytime the underlying search input has focus. The dropdown will
open when clicked, or when it has received focus when tabbing through inputs. open when clicked, or when it has received focus when tabbing through inputs.
## Customizing Dropdown Behaviour <Badge text="v3.12.0+" /> ## Customizing Dropdown Behaviour <Badge text="v4+" />
The `dropdownShouldOpen` prop allows for full customization of the open/close behaviour. The prop The `open` prop can control showing and hiding the dropdown menu from a parent component. If this prop is set, the component will always use the value of `props.open` to handle showing and hiding the dropdown. Vue Select will emit the `update:open` when the prop value should change, so you can use `<VueSelect v-model:open="open" />` to have control over the `open` state while preserving default behaviour.
accepts a `function` that should return a `boolean` value. The returned boolean value will be used
to determine if the dropdown should be `open`/`true` or `false`/`closed`. The function receives the
instance of the component as the only argument.
#### Example: Open the dropdown when search text is present #### Example: Open the dropdown when search text is present
--- ---
+4 -3
View File
@@ -1,9 +1,10 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vue Select Dev</title> <title>Vue Select Dev</title>
<script src="https://cdn.tailwindcss.com"></script>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
+100 -100
View File
@@ -1,104 +1,104 @@
{ {
"name": "vue-select", "name": "vue-select",
"version": "4.0.0-beta.6", "version": "4.0.0-beta.6",
"description": "Everything you wish the HTML <select> element could do, wrapped up into a lightweight, extensible Vue component.", "description": "Everything you wish the HTML <select> element could do, wrapped up into a lightweight, extensible Vue component.",
"author": "Jeff Sagal <sagalbot@gmail.com>", "author": "Jeff Sagal <sagalbot@gmail.com>",
"homepage": "https://vue-select.org", "homepage": "https://vue-select.org",
"directories": { "directories": {
"doc": "docs", "doc": "docs",
"test": "tests" "test": "tests"
},
"files": [
"dist"
],
"main": "./dist/vue-select.umd.js",
"module": "./dist/vue-select.es.js",
"exports": {
".": {
"import": "./dist/vue-select.es.js",
"require": "./dist/vue-select.umd.js"
}, },
"files": [ "./dist/vue-select.css": {
"dist" "import": "./dist/vue-select.css",
], "require": "./dist/vue-select.css",
"main": "./dist/vue-select.umd.js", "style": "./dist/vue-select.css"
"module": "./dist/vue-select.es.js",
"exports": {
".": {
"import": "./dist/vue-select.es.js",
"require": "./dist/vue-select.umd.js"
},
"./dist/vue-select.css": {
"import": "./dist/vue-select.css",
"require": "./dist/vue-select.css",
"style": "./dist/vue-select.css"
}
},
"private": false,
"license": "MIT",
"prepare": "npm run build",
"scripts": {
"dev:docs": "cd docs && yarn dev",
"build:docs": "cd docs && yarn generate",
"semantic-release": "semantic-release",
"commit": "git-cz",
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview --port 5050",
"test": "vitest --environment jsdom",
"coverage": "vitest --run --coverage --environment jsdom --silent",
"typecheck": "vue-tsc --noEmit -p tsconfig.vitest.json --composite false",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore"
},
"repository": {
"type": "git",
"url": "https://github.com/sagalbot/vue-select.git"
},
"peerDependencies": {
"vue": "3.x"
},
"devDependencies": {
"@rushstack/eslint-patch": "^1.8.0",
"@semantic-release/git": "^10.0.1",
"@semantic-release/github": "^10.0.2",
"@types/jsdom": "^21.1.6",
"@types/node": "^20.11.30",
"@vitejs/plugin-vue": "^5.0.4",
"@vitest/coverage-c8": "^0.33.0",
"@vue/eslint-config-prettier": "^9.0.0",
"@vue/eslint-config-typescript": "^13.0.0",
"@vue/test-utils": "^2.4.5",
"@vue/tsconfig": "^0.5.1",
"autoprefixer": "^10.4.19",
"bundlewatch": "^0.3.3",
"commitizen": "^4.3.0",
"coveralls": "^3.1.1",
"cross-env": "^7.0.3",
"cz-conventional-changelog": "3.3.0",
"eslint": "^8.57.0",
"eslint-plugin-vue": "^9.23.0",
"jsdom": "^24.0.0",
"postcss-nested": "^6.0.1",
"prettier": "^3.2.5",
"semantic-release": "^23.0.5",
"typescript": "^5.4.3",
"vite": "^5.2.2",
"vitest": "^1.4.0",
"vue": "^3.4.21",
"vue-tsc": "^2.0.7"
},
"config": {
"commitizen": {
"path": "./node_modules/cz-conventional-changelog"
}
},
"bundlewatch": {
"files": [
{
"path": "./dist/vue-select.es.js",
"compression": "gzip",
"maxSize": "8 KB"
},
{
"path": "./dist/vue-select.umd.js",
"compression": "gzip",
"maxSize": "7 KB"
},
{
"path": "./dist/vue-select.css",
"compression": "gzip",
"maxSize": "2 KB"
}
]
} }
},
"private": false,
"license": "MIT",
"prepare": "npm run build",
"scripts": {
"dev:docs": "cd docs && yarn dev",
"build:docs": "cd docs && yarn generate",
"semantic-release": "semantic-release",
"commit": "git-cz",
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview --port 5050",
"test": "vitest --environment jsdom",
"coverage": "vitest --run --coverage --environment jsdom --silent",
"typecheck": "vue-tsc --noEmit -p tsconfig.vitest.json --composite false",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore"
},
"repository": {
"type": "git",
"url": "https://github.com/sagalbot/vue-select.git"
},
"peerDependencies": {
"vue": "3.x"
},
"devDependencies": {
"@rushstack/eslint-patch": "^1.8.0",
"@semantic-release/git": "^10.0.1",
"@semantic-release/github": "^10.0.2",
"@types/jsdom": "^21.1.6",
"@types/node": "^20.11.30",
"@vitejs/plugin-vue": "^5.0.4",
"@vitest/coverage-v8": "^1.4.0",
"@vue/eslint-config-prettier": "^9.0.0",
"@vue/eslint-config-typescript": "^13.0.0",
"@vue/test-utils": "^2.4.5",
"@vue/tsconfig": "^0.5.1",
"autoprefixer": "^10.4.19",
"bundlewatch": "^0.3.3",
"commitizen": "^4.3.0",
"coveralls": "^3.1.1",
"cross-env": "^7.0.3",
"cz-conventional-changelog": "3.3.0",
"eslint": "^8.57.0",
"eslint-plugin-vue": "^9.23.0",
"jsdom": "^24.0.0",
"postcss-nested": "^6.0.1",
"prettier": "^3.2.5",
"semantic-release": "^23.0.5",
"typescript": "^5.4.3",
"vite": "^5.2.3",
"vitest": "^1.4.0",
"vue": "^3.4.21",
"vue-tsc": "^2.0.7"
},
"config": {
"commitizen": {
"path": "./node_modules/cz-conventional-changelog"
}
},
"bundlewatch": {
"files": [
{
"path": "./dist/vue-select.es.js",
"compression": "gzip",
"maxSize": "8 KB"
},
{
"path": "./dist/vue-select.umd.js",
"compression": "gzip",
"maxSize": "7 KB"
},
{
"path": "./dist/vue-select.css",
"compression": "gzip",
"maxSize": "2 KB"
}
]
}
} }
+72
View File
@@ -0,0 +1,72 @@
<script setup lang="ts">
import { ListBoxKey } from '@/keys'
import type { ComputedRef } from 'vue'
import {
provide,
computed,
reactive,
watch,
onMounted,
ref,
onUnmounted,
} from 'vue'
import { useClickAway } from '@/hooks/useClickAway'
import type {
InjectedListBoxProps,
ListBoxProps,
ResolvedListBoxProps,
VueSelectValue,
} from '@/types'
const emit = defineEmits(['update:modelValue', 'update:open', 'open', 'close'])
const props = withDefaults(defineProps<ListBoxProps>(), {
open: undefined,
})
const el = ref<HTMLElement>()
const state = reactive<{
open: boolean
}>({
open: props.open === undefined ? false : props.open,
})
watch(
() => state.open,
(open) => emit('update:open', open),
)
const inputText = ref('')
const isOpen = computed<boolean>(() => {
if (props.open !== undefined) {
return props.open
}
return state.open
})
const { addClickAwayListener, removeClickAwayListener } = useClickAway(
() => (state.open = false),
)
onMounted(() => addClickAwayListener(el.value))
onUnmounted(() => removeClickAwayListener(el.value))
provide<InjectedListBoxProps>(
ListBoxKey,
computed<ResolvedListBoxProps>(() => ({
open: isOpen.value,
modelValue: props.modelValue,
inputText: inputText.value,
toggleOpen: () => (state.open = !state.open),
setModelValue: (modelValue) => emit('update:modelValue', modelValue),
setInputText: (value: string) => (inputText.value = value),
})),
)
</script>
<template>
<div tabindex="0" role="combobox" ref="el">
<slot></slot>
</div>
</template>
@@ -0,0 +1,28 @@
<script setup lang="ts">
import { inject } from 'vue'
import { ListBoxKey } from '@/keys'
withDefaults(
defineProps<{
as?: string
}>(),
{
as: 'button',
},
)
const listBoxProps = inject(ListBoxKey)
</script>
<template>
<Component
:is="as"
tabindex="0"
type="button"
aria-haspopup="true"
:aria-expanded="listBoxProps.open"
@click="listBoxProps.toggleOpen"
>
<slot></slot>
</Component>
</template>
+13
View File
@@ -0,0 +1,13 @@
<script setup lang="ts">
import { inject } from 'vue'
import { ListBoxKey } from '@/keys'
const listBoxProps = inject(ListBoxKey)
</script>
<template>
<input
type="search"
@input="({ target }) => listBoxProps?.setInputText(target.value)"
/>
</template>
+21
View File
@@ -0,0 +1,21 @@
<script setup lang="ts">
import { defineProps, inject } from 'vue'
import { ListBoxKey } from '@/keys'
withDefaults(
defineProps<{
as?: string
}>(),
{
as: 'div',
},
)
const listBoxProps = inject(ListBoxKey)
</script>
<template>
<Component :is="as" v-show="listBoxProps.open">
<slot></slot>
</Component>
</template>
@@ -0,0 +1,26 @@
<script setup lang="ts">
import { computed, defineProps, inject } from 'vue'
import { ListBoxKey } from '@/keys'
const props = withDefaults(
defineProps<{
as?: string
value: unknown
}>(),
{
as: 'div',
},
)
const listBoxProps = inject(ListBoxKey)
const isSelected = computed(() => {
return listBoxProps?.value.modelValue === props.value
})
</script>
<template>
<Component :is="as" @click="() => listBoxProps.setModelValue(value)">
<slot v-bind="{ isSelected }"></slot>
</Component>
</template>
@@ -0,0 +1,50 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import ComboBox from '@/components/ComboBox/ComboBox.vue'
import ComboBoxInput from '@/components/ComboBox/ComboBoxInput.vue'
import ComboBoxMenu from '@/components/ComboBox/ComboBoxMenu.vue'
import ComboBoxOption from '@/components/ComboBox/ComboBoxOption.vue'
import ComboBoxButton from '@/components/ComboBox/ComboBoxButton.vue'
import type { VueSelectOption } from '@/types'
const props = defineProps<{
modelValue: unknown
label: (value: VueSelectOption) => string
}>()
const emits = defineEmits(['update:modelValue'])
const value = ref(props.modelValue)
watch(value, (newValue) => emits('update:modelValue', newValue))
</script>
<template>
<ComboBox v-model="value" class="w-64 relative">
<div class="flex border border-gray-500 rounded">
<ComboBoxInput v-bind="{ label }" class="pl-2 py-1 border-none rounded" />
<ComboBoxButton as="button">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-6 h-6"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M8.25 15L12 18.75 15.75 15m-7.5-6L12 5.25 15.75 9"
/>
</svg>
</ComboBoxButton>
</div>
<ComboBoxMenu
class="absolute z-50 bg-white inset-0 top-12 w-full h-64 overflow-y-scroll space-y-1 border rounded"
>
<slot></slot>
</ComboBoxMenu>
</ComboBox>
</template>
+21
View File
@@ -0,0 +1,21 @@
export function useClickAway(callback: () => void): {
addClickAwayListener: (el: HTMLElement | undefined) => void
removeClickAwayListener: (el: HTMLElement | undefined) => void
} {
function clickedOutside(el: HTMLElement | undefined, event: MouseEvent) {
if (el && !(el == event.target || el?.contains(event.target))) {
callback()
}
}
return {
addClickAwayListener: (el) =>
document.addEventListener('click', (event: MouseEvent) =>
clickedOutside(el, event)
),
removeClickAwayListener: (el) =>
document.removeEventListener('click', (event: MouseEvent) =>
clickedOutside(el, event)
),
}
}
View File
+5
View File
@@ -0,0 +1,5 @@
export function useModelValue<TypeProps>(
props: Readonly<TypeProps>,
key: string,
emit: (name: string, ...args: any[]) => void
) {}
+8
View File
@@ -0,0 +1,8 @@
import type { InjectionKey } from 'vue'
import type { ResolvedListBoxProps } from '@/types'
export const ListBoxKey: InjectionKey<ResolvedListBoxProps> = Symbol(
'ListBoxInjectionKey',
)
export const ListBoxOptionInjectionKey = Symbol() as InjectionKey<string>
+16
View File
@@ -0,0 +1,16 @@
import { ComputedRef, PropType } from 'vue'
export type VueSelectValue = PropType<unknown>
export type VueSelectOption = PropType<unknown>
export interface ListBoxProps {
modelValue: VueSelectValue
open?: boolean | undefined
}
export interface ResolvedListBoxProps extends Omit<ListBoxProps, 'open'> {
open: boolean
inputText: string
toggleOpen: () => boolean
setInputText: (text: string) => void
setModelValue: (value: unknown) => void
}
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"extends": "@vue/tsconfig/tsconfig.node.json", "extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["vite.config.*"], "include": ["vite.config.*"],
"compilerOptions": { "compilerOptions": {
"composite": true, "composite": true,