Add Transfer component (dual listbox)

Two lists with buttons between them to move rows from available to selected and back, with search, select all, reordering and a hidden select multiple that keeps a form in sync. Raises the bundlewatch limit for minified JS to 44 kB.
This commit is contained in:
Bartek committed 2026-10-08 18:24:27 +02:00
1 parent c2a7cbf026
commit efd46f1c79
13 files changed
+1332 -2

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
---
'@tabler/core': minor
'@tabler/preview': minor
'@tabler/docs': minor
---
Added the `Transfer` component (dual listbox), two lists with buttons to move rows between them, search, select all, reordering and a hidden `<select multiple>` that keeps a form in sync.
+630
View File
@@ -0,0 +1,630 @@
/**
* --------------------------------------------------------------------------
* Tabler transfer.ts
* Licensed under MIT (https://github.com/tabler/tabler/blob/dev/LICENSE)
* --------------------------------------------------------------------------
*/
import BaseComponent from './bootstrap/base-component'
import EventHandler from './bootstrap/dom/event-handler'
import SelectorEngine from './bootstrap/dom/selector-engine'
import { initAll } from './bootstrap/util/component-functions'
import type { ElementSelector } from './bootstrap/types'
type TransferSide = 'source' | 'target'
type TransferAction = 'add' | 'remove' | 'add-all' | 'remove-all' | 'up' | 'down'
type TransferDirection = 'add' | 'remove' | 'reorder'
type ComponentConfig = {
/** text of the counter in a panel header; `{checked}` and `{total}` are replaced */
counterTemplate: string
/** `name` of the `<select multiple>` the plugin renders when the markup has none; `null` renders nothing */
name: string | null
}
type ComponentConfigInput = Partial<ComponentConfig> & Record<string, unknown>
/**
* Constants
*/
const NAME = 'transfer'
const DATA_KEY = `bs.${NAME}`
const EVENT_KEY = `.${DATA_KEY}`
const EVENT_CHANGE = `change${EVENT_KEY}`
const CLASS_NAME_ITEM = 'transfer-item'
const attributeSelector = (name: string): string => `[data-bs-${name}], [data-tblr-${name}]`
const SELECTOR_DATA_TOGGLE = `[data-bs-toggle="${NAME}"], [data-tblr-toggle="${NAME}"]`
const SELECTOR_PANEL = attributeSelector(`${NAME}-panel`)
const SELECTOR_LIST = attributeSelector(`${NAME}-list`)
const SELECTOR_SEARCH = attributeSelector(`${NAME}-search`)
const SELECTOR_COUNTER = attributeSelector(`${NAME}-counter`)
const SELECTOR_SELECT_ALL = attributeSelector(`${NAME}-select-all`)
const SELECTOR_ACTION = attributeSelector(`${NAME}-action`)
const SELECTOR_ITEM = `.${CLASS_NAME_ITEM}`
const SELECTOR_CHECKBOX = 'input[type="checkbox"]'
const SELECTOR_SELECT = 'select[multiple]'
const ATTRIBUTES_PANEL = [`data-bs-${NAME}-panel`, `data-tblr-${NAME}-panel`]
const ATTRIBUTES_ACTION = [`data-bs-${NAME}-action`, `data-tblr-${NAME}-action`]
const ACTIONS: TransferAction[] = ['add', 'remove', 'add-all', 'remove-all', 'up', 'down']
const KEY_UP = 'ArrowUp'
const KEY_DOWN = 'ArrowDown'
const KEY_HOME = 'Home'
const KEY_END = 'End'
const KEY_SPACE = ' '
const KEY_ENTER = 'Enter'
const Default: ComponentConfig = {
counterTemplate: '{checked} / {total}',
name: null,
}
const DefaultType: Record<keyof ComponentConfig, string> = {
counterTemplate: 'string',
name: '(string|null)',
}
/**
* Class definition
*
* Two lists side by side with buttons between them. The rows live in the
* markup; the plugin moves them from one list to the other and keeps a
* `<select multiple>` in sync, so a form submits the values of the rows in the
* target list and sees nothing but an ordinary field.
*/
class Transfer extends BaseComponent {
declare _element: HTMLElement
declare _config: ComponentConfig
_panels: Record<TransferSide, HTMLElement | null> = { source: null, target: null }
_select: HTMLSelectElement | null = null
// Position of every row in the markup, so a row sent back lands where it came from
_order = new Map<HTMLElement, number>()
_onClick = (event: Event): void => this._handleClick(event)
_onKeyDown = (event: Event): void => this._handleKeyDown(event as KeyboardEvent)
_onInput = (event: Event): void => this._handleInput(event)
_onChange = (event: Event): void => this._handleChange(event)
constructor(element: ElementSelector, config?: ComponentConfigInput) {
super(element, config)
if (!this._element) {
return
}
for (const panel of SelectorEngine.find(SELECTOR_PANEL, this._element) as HTMLElement[]) {
const side = this._sideOf(panel)
if (side && !this._panels[side]) {
this._panels[side] = panel
}
}
if (!this._panels.source || !this._panels.target) {
return
}
this._setUpItems()
this._setUpSelect()
this._addEventListeners()
this._syncSelect()
this._refresh()
}
// Getters
static get Default(): ComponentConfig {
return Default
}
static get DefaultType(): Record<keyof ComponentConfig, string> {
return DefaultType
}
static get NAME(): string {
return NAME
}
// Public
getValue(): string[] {
return this._items('target').map((item) => this._valueOf(item))
}
setValue(values: string[]): void {
const before = this.getValue()
const wanted = values.map(String)
const items = [...this._items('source'), ...this._items('target')]
const byValue = new Map(items.map((item) => [this._valueOf(item), item]))
for (const item of items) {
if (!wanted.includes(this._valueOf(item)) && !this._isDisabled(item)) {
this._place(item, 'source')
}
}
for (const value of wanted) {
const item = byValue.get(value)
if (item && !this._isDisabled(item)) {
this._place(item, 'target')
}
}
this._finish(before, 'reorder')
}
add(values?: string[]): void {
this._moveItems(this._pick('source', values), 'target')
}
remove(values?: string[]): void {
this._moveItems(this._pick('target', values), 'source')
}
addAll(): void {
this._moveItems(this._enabled('source'), 'target')
}
removeAll(): void {
this._moveItems(this._enabled('target'), 'source')
}
dispose(): void {
if (this._element) {
this._element.removeEventListener('click', this._onClick)
this._element.removeEventListener('keydown', this._onKeyDown)
this._element.removeEventListener('input', this._onInput)
this._element.removeEventListener('change', this._onChange)
}
super.dispose()
}
// Private
_sideOf(panel: HTMLElement): TransferSide | null {
for (const name of ATTRIBUTES_PANEL) {
const side = panel.getAttribute(name)
if (side === 'source' || side === 'target') {
return side
}
}
return null
}
_list(side: TransferSide): HTMLElement {
return SelectorEngine.findOne(SELECTOR_LIST, this._panels[side]!) as HTMLElement
}
_items(side: TransferSide): HTMLElement[] {
return SelectorEngine.find(SELECTOR_ITEM, this._list(side)) as HTMLElement[]
}
_enabled(side: TransferSide): HTMLElement[] {
return this._items(side).filter((item) => !this._isDisabled(item))
}
_checked(side: TransferSide): HTMLElement[] {
return this._enabled(side).filter((item) => this._isChecked(item))
}
_pick(side: TransferSide, values?: string[]): HTMLElement[] {
if (!values) {
return this._checked(side)
}
const wanted = values.map(String)
return this._enabled(side).filter((item) => wanted.includes(this._valueOf(item)))
}
_valueOf(item: HTMLElement): string {
return item.dataset.value ?? ''
}
_isDisabled(item: HTMLElement): boolean {
return item.getAttribute('aria-disabled') === 'true'
}
_isChecked(item: HTMLElement): boolean {
return item.getAttribute('aria-selected') === 'true'
}
_isVisible(item: HTMLElement): boolean {
return !item.hidden
}
_setUpItems(): void {
let index = 0
for (const side of ['source', 'target'] as TransferSide[]) {
const list = this._list(side)
list.setAttribute('role', 'listbox')
list.setAttribute('aria-multiselectable', 'true')
for (const item of this._items(side)) {
const checkbox = SelectorEngine.findOne(SELECTOR_CHECKBOX, item) as HTMLInputElement | null
item.setAttribute('role', 'option')
item.tabIndex = -1
this._order.set(item, index++)
this._setChecked(item, this._isChecked(item) || Boolean(checkbox?.checked))
if (checkbox) {
checkbox.tabIndex = -1
checkbox.setAttribute('aria-hidden', 'true')
}
}
}
}
// Uses the `<select multiple>` of the markup, or renders a hidden one when
// the `name` option asks for it.
_setUpSelect(): void {
const existing = SelectorEngine.findOne(SELECTOR_SELECT, this._element) as HTMLSelectElement | null
if (existing) {
this._select = existing
return
}
if (!this._config.name) {
return
}
const select = document.createElement('select')
select.multiple = true
select.hidden = true
select.name = this._config.name
this._element.append(select)
this._select = select
}
_addEventListeners(): void {
this._element.addEventListener('click', this._onClick)
this._element.addEventListener('keydown', this._onKeyDown)
this._element.addEventListener('input', this._onInput)
this._element.addEventListener('change', this._onChange)
}
_itemFrom(event: Event): HTMLElement | null {
const target = event.target as Element | null
const item = target?.closest(SELECTOR_ITEM) as HTMLElement | null
return item && this._element.contains(item) ? item : null
}
_sideOfItem(item: HTMLElement): TransferSide {
return this._panels.target!.contains(item) ? 'target' : 'source'
}
_actionOf(button: Element): TransferAction | null {
for (const name of ATTRIBUTES_ACTION) {
const action = button.getAttribute(name) as TransferAction | null
if (action && ACTIONS.includes(action)) {
return action
}
}
return null
}
_handleClick(event: Event): void {
const target = event.target as Element
const button = target.closest(SELECTOR_ACTION)
if (button && this._element.contains(button)) {
event.preventDefault()
this._runAction(this._actionOf(button))
return
}
const item = this._itemFrom(event)
if (item && !this._isDisabled(item)) {
this._setChecked(item, !this._isChecked(item))
this._focusItem(item)
this._refresh()
}
}
_runAction(action: TransferAction | null): void {
switch (action) {
case 'add':
this.add()
break
case 'remove':
this.remove()
break
case 'add-all':
this.addAll()
break
case 'remove-all':
this.removeAll()
break
case 'up':
this._reorder(-1)
break
case 'down':
this._reorder(1)
break
default:
}
}
_handleKeyDown(event: KeyboardEvent): void {
const item = this._itemFrom(event)
if (!item || event.target !== item) {
return
}
const side = this._sideOfItem(item)
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'a') {
event.preventDefault()
this._checkAll(side, true)
return
}
if (event.key === KEY_SPACE || event.key === KEY_ENTER) {
event.preventDefault()
if (!this._isDisabled(item)) {
this._setChecked(item, !this._isChecked(item))
this._refresh()
}
return
}
const visible = this._items(side).filter((candidate) => this._isVisible(candidate))
const index = visible.indexOf(item)
const targets: Record<string, HTMLElement | undefined> = {
[KEY_UP]: visible[index - 1],
[KEY_DOWN]: visible[index + 1],
[KEY_HOME]: visible[0],
[KEY_END]: visible[visible.length - 1],
}
if (!(event.key in targets)) {
return
}
event.preventDefault()
const next = targets[event.key]
if (next) {
this._focusItem(next)
}
}
_handleInput(event: Event): void {
const search = (event.target as Element).closest(SELECTOR_SEARCH) as HTMLInputElement | null
const panel = search?.closest(SELECTOR_PANEL) as HTMLElement | null
if (!search || !panel) {
return
}
const side = this._sideOf(panel)
if (side) {
this._applyFilter(side)
this._refresh()
}
}
_handleChange(event: Event): void {
const checkbox = (event.target as Element).closest(SELECTOR_SELECT_ALL) as HTMLInputElement | null
const panel = checkbox?.closest(SELECTOR_PANEL) as HTMLElement | null
if (!checkbox || !panel) {
return
}
const side = this._sideOf(panel)
if (side) {
this._checkAll(side, checkbox.checked)
}
}
_applyFilter(side: TransferSide): void {
const search = SelectorEngine.findOne(SELECTOR_SEARCH, this._panels[side]!) as HTMLInputElement | null
const query = (search?.value ?? '').trim().toLowerCase()
for (const item of this._items(side)) {
item.hidden = query !== '' && !(item.textContent ?? '').toLowerCase().includes(query)
}
}
_checkAll(side: TransferSide, checked: boolean): void {
for (const item of this._enabled(side)) {
if (this._isVisible(item)) {
this._setChecked(item, checked)
}
}
this._refresh()
}
_setChecked(item: HTMLElement, checked: boolean): void {
item.setAttribute('aria-selected', String(checked))
const checkbox = SelectorEngine.findOne(SELECTOR_CHECKBOX, item) as HTMLInputElement | null
if (checkbox) {
checkbox.checked = checked
}
}
_focusItem(item: HTMLElement): void {
for (const sibling of this._items(this._sideOfItem(item))) {
sibling.tabIndex = sibling === item ? 0 : -1
}
item.focus()
}
// Appends the row to the target list, or puts it back among the rows of the
// source list in its original order.
_place(item: HTMLElement, side: TransferSide): void {
const list = this._list(side)
this._setChecked(item, false)
if (side === 'target') {
list.append(item)
} else {
const order = this._order.get(item) ?? 0
const next = this._items('source').find((candidate) => candidate !== item && (this._order.get(candidate) ?? 0) > order)
list.insertBefore(item, next ?? null)
}
}
_moveItems(items: HTMLElement[], side: TransferSide): void {
if (items.length === 0) {
return
}
const before = this.getValue()
for (const item of items) {
this._place(item, side)
}
this._applyFilter('source')
this._applyFilter('target')
this._finish(
before,
side === 'target' ? 'add' : 'remove',
items.map((item) => this._valueOf(item)),
)
}
_reorder(step: -1 | 1): void {
const before = this.getValue()
const list = this._list('target')
const items = this._items('target')
if (step === 1) {
items.reverse()
}
for (const item of items) {
if (!this._isChecked(item) || this._isDisabled(item)) {
continue
}
const neighbour = (step === -1 ? item.previousElementSibling : item.nextElementSibling) as HTMLElement | null
if (neighbour && !this._isChecked(neighbour)) {
list.insertBefore(item, step === -1 ? neighbour : neighbour.nextElementSibling)
}
}
this._finish(before, 'reorder')
}
_finish(before: string[], direction: TransferDirection, moved: string[] = []): void {
const value = this.getValue()
const changed = value.length !== before.length || value.some((entry, index) => entry !== before[index])
this._syncSelect()
this._refresh()
if (!changed) {
return
}
this._select?.dispatchEvent(new Event('change', { bubbles: true }))
EventHandler.trigger(this._element, EVENT_CHANGE, { value, moved, direction })
}
// The options follow the rows: selected when the row is in the target list,
// and ordered like the target list, since a form submits them in DOM order.
_syncSelect(): void {
const select = this._select
if (!select) {
return
}
const rows = [...this._items('source'), ...this._items('target')]
const options = new Map(Array.from(select.options, (option) => [option.value, option]))
for (const item of rows) {
const value = this._valueOf(item)
let option = options.get(value)
if (!option) {
option = new Option((item.textContent ?? '').trim(), value)
options.set(value, option)
}
option.selected = this._sideOfItem(item) === 'target'
}
const ordered = rows.map((item) => options.get(this._valueOf(item))!)
const selected = this._items('target').map((item) => options.get(this._valueOf(item))!)
select.replaceChildren(...selected, ...ordered.filter((option) => !selected.includes(option)))
for (const option of selected) {
option.selected = true
}
}
// Counters, the select-all checkboxes, the roving tabindex and the buttons
_refresh(): void {
for (const side of ['source', 'target'] as TransferSide[]) {
const panel = this._panels[side]!
const items = this._items(side)
const enabled = this._enabled(side).filter((item) => this._isVisible(item))
const checked = enabled.filter((item) => this._isChecked(item))
const counter = SelectorEngine.findOne(SELECTOR_COUNTER, panel)
if (counter) {
const total = items.length
const checkedCount = items.filter((item) => this._isChecked(item)).length
counter.textContent = this._config.counterTemplate.replace('{checked}', String(checkedCount)).replace('{total}', String(total))
}
const selectAll = SelectorEngine.findOne(SELECTOR_SELECT_ALL, panel) as HTMLInputElement | null
if (selectAll) {
selectAll.checked = enabled.length > 0 && checked.length === enabled.length
selectAll.indeterminate = checked.length > 0 && checked.length < enabled.length
selectAll.disabled = enabled.length === 0
}
const visible = items.filter((item) => this._isVisible(item))
const current = visible.find((item) => item.tabIndex === 0) ?? visible.find((item) => !this._isDisabled(item)) ?? visible[0]
for (const item of items) {
item.tabIndex = item === current ? 0 : -1
}
}
for (const button of SelectorEngine.find(SELECTOR_ACTION, this._element) as HTMLButtonElement[]) {
const action = this._actionOf(button)
let enabled = false
if (action === 'add') {
enabled = this._checked('source').length > 0
} else if (action === 'remove') {
enabled = this._checked('target').length > 0
} else if (action === 'add-all') {
enabled = this._enabled('source').length > 0
} else if (action === 'remove-all') {
enabled = this._enabled('target').length > 0
} else if (action === 'up' || action === 'down') {
enabled = this._checked('target').length > 0
}
button.disabled = !enabled
}
}
}
/**
* Data API implementation
*/
// js-docs-start transfer-init
initAll(SELECTOR_DATA_TOGGLE, Transfer)
// js-docs-end transfer-init
export default Transfer
+2
View File
@@ -16,6 +16,7 @@ import './src/otp-input'
import './src/sparkline'
import './src/strength'
import './src/stepper'
import './src/transfer'
// Re-export everything from bootstrap.ts (single source of truth)
export * from './src/bootstrap'
@@ -33,6 +34,7 @@ export { default as Sparkline } from './src/sparkline'
export { default as Stepper } from './src/stepper'
export { default as Strength } from './src/strength'
export { default as SwitchIcon } from './src/switch-icon'
export { default as Transfer } from './src/transfer'
// deprecated(2.0): the `tabler` namespace with `getColor()`, `hexToRgba()` and `prefix`
export * as tabler from './src/deprecated'
+235
View File
@@ -0,0 +1,235 @@
import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest'
import { clearFixture, getFixture } from '../helpers/fixture'
import Transfer from '../../src/transfer'
const item = (value: string, attributes = ''): string => `<div class="list-group-item transfer-item" data-value="${value}" ${attributes}><input type="checkbox" class="form-check-input" /><span>Item ${value}</span></div>`
const markup = (): string =>
[
'<div class="transfer" data-bs-toggle="transfer" data-bs-name="picked">',
' <div class="card transfer-panel" data-bs-transfer-panel="source">',
' <div class="card-header"><input type="checkbox" data-bs-transfer-select-all /><span data-bs-transfer-counter></span></div>',
' <input type="search" data-bs-transfer-search />',
` <div class="list-group" data-bs-transfer-list>${item('a')}${item('b')}${item('c')}${item('d', 'aria-disabled="true"')}</div>`,
' </div>',
' <div class="transfer-actions">',
' <button type="button" data-bs-transfer-action="add"></button>',
' <button type="button" data-bs-transfer-action="remove"></button>',
' <button type="button" data-bs-transfer-action="add-all"></button>',
' <button type="button" data-bs-transfer-action="remove-all"></button>',
' <button type="button" data-bs-transfer-action="up"></button>',
' <button type="button" data-bs-transfer-action="down"></button>',
' </div>',
' <div class="card transfer-panel" data-bs-transfer-panel="target">',
' <div class="card-header"><span data-bs-transfer-counter></span></div>',
` <div class="list-group" data-bs-transfer-list>${item('e')}</div>`,
' </div>',
'</div>',
].join('')
describe('Transfer', () => {
let fixtureEl: HTMLElement
beforeAll(() => {
fixtureEl = getFixture()
})
beforeEach(() => {
fixtureEl.innerHTML = markup()
})
afterEach(() => {
clearFixture()
vi.restoreAllMocks()
})
const root = (): HTMLElement => fixtureEl.querySelector('.transfer')!
const rows = (side: 'source' | 'target'): HTMLElement[] => Array.from(fixtureEl.querySelectorAll(`[data-bs-transfer-panel="${side}"] .transfer-item`))
const values = (side: 'source' | 'target'): string[] => rows(side).map((row) => row.dataset.value!)
const row = (value: string): HTMLElement => fixtureEl.querySelector(`[data-value="${value}"]`)!
const button = (action: string): HTMLButtonElement => fixtureEl.querySelector(`[data-bs-transfer-action="${action}"]`)!
const select = (): HTMLSelectElement => fixtureEl.querySelector('select')!
const selectedOptions = (): string[] => Array.from(select().selectedOptions, (option) => option.value)
describe('NAME', () => {
it('should return the plugin name', () => {
expect(Transfer.NAME).toBe('transfer')
})
})
describe('constructor', () => {
it('should set up the listbox semantics', () => {
const transfer = new Transfer(root())
expect(transfer.getValue()).toEqual(['e'])
expect(fixtureEl.querySelector('[data-bs-transfer-list]')!.getAttribute('role')).toBe('listbox')
expect(fixtureEl.querySelector('[data-bs-transfer-list]')!.getAttribute('aria-multiselectable')).toBe('true')
expect(row('a').getAttribute('role')).toBe('option')
})
it('should render a hidden select from the name option and sync it', () => {
new Transfer(root())
expect(select().name).toBe('picked')
expect(select().hidden).toBe(true)
expect(selectedOptions()).toEqual(['e'])
})
it('should fill the counters and disable the buttons without a selection', () => {
new Transfer(root())
expect(fixtureEl.querySelector('[data-bs-transfer-counter]')!.textContent).toBe('0 / 4')
expect(button('add').disabled).toBe(true)
expect(button('add-all').disabled).toBe(false)
})
})
describe('selecting rows', () => {
it('should toggle a row on click and enable the add button', () => {
new Transfer(root())
row('a').click()
expect(row('a').getAttribute('aria-selected')).toBe('true')
expect(button('add').disabled).toBe(false)
expect(fixtureEl.querySelector('[data-bs-transfer-counter]')!.textContent).toBe('1 / 4')
row('a').click()
expect(row('a').getAttribute('aria-selected')).toBe('false')
})
it('should ignore a disabled row', () => {
new Transfer(root())
row('d').click()
expect(row('d').getAttribute('aria-selected')).toBe('false')
})
it('should select the visible rows with the select-all checkbox', () => {
new Transfer(root())
const all = fixtureEl.querySelector<HTMLInputElement>('[data-bs-transfer-select-all]')!
all.checked = true
all.dispatchEvent(new Event('change', { bubbles: true }))
expect(['a', 'b', 'c'].every((value) => row(value).getAttribute('aria-selected') === 'true')).toBe(true)
expect(row('d').getAttribute('aria-selected')).toBe('false')
expect(all.checked).toBe(true)
})
it('should filter rows by the search field', () => {
new Transfer(root())
const search = fixtureEl.querySelector<HTMLInputElement>('[data-bs-transfer-search]')!
search.value = 'item b'
search.dispatchEvent(new Event('input', { bubbles: true }))
expect(row('a').hidden).toBe(true)
expect(row('b').hidden).toBe(false)
})
})
describe('moving rows', () => {
it('should move the selected rows with the add button and fire change.bs.transfer', () => {
const transfer = new Transfer(root())
const spy = vi.fn()
root().addEventListener('change.bs.transfer', spy)
row('a').click()
row('c').click()
button('add').click()
expect(values('target')).toEqual(['e', 'a', 'c'])
expect(values('source')).toEqual(['b', 'd'])
expect(transfer.getValue()).toEqual(['e', 'a', 'c'])
expect(selectedOptions()).toEqual(['e', 'a', 'c'])
expect(spy).toHaveBeenCalledTimes(1)
const event = spy.mock.calls[0][0] as Event & { value: string[]; moved: string[]; direction: string }
expect(event.value).toEqual(['e', 'a', 'c'])
expect(event.moved).toEqual(['a', 'c'])
expect(event.direction).toBe('add')
})
it('should send a row back to its original place', () => {
const transfer = new Transfer(root())
transfer.add(['b'])
transfer.add(['a'])
transfer.remove(['b'])
expect(values('source')).toEqual(['b', 'c', 'd'])
})
it('should move every enabled row with add-all and remove-all', () => {
const transfer = new Transfer(root())
button('add-all').click()
expect(values('target')).toEqual(['e', 'a', 'b', 'c'])
expect(values('source')).toEqual(['d'])
button('remove-all').click()
expect(transfer.getValue()).toEqual([])
expect(values('source')).toEqual(['a', 'b', 'c', 'd', 'e'])
})
it('should fire a change event on the select', () => {
const transfer = new Transfer(root())
const spy = vi.fn()
select().addEventListener('change', spy)
transfer.add(['a'])
expect(spy).toHaveBeenCalledTimes(1)
})
it('should not fire anything when nothing moves', () => {
const transfer = new Transfer(root())
const spy = vi.fn()
root().addEventListener('change.bs.transfer', spy)
transfer.add(['unknown'])
expect(spy).not.toHaveBeenCalled()
})
})
describe('reordering', () => {
it('should move the selected target rows up and down', () => {
const transfer = new Transfer(root())
transfer.add(['a', 'b'])
expect(transfer.getValue()).toEqual(['e', 'a', 'b'])
row('b').click()
button('up').click()
expect(transfer.getValue()).toEqual(['e', 'b', 'a'])
expect(selectedOptions()).toEqual(['e', 'b', 'a'])
button('down').click()
expect(transfer.getValue()).toEqual(['e', 'a', 'b'])
})
})
describe('setValue', () => {
it('should put exactly the given values in the target list', () => {
const transfer = new Transfer(root())
transfer.setValue(['c', 'a'])
expect(transfer.getValue()).toEqual(['c', 'a'])
expect(values('source')).toEqual(['b', 'd', 'e'])
})
})
describe('keyboard', () => {
const press = (target: HTMLElement, key: string, init: KeyboardEventInit = {}): void => {
target.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...init }))
}
it('should toggle a row with space and move focus with the arrow keys', () => {
new Transfer(root())
row('a').focus()
press(row('a'), ' ')
expect(row('a').getAttribute('aria-selected')).toBe('true')
press(row('a'), 'ArrowDown')
expect(document.activeElement).toBe(row('b'))
expect(row('b').tabIndex).toBe(0)
expect(row('a').tabIndex).toBe(-1)
})
it('should select every row with Ctrl+A', () => {
new Transfer(root())
row('a').focus()
press(row('a'), 'a', { ctrlKey: true })
expect(['a', 'b', 'c'].every((value) => row(value).getAttribute('aria-selected') === 'true')).toBe(true)
})
})
describe('dispose', () => {
it('should remove the instance and stop reacting', () => {
const transfer = new Transfer(root())
transfer.dispose()
expect(Transfer.getInstance(root())).toBeNull()
row('a').click()
expect(row('a').getAttribute('aria-selected')).toBe('false')
})
})
})
+2 -2
View File
@@ -216,7 +216,7 @@
},
{
"path": "./dist/js/tabler.min.js",
"maxSize": "42 kB"
"maxSize": "44 kB"
},
{
"path": "./dist/js/tabler.esm.js",
@@ -224,7 +224,7 @@
},
{
"path": "./dist/js/tabler.esm.min.js",
"maxSize": "42 kB"
"maxSize": "44 kB"
},
{
"path": "./dist/js/tabler-theme.js",
+1
View File
@@ -99,6 +99,7 @@
@forward 'ui/toolbar';
@forward 'ui/tracking';
@forward 'ui/timeline';
@forward 'ui/transfer';
@forward 'ui/type';
@forward 'ui/charts';
@forward 'ui/sparkline';
+10
View File
@@ -1235,6 +1235,16 @@ $stepper-icon-decrement: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2
$stepper-icon-increment: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path fill='none' stroke='black' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M12 5v14M5 12h14'/></svg>") !default;
// scss-docs-end stepper-variables
// Transfer
// scss-docs-start transfer-variables
$transfer-gap: 1rem !default;
$transfer-list-height: 16rem !default;
$transfer-item-padding-y: 0.5rem !default;
$transfer-item-padding-x: 1.25rem !default;
$transfer-item-hover-bg: var(--hover-bg) !default;
$transfer-item-selected-bg: var(--active-bg) !default;
// scss-docs-end transfer-variables
// Cards
// scss-docs-start card-variables
$card-spacer-y: 1.25rem !default;
+102
View File
@@ -0,0 +1,102 @@
//
// Transfer (dual listbox)
//
@use '../config' as *;
.transfer {
// scss-docs-start transfer-css-vars
--transfer-gap: #{$transfer-gap};
--transfer-list-height: #{$transfer-list-height};
--transfer-item-padding-y: #{$transfer-item-padding-y};
--transfer-item-padding-x: #{$transfer-item-padding-x};
--transfer-item-hover-bg: #{$transfer-item-hover-bg};
--transfer-item-selected-bg: #{$transfer-item-selected-bg};
// scss-docs-end transfer-css-vars
display: flex;
flex-direction: column;
gap: var(--transfer-gap);
align-items: stretch;
width: 100%;
@include media-breakpoint-up(md) {
flex-direction: row;
}
}
.transfer-panel {
flex: 1 1 0;
min-width: 0;
margin-bottom: 0;
}
.transfer-counter {
margin-inline-start: auto;
font-size: $font-size-sm;
color: var(--secondary-color);
white-space: nowrap;
}
.transfer-list {
height: var(--transfer-list-height);
overflow-y: auto;
}
.transfer-item {
display: flex;
gap: 0.5rem;
align-items: center;
padding: var(--transfer-item-padding-y) var(--transfer-item-padding-x);
cursor: pointer;
user-select: none;
.form-check-input {
flex: none;
margin: 0;
pointer-events: none;
}
&:hover {
background-color: var(--transfer-item-hover-bg);
}
&[aria-selected='true'] {
background-color: var(--transfer-item-selected-bg);
}
&:focus-visible {
outline: 0;
box-shadow: inset 0 0 0 2px $focus-ring-color;
}
&[aria-disabled='true'] {
color: var(--secondary-color);
pointer-events: none;
cursor: default;
opacity: 0.6;
}
}
.transfer-actions {
$horizontal-icons: '.btn:not([data-bs-transfer-action="up"], [data-bs-transfer-action="down"], [data-tblr-transfer-action="up"], [data-tblr-transfer-action="down"]) .icon';
display: flex;
flex: none;
flex-direction: row;
gap: 0.5rem;
align-items: center;
justify-content: center;
@include media-breakpoint-up(md) {
flex-direction: column;
#{$horizontal-icons} {
scale: var(--dir) 1;
}
}
@include media-breakpoint-down(md) {
#{$horizontal-icons} {
rotate: 90deg;
}
}
}
+174
View File
@@ -0,0 +1,174 @@
---
title: Transfer
seoTitle: Transfer (dual listbox)
summary: A transfer is a pair of lists with buttons between them, to move items from the available list to the selected one and back.
description: Add a transfer (dual listbox) with search, select all, reordering and a hidden select that keeps a form in sync.
related: [/ui/components/list-group, /ui/forms/elements]
source: core/scss/ui/_transfer.scss
css-vars: transfer
classnames:
component:
- class: transfer
desc: The root, two panels with a column of buttons between them; stacks on small screens
part:
- class: transfer-panel
desc: A card that holds one list
- class: transfer-counter
desc: The "checked / total" counter in a panel header
- class: transfer-list
desc: The scrollable list of rows
- class: transfer-item
desc: A row; carries the value in `data-value`
- class: transfer-actions
desc: The column of buttons between the panels
---
import Example from '@components/Example.astro'
import CodeDocs from '@components/CodeDocs.astro'
import Transfer from '@ui/Transfer.astro'
## Overview
A transfer is two lists side by side. Click rows to check them, then use the buttons between the lists to move the checked rows across, or move every row at once. The rows stay in the markup; the plugin moves them and keeps a `<select multiple>` in sync, so a form submits the values of the rows in the right-hand list
like any other field.
<Example columnFullWidth>
<Transfer
name="members[]"
items={[
{ value: 'ada', label: 'Ada Lovelace' },
{ value: 'alan', label: 'Alan Turing' },
{ value: 'grace', label: 'Grace Hopper' },
{ value: 'linus', label: 'Linus Torvalds' },
]}
selected={['grace']}
height="12rem"
/>
</Example>
## Usage
### Markup
Put `data-bs-toggle="transfer"` on the root. Each panel carries `data-bs-transfer-panel="source"` or `"target"` and a list with `data-bs-transfer-list`. A row is a `.transfer-item` with a `data-value` and, optionally, a checkbox. Buttons use `data-bs-transfer-action` with `add`, `remove`, `add-all`, `remove-all`, `up`
or `down`; a button is disabled while its action has nothing to do.
```html
<div class="transfer" data-bs-toggle="transfer" data-bs-name="members[]">
<div class="card transfer-panel" data-bs-transfer-panel="source">
<div class="card-header">
<label class="form-check mb-0">
<input type="checkbox" class="form-check-input" data-bs-transfer-select-all />
<span class="form-check-label">Available</span>
</label>
<span class="transfer-counter" data-bs-transfer-counter></span>
</div>
<div class="list-group list-group-flush transfer-list" aria-label="Available" data-bs-transfer-list>
<div class="list-group-item transfer-item" data-value="ada">
<input type="checkbox" class="form-check-input" />
<span>Ada Lovelace</span>
</div>
</div>
</div>
<div class="transfer-actions">
<button type="button" class="btn btn-icon" aria-label="Move selected to the right" data-bs-transfer-action="add">…</button>
</div>
<div class="card transfer-panel" data-bs-transfer-panel="target">…</div>
</div>
```
The rows that start in the target panel are the initial value. A row sent back lands in its original place in the available list.
### Search
Add an input with `data-bs-transfer-search` to a panel to filter its rows by text.
<Example columnFullWidth>
<Transfer
items={[
{ value: 'name', label: 'Name' },
{ value: 'email', label: 'Email' },
{ value: 'role', label: 'Role' },
{ value: 'created', label: 'Created' },
{ value: 'status', label: 'Status' },
]}
selected={['name']}
search
height="12rem"
/>
</Example>
### Reordering
Add the `up` and `down` buttons to move the checked rows of the right-hand list one place at a time. The order is kept in the `<select>`, so it is the order a form submits.
<Example columnFullWidth>
<Transfer
items={[
{ value: 'name', label: 'Name' },
{ value: 'email', label: 'Email' },
{ value: 'role', label: 'Role' },
{ value: 'created', label: 'Created' },
]}
selected={['name', 'email']}
titles={['Hidden columns', 'Visible columns']}
reorder
height="12rem"
/>
</Example>
### Disabled rows
Add `aria-disabled="true"` to a row to keep it from being checked or moved.
### Form value
Use a `<select multiple>` inside the root, or set the `name` option and the plugin renders a hidden one. Its options follow the rows: an option is selected when its row is in the right-hand list, and the options are ordered like that list. It fires a `change` event whenever the value changes.
### Keyboard
The lists are `role="listbox"` with `aria-multiselectable`, and the rows are options. <kbd>↑</kbd>, <kbd>↓</kbd>, <kbd>Home</kbd> and <kbd>End</kbd> move the focus, <kbd>Space</kbd> and <kbd>Enter</kbd> check a row, <kbd>Ctrl</kbd> + <kbd>A</kbd> checks every visible row.
## Options
Options are read from `data-bs-*` attributes on the root (the `data-tblr-*` alias also works), or passed to the constructor.
| Option | Type | Default | Description |
| ----------------- | -------------- | ------------------------- | ------------------------------------------------------------------------------------- |
| `counterTemplate` | string | `\'{checked} / {total}\'` | Text of the counter in a panel header; `{checked}` and `{total}` are replaced. |
| `name` | string or null | `null` | `name` of the hidden `<select multiple>` the plugin renders when the markup has none. |
## JavaScript
Tabler creates a `Transfer` for every element matching `[data-bs-toggle="transfer"]` (or `[data-tblr-toggle="transfer"]`):
<CodeDocs name="transfer-init" file="core/js/src/transfer.ts" />
Create one manually, or get an existing instance:
```javascript
const el = document.querySelector('#members')
const transfer = tabler.Transfer.getOrCreateInstance(el)
```
`change.bs.transfer` fires on the root after rows moved or were reordered:
```javascript
el.addEventListener('change.bs.transfer', (event) => {
console.log(event.value, event.moved, event.direction)
})
```
`value` is the list of values in the right-hand list, `moved` the values that moved, and `direction` is `add`, `remove` or `reorder`.
### Methods
| Method | Description |
| ------------------ | ---------------------------------------------------------------------- |
| `getValue()` | Returns the values of the right-hand list, in order. |
| `setValue(values)` | Puts exactly these values in the right-hand list, in the given order. |
| `add(values?)` | Moves the given rows (or the checked ones) to the right-hand list. |
| `remove(values?)` | Moves the given rows (or the checked ones) back to the left-hand list. |
| `addAll()` | Moves every enabled row to the right-hand list. |
| `removeAll()` | Moves every enabled row back to the left-hand list. |
| `dispose()` | Removes the listeners and the instance. |
+53
View File
@@ -0,0 +1,53 @@
---
import DefaultLayout from '@shared/layouts/DefaultLayout.astro'
import Card from '@ui/Card.astro'
import CardBody from '@ui/CardBody.astro'
import CardTitle from '@ui/CardTitle.astro'
import CardSubtitle from '@ui/CardSubtitle.astro'
import DocsLink from '@ui/DocsLink.astro'
import Transfer from '@ui/Transfer.astro'
const users = [
{ value: 'ada', label: 'Ada Lovelace' },
{ value: 'alan', label: 'Alan Turing' },
{ value: 'grace', label: 'Grace Hopper' },
{ value: 'linus', label: 'Linus Torvalds' },
{ value: 'margaret', label: 'Margaret Hamilton' },
{ value: 'dennis', label: 'Dennis Ritchie', disabled: true },
{ value: 'barbara', label: 'Barbara Liskov' },
]
const columns = [
{ value: 'name', label: 'Name' },
{ value: 'email', label: 'Email' },
{ value: 'role', label: 'Role' },
{ value: 'created', label: 'Created' },
{ value: 'status', label: 'Status' },
{ value: 'country', label: 'Country' },
]
---
<DefaultLayout title="Transfer" pageMenu="base.transfer" description="A transfer is a pair of lists with buttons between them, to move items from available to selected and back.">
<DocsLink slot="page-header-actions" path="/ui/components/transfer" />
<div class="row row-cards">
<div class="col-12">
<Card>
<CardBody>
<CardTitle>Basic</CardTitle>
<CardSubtitle>Pick rows, then move them with the arrows. A hidden <code>&lt;select multiple&gt;</code> carries the chosen values in a form.</CardSubtitle>
<Transfer name="members[]" items={users} selected={['grace']} />
</CardBody>
</Card>
</div>
<div class="col-12">
<Card>
<CardBody>
<CardTitle>Search and reordering</CardTitle>
<CardSubtitle>Search each list, and change the order of the selected rows with the up and down buttons. A disabled row stays where it is.</CardSubtitle>
<Transfer name="columns[]" items={columns} selected={['name', 'email']} titles={['Hidden columns', 'Visible columns']} search reorder />
</CardBody>
</Card>
</div>
</div>
</DefaultLayout>
+4
View File
@@ -354,6 +354,10 @@
"title": "Tracking",
"url": "/ui/components/tracking"
},
{
"title": "Transfer",
"url": "/ui/components/transfer"
},
{
"title": "Trending",
"url": "/ui/components/trending"
+5
View File
@@ -294,6 +294,11 @@
"title": "Tracking",
"url": "tracking.html"
},
"transfer": {
"url": "transfer.html",
"title": "Transfer",
"badge": "New"
},
"typography": {
"title": "Typography",
"url": "typography.html"
+107
View File
@@ -0,0 +1,107 @@
---
// Transfer initializes from `data-bs-toggle="transfer"` (tabler.js, core/js/src/transfer.ts).
// The rows live in the markup; the plugin moves them and keeps the `<select multiple>` in sync.
import Icon from './Icon.astro'
export interface TransferItem {
value: string
label: string
disabled?: boolean
}
interface Props {
/** every row, in its original order */
items: TransferItem[]
/** values of the rows that start in the target list */
selected?: string[]
/** data-bs-name: `name` of the `<select multiple>` that carries the value in a form */
name?: string
/** headers of the two panels */
titles?: [string, string]
/** search field in each panel header */
search?: boolean
/** select-all checkbox in each panel header */
selectAll?: boolean
/** up / down buttons to reorder the target list */
reorder?: boolean
/** data-bs-counter-template: `{checked}` and `{total}` are replaced (default: `{checked} / {total}`) */
counterTemplate?: string
/** placeholder of the search fields */
searchPlaceholder?: string
/** height of the lists, any CSS length */
height?: string
class?: string
/** remaining attributes (id, data-*, …) are forwarded to the root element */
[key: string]: unknown
}
const { items, selected = [], name, titles = ['Available', 'Selected'], search = false, selectAll = true, reorder = false, counterTemplate, searchPlaceholder = 'Search…', height, class: className, ...rest } = Astro.props
const sides = [
{ side: 'source', title: titles[0], items: items.filter((item) => !selected.includes(item.value)) },
{ side: 'target', title: titles[1], items: items.filter((item) => selected.includes(item.value)) },
]
const style = height ? `--tblr-transfer-list-height: ${height}` : undefined
---
<div class:list={['transfer', className]} data-bs-toggle="transfer" data-bs-name={name} data-bs-counter-template={counterTemplate} style={style} {...rest}>
{
sides.map(({ side, title, items: rows }, index) => (
<>
<div class="card transfer-panel" data-bs-transfer-panel={side}>
<div class="card-header">
{selectAll ? (
<label class="form-check mb-0">
<input type="checkbox" class="form-check-input" data-bs-transfer-select-all />
<span class="form-check-label fw-medium">{title}</span>
</label>
) : (
<h3 class="card-title">{title}</h3>
)}
<span class="transfer-counter" data-bs-transfer-counter />
</div>
{search && (
<div class="card-body border-bottom py-2">
<input type="search" class="form-control form-control-sm" placeholder={searchPlaceholder} aria-label={`Search in ${title}`} data-bs-transfer-search />
</div>
)}
<div class="list-group list-group-flush transfer-list" aria-label={title} data-bs-transfer-list>
{rows.map((item) => (
<div class="list-group-item transfer-item" data-value={item.value} aria-disabled={item.disabled ? 'true' : undefined}>
<input type="checkbox" class="form-check-input" />
<span>{item.label}</span>
</div>
))}
</div>
</div>
{index === 0 && (
<div class="transfer-actions">
<button type="button" class="btn btn-icon" aria-label="Move selected to the right" data-bs-transfer-action="add">
<Icon name="chevron-right" />
</button>
<button type="button" class="btn btn-icon" aria-label="Move selected to the left" data-bs-transfer-action="remove">
<Icon name="chevron-left" />
</button>
<button type="button" class="btn btn-icon" aria-label="Move all to the right" data-bs-transfer-action="add-all">
<Icon name="chevrons-right" />
</button>
<button type="button" class="btn btn-icon" aria-label="Move all to the left" data-bs-transfer-action="remove-all">
<Icon name="chevrons-left" />
</button>
{reorder && (
<>
<button type="button" class="btn btn-icon" aria-label="Move selected up" data-bs-transfer-action="up">
<Icon name="chevron-up" />
</button>
<button type="button" class="btn btn-icon" aria-label="Move selected down" data-bs-transfer-action="down">
<Icon name="chevron-down" />
</button>
</>
)}
</div>
)}
</>
))
}
</div>