An open API service indexing awesome lists of open source software.

https://github.com/alexstep/custom-select

Select web-component, like native
https://github.com/alexstep/custom-select

custom-select select-component select-filter select-multiple vanillajs web-component

Last synced: 2 days ago
JSON representation

Select web-component, like native

Awesome Lists containing this project

README

          

# Pure Custom Select

[![npm](https://img.shields.io/npm/v/pure-custom-select.svg)](https://www.npmjs.com/package/pure-custom-select)
[![MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)

A `` replacement as a native web component. No dependencies, no build step, no framework.

- Native form participation via `ElementInternals` — works inside ``, FormData, validation.
- Goes native on mobile: real `` or a bottom sheet. Desktop gets a custom popup.
- Keyboard: arrows, Home/End, typeahead, Enter/Esc. ARIA combobox/listbox roles.
- Single & multiple selection, option groups, built-in filter, async search.
- Themeable with plain CSS variables. Optional Shadow DOM isolation.

[Live demo](https://alexstep.github.io/custom-select/demo.html) · ~10 KB initial gzipped (entry JS + CSS, see [Size](#size)).

## Install

```bash
npm install pure-custom-select
```

```js
import 'pure-custom-select' // registers
```

Registration is idempotent. Importing the package more than once (HMR, micro-frontends, several copies of the package) will not throw `NotSupportedError`; the duplicate `define` is skipped. To register under a different tag name, call the exported helper:

```js
import { defineCustomSelect } from 'pure-custom-select'
defineCustomSelect('my-select') // returns true if it registered, false if already defined
```

Or without a bundler (pre-built `dist/`, all chunk files must be reachable):

```html

import CustomSelect from 'https://unpkg.com/pure-custom-select@1/dist/custom-select.min.js'
customElements.define('custom-select', CustomSelect)

```

If you import the class yourself, register it manually (guard against a double `define`):

```js
import CustomSelect from 'pure-custom-select/custom-select.js'
if (!customElements.get('custom-select')) {
customElements.define('custom-select', CustomSelect)
}
```

Styles are imported by `index.js`. When loading files directly, include them yourself:

```html

```

**Load CSS before JS** when using `` or a CDN bundle. [`base.css`](styles/base.css) hides light-DOM `<option>` / `<optgroup>` and reserves trigger height until the element upgrades (`:not(:defined)`) and until the first render finishes (`:not([data-cs-ready])`) — this prevents a flash of expanded options and layout shift on slow connections. With a bundler, `import 'pure-custom-select'` pulls CSS in with the JS entry; for zero flash on first paint, add `<link rel="stylesheet" href="…/custom-select.min.css">` in `<head>` ahead of the module script.

## Usage

```html
<custom-select name="fruit" placeholder="Pick a fruit…">
<option value="apple">Apple</option>
<option value="banana" selected>Banana</option>
<option value="cherry">Cherry</option>
</custom-select>
```

Multiple + filter + groups:

```html
<custom-select name="city" multiple searchable placeholder="Cities…">
<optgroup label="Europe">
<option value="lon">London</option>
<option value="par">Paris</option>
</optgroup>
<optgroup label="Asia">
<option value="tok">Tokyo</option>
</optgroup>
</custom-select>
```

Read the value (string, or array when `multiple`):

```js
const el = document.querySelector('custom-select[name=fruit]')
el.addEventListener('change', e => console.log(e.detail.value))
```

Set options imperatively instead of `<option>`:

```js
el.items = [
{ value: 'a', label: 'Alpha' },
{ value: 'b', label: 'Beta', disabled: true },
]
```

Async search has two modes.

Local (default) — `onsearch` returns the subset of the **already-declared** options that match. Matching is done by `value`, so options the server does not know about stay hidden:

```js
el.searchable = true
el.onsearch = async (query, { signal }) => {
const res = await fetch(`/api/search?q=${query}`, { signal })
return res.json() // [{ value }] referencing existing <option>s
}
```

Remote (`search-mode="remote"`) — `onsearch` returns the full list to display. Items that were never present as `<option>` children are rendered, and when one is picked it is registered on the component (label shown, value submitted with the form):

```html
<custom-select searchable search-mode="remote" name="city"></custom-select>
```

```js
el.onsearch = async (query, { signal }) => {
const res = await fetch(`/api/cities?q=${query}`, { signal })
return res.json() // [{ value, label, disabled? }] — may include brand-new options
}
```

## Frameworks (React, Angular, Vue)

The component is a standard custom element. Import once so it registers, then use the tag in templates. **Set `value` and complex props via the DOM property**, not attributes (especially for `multiple` and arrays).

```js
import 'pure-custom-select'
```

### React

```jsx
import { useEffect, useRef } from 'react'
import 'pure-custom-select'

export function FruitSelect({ value, onChange }) {
const ref = useRef(null)

useEffect(() => {
const el = ref.current
if (!el) return
el.value = value
const onSelectChange = e => onChange(e.detail.value)
el.addEventListener('change', onSelectChange)
return () => el.removeEventListener('change', onSelectChange)
}, [value, onChange])

return (
<custom-select ref={ref} name="fruit" placeholder="Pick a fruit…">
<option value="apple">Apple</option>
<option value="banana">Banana</option>
</custom-select>
)
}
```

For `multiple`, pass an array to `el.value`. With SSR, render the tag on the client only or after `customElements.whenDefined('custom-select')`.

### Angular

```typescript
import { Component, CUSTOM_ELEMENTS_SCHEMA, ElementRef, ViewChild, effect, input, output } from '@angular/core'
import 'pure-custom-select'

@Component({
selector: 'app-fruit-select',
schemas: [CUSTOM_ELEMENTS_SCHEMA],
template: `
<custom-select #sel name="fruit" placeholder="Pick a fruit…">
<option value="apple">Apple</option>
<option value="banana">Banana</option>
</custom-select>
`,
})
export class FruitSelectComponent {
value = input<string>('')
valueChange = output<string>()

@ViewChild('sel', { static: true }) sel!: ElementRef<HTMLElement & { value: string }>

constructor() {
effect(() => {
this.sel.nativeElement.value = this.value()
})
}

ngAfterViewInit() {
this.sel.nativeElement.addEventListener('change', (e: Event) => {
const detail = (e as CustomEvent<{ value: string }>).detail
this.valueChange.emit(detail.value)
})
}
}
```

Add `CUSTOM_ELEMENTS_SCHEMA` to the component (or module) that uses `<custom-select>`.

### Vue 3

```vue
<script setup>
import { ref, watch, onMounted, onBeforeUnmount } from 'vue'
import 'pure-custom-select'

const props = defineProps({ modelValue: { type: String, default: '' } })
const emit = defineEmits(['update:modelValue'])

const el = ref(null)

function syncToElement() {
if (el.value) el.value.value = props.modelValue
}

function onChange(e) {
emit('update:modelValue', e.detail.value)
}

onMounted(() => {
syncToElement()
el.value?.addEventListener('change', onChange)
})

onBeforeUnmount(() => el.value?.removeEventListener('change', onChange))

watch(() => props.modelValue, syncToElement)


Apple
Banana

```

`vite.config` does not need special handling if you import `pure-custom-select` (CSS is a side effect). For `multiple`, bind `modelValue` as an array and assign `el.value.value = [...]`.

## API

### Attributes / properties

| Name | Type | Default | Notes |
| --- | --- | --- | --- |
| `name` | string | `''` | Form field name. |
| `value` | string \| string[] | `''` | Array when `multiple`. |
| `multiple` | boolean | `false` | Multi-select. |
| `placeholder` | string | `''` | Shown when nothing is selected. |
| `theme` | `light` \| `dark` \| `auto` | `auto` | `auto` follows `prefers-color-scheme`. |
| `mobileview` | `native` \| `native-multiple` \| `sheet` \| `desktop` | `native` | Mobile rendering mode. |
| `searchable` | boolean | `false` | Adds a filter input to the popup. |
| `search-placeholder` | string | `''` | Filter input placeholder. |
| `search-mode` | `local` \| `remote` | `local` | `remote` renders `onsearch` results wholesale (see [Async search](#frameworks-react-angular-vue)). |
| `disabled` | boolean | `false` | Blocks interaction. |
| `noscroll` | boolean | `false` | Disables popup internal scroll. |
| `shadow-dom` | boolean | `false` | Render inside a Shadow DOM. |
| `no-sheet-history` | boolean | `false` | Disables the mobile sheet's `history.pushState` back-button integration. |

Property-only:

| Property | Type | Notes |
| --- | --- | --- |
| `items` | `{value, label, disabled?, labelText?}[]` | Set options from JS. |
| `onsearch` | `(query, {signal}) => Promise` | Async search callback. |
| `searchMode` | `'local' \| 'remote'` | Mirrors the `search-mode` attribute. |
| `noSheetHistory` | boolean | Mirrors the `no-sheet-history` attribute. |
| `themes` | string[] | Available themes (read-only). |
| `mobileviews` | string[] | Available mobile modes (read-only). |
| `searchModes` | string[] | Available search modes (read-only). |

### Events

| Event | `detail` | When |
| --- | --- | --- |
| `change` | `{ value }` | Value changes. |
| `popup-open` | `{}` | Desktop popup opens. |
| `popup-close` | `{}` | Desktop popup closes (cancelable). |
| `filter-change` | `{ query, results }` | Filter input changes. |

## Mobile bottom sheet

On mobile, opening the sheet pushes a history entry (`#custom-select-open`) so the hardware back button closes it instead of navigating away. Inside a hash router, a Telegram Mini App, or a deep-link/analytics setup this hash change is an unexpected side effect. Disable it with `no-sheet-history`:

```html

```

## Theming

Override CSS variables on the element (or any ancestor). Defaults fall back to Telegram Mini App theme variables, then to sensible values.

```css
custom-select {
--cs-bg: #fff; /* trigger / popup background */
--cs-text: #111; /* text color */
--cs-border: #e2e8f0; /* border color */
--cs-border-radius: 10px; /* corner radius */
--cs-min-width: 240px; /* min trigger width */
--cs-accent-bg: #2563eb; /* focused/selected option background */
--cs-accent-text: #fff; /* focused/selected option text */
--cs-muted-text: #94a3b8; /* placeholder / hints */
--cs-animation-duration: .25s;
}
```

The desktop popup merges with the trigger into a single block and opens up or down depending on available space. The toggle arrow rotates on open and both the arrow and options have a press (scale) feedback. All transitions respect `prefers-reduced-motion`.

## Browser support

Latest Chrome, Firefox, Safari, Edge. Requires Custom Elements v1, `ElementInternals`, and ``.

## Size

`npm run build` emits **code-split** chunks (`--splitting`): the browser loads `custom-select.min.js` first; `desktop-popup`, `mobile-sheet`, and `filter-module` chunks load on first open. CSS is `base` + `mobile` + `themes` combined.

| What loads | JS (min) | JS (gzip) |
|------------|----------|-----------|
| **Initial** (entry only) | 24.65 KB | 7.69 KB |
| + desktop popup (first open) | +4.93 KB | +2.06 KB |
| + filter (`searchable`, first open) | +2.41 KB | +1.22 KB |
| + mobile sheet (first open) | +5.55 KB | +2.19 KB |
| **All JS chunks** | 38.46 KB | 13.90 KB |
| **CSS** (`custom-select.min.css`) | 14.10 KB | 3.20 KB |

**Typical first paint:** entry JS + CSS = **10.89 KB gzip**.

Pre-built `dist/`: ship **all** `*.min.js` files together (chunk hashes are stable per build). Register from the entry bundle:

```js
import CustomSelect from './dist/custom-select.min.js'
import './dist/custom-select.min.css'
customElements.define('custom-select', CustomSelect)
```

Source `import 'pure-custom-select'` keeps the same lazy `import()` boundaries; your bundler splits chunks automatically.

## TypeScript

Types ship in `types/` (`types/index.d.ts`, `types/custom-select.d.ts`).

```ts
import 'pure-custom-select'
import type { CustomSelectItem, CustomSelectChangeEventDetail } from 'pure-custom-select'

const el = document.querySelector('custom-select')!
el.addEventListener('change', (e: CustomEvent) => {
console.log(e.detail.value)
})
```

Hand-written declarations are the source of truth. Optional: `npm run types:emit-from-js` runs `tsc` on `custom-select.js` (noisy output in `types-gen/`, for diff only). `npm run types:check` validates `.d.ts`.

## Development

```bash
npm run types:check
npm run build # minify + gzip + brotli → dist/
```

Source layout: `core/` (state, parsing, form), `ui/` (desktop popup, mobile sheet), `keyboard/`, `filter/`, `utils/`, `styles/`.

## License

MIT