mirror of
https://github.com/tabler/tabler.git
synced 2026-10-13 13:47:00 +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,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.
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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. |
|
||||
@@ -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><select multiple></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>
|
||||
@@ -354,6 +354,10 @@
|
||||
"title": "Tracking",
|
||||
"url": "/ui/components/tracking"
|
||||
},
|
||||
{
|
||||
"title": "Transfer",
|
||||
"url": "/ui/components/transfer"
|
||||
},
|
||||
{
|
||||
"title": "Trending",
|
||||
"url": "/ui/components/trending"
|
||||
|
||||
@@ -294,6 +294,11 @@
|
||||
"title": "Tracking",
|
||||
"url": "tracking.html"
|
||||
},
|
||||
"transfer": {
|
||||
"url": "transfer.html",
|
||||
"title": "Transfer",
|
||||
"badge": "New"
|
||||
},
|
||||
"typography": {
|
||||
"title": "Typography",
|
||||
"url": "typography.html"
|
||||
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user