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

+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;
}
}
}