mirror of
https://github.com/tabler/tabler.git
synced 2026-10-12 05:06:55 +04:00
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:
1 parent
c2a7cbf026
commit
efd46f1c79
13 files changed
+1332
-2
No files matched your search
@@ -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
|
||||
@@ -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'
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user