From efd46f1c792921fe3e29dc2db304e678353b7a6d Mon Sep 17 00:00:00 2001 From: Bartek Date: Thu, 8 Oct 2026 18:24:27 +0200 Subject: [PATCH] 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. --- .changeset/transfer.md | 7 + core/js/src/transfer.ts | 630 ++++++++++++++++++++++++ core/js/tabler.ts | 2 + core/js/tests/unit/transfer.spec.ts | 235 +++++++++ core/package.json | 4 +- core/scss/_core.scss | 1 + core/scss/_variables.scss | 10 + core/scss/ui/_transfer.scss | 102 ++++ docs/content/ui/components/transfer.mdx | 174 +++++++ preview/pages/transfer.astro | 53 ++ shared/data/docs.json | 4 + shared/data/menu.json | 5 + shared/ui/Transfer.astro | 107 ++++ 13 files changed, 1332 insertions(+), 2 deletions(-) create mode 100644 .changeset/transfer.md create mode 100644 core/js/src/transfer.ts create mode 100644 core/js/tests/unit/transfer.spec.ts create mode 100644 core/scss/ui/_transfer.scss create mode 100644 docs/content/ui/components/transfer.mdx create mode 100644 preview/pages/transfer.astro create mode 100644 shared/ui/Transfer.astro diff --git a/.changeset/transfer.md b/.changeset/transfer.md new file mode 100644 index 000000000..dcdffb857 --- /dev/null +++ b/.changeset/transfer.md @@ -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 `` the plugin renders when the markup has none; `null` renders nothing */ + name: string | null +} + +type ComponentConfigInput = Partial & Record + +/** + * 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 = { + 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 + * `` 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 = { + [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 diff --git a/core/js/tabler.ts b/core/js/tabler.ts index b9ae8013c..b52d2af05 100644 --- a/core/js/tabler.ts +++ b/core/js/tabler.ts @@ -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' diff --git a/core/js/tests/unit/transfer.spec.ts b/core/js/tests/unit/transfer.spec.ts new file mode 100644 index 000000000..d8eccbcb5 --- /dev/null +++ b/core/js/tests/unit/transfer.spec.ts @@ -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 => `
Item ${value}
` + +const markup = (): string => + [ + '
', + '
', + '
', + ' ', + `
${item('a')}${item('b')}${item('c')}${item('d', 'aria-disabled="true"')}
`, + '
', + '
', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + '
', + '
', + '
', + `
${item('e')}
`, + '
', + '
', + ].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('[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('[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') + }) + }) +}) diff --git a/core/package.json b/core/package.json index de35b5b73..a7c2f5e6c 100644 --- a/core/package.json +++ b/core/package.json @@ -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", diff --git a/core/scss/_core.scss b/core/scss/_core.scss index 6218ad90e..416415c9f 100644 --- a/core/scss/_core.scss +++ b/core/scss/_core.scss @@ -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'; diff --git a/core/scss/_variables.scss b/core/scss/_variables.scss index 1ab7ebada..45ddcf415 100644 --- a/core/scss/_variables.scss +++ b/core/scss/_variables.scss @@ -1235,6 +1235,16 @@ $stepper-icon-decrement: url("data:image/svg+xml,") !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; diff --git a/core/scss/ui/_transfer.scss b/core/scss/ui/_transfer.scss new file mode 100644 index 000000000..89b42cba6 --- /dev/null +++ b/core/scss/ui/_transfer.scss @@ -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; + } + } +} diff --git a/docs/content/ui/components/transfer.mdx b/docs/content/ui/components/transfer.mdx new file mode 100644 index 000000000..8e7232ba0 --- /dev/null +++ b/docs/content/ui/components/transfer.mdx @@ -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 ` + Available + + + +
+
+ + Ada Lovelace +
+
+ +
+ +
+
…
+ +``` + +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. + + + + + +### 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 `` 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. ↑, ↓, Home and End move the focus, Space and Enter check a row, Ctrl + A 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 `` 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 ` + {title} + + ) : ( +

{title}

+ )} + + + {search && ( +
+ +
+ )} +
+ {rows.map((item) => ( +
+ + {item.label} +
+ ))} +
+ + {index === 0 && ( +
+ + + + + {reorder && ( + <> + + + + )} +
+ )} + + )) + } +