| import Chips from '../../src/chips.js' |
| import EventHandler from '../../src/dom/event-handler.js' |
| import { clearFixture, createEvent, getFixture } from '../helpers/fixture.js' |
| |
| describe('Chips', () => { |
| let fixtureEl |
| |
| const makeChips = (options, html = '<div class="chips"></div>') => { |
| fixtureEl.innerHTML = html |
| const chipsEl = fixtureEl.querySelector('.chips') |
| return { chipsEl, chips: new Chips(chipsEl, options) } |
| } |
| |
| const keydown = (target, key, modifiers = {}) => { |
| target.dispatchEvent(new KeyboardEvent('keydown', { |
| key, |
| bubbles: true, |
| cancelable: true, |
| ...modifiers |
| })) |
| } |
| |
| beforeAll(() => { |
| fixtureEl = getFixture() |
| }) |
| |
| afterEach(() => { |
| clearFixture() |
| }) |
| |
| describe('static properties', () => { |
| it('should expose version, name, default and default type', () => { |
| expect(Chips.VERSION).toEqual(jasmine.any(String)) |
| expect(Chips.NAME).toEqual('chips') |
| expect(Chips.Default).toEqual(jasmine.any(Object)) |
| expect(Chips.DefaultType).toEqual(jasmine.any(Object)) |
| }) |
| }) |
| |
| describe('constructor', () => { |
| it('should create a ghost input when none is present', () => { |
| const { chipsEl } = makeChips() |
| |
| expect(chipsEl.querySelector('.form-ghost')).not.toBeNull() |
| }) |
| |
| it('should reuse an existing ghost input and set the placeholder from config', () => { |
| const { chipsEl } = makeChips( |
| { placeholder: 'Add tag' }, |
| '<div class="chips"><input class="form-ghost" type="text"></div>' |
| ) |
| |
| expect(chipsEl.querySelectorAll('.form-ghost').length).toEqual(1) |
| }) |
| |
| it('should initialize from existing chips in the markup', () => { |
| const { chips, chipsEl } = makeChips( |
| {}, |
| '<div class="chips"><span class="chip" data-bs-chip-value="alpha">alpha</span></div>' |
| ) |
| |
| expect(chips.getValues()).toEqual(['alpha']) |
| // Existing chips get a dismiss button and become focusable |
| expect(chipsEl.querySelector('.chip').getAttribute('tabindex')).toEqual('0') |
| expect(chipsEl.querySelector('.chip-dismiss')).not.toBeNull() |
| }) |
| |
| it('should derive a chip value from text content when no data attribute is set', () => { |
| const { chips } = makeChips( |
| {}, |
| '<div class="chips"><span class="chip">beta</span></div>' |
| ) |
| |
| expect(chips.getValues()).toEqual(['beta']) |
| }) |
| |
| it('should derive a chip value from text while ignoring an existing dismiss button', () => { |
| const { chips } = makeChips( |
| {}, |
| '<div class="chips"><span class="chip">gamma<button class="chip-dismiss">x</button></span></div>' |
| ) |
| |
| expect(chips.getValues()).toEqual(['gamma']) |
| }) |
| }) |
| |
| describe('add / getValues', () => { |
| it('should add a chip and reflect it in the DOM and values', () => { |
| const { chips, chipsEl } = makeChips() |
| |
| chips.add('alpha') |
| |
| expect(chips.getValues()).toEqual(['alpha']) |
| expect(chipsEl.querySelectorAll('.chip').length).toEqual(1) |
| }) |
| |
| it('should ignore empty values', () => { |
| const { chips } = makeChips() |
| |
| expect(chips.add(' ')).toBeNull() |
| expect(chips.getValues()).toEqual([]) |
| }) |
| |
| it('should reject duplicates when allowDuplicates is false', () => { |
| const { chips, chipsEl } = makeChips() |
| |
| chips.add('alpha') |
| chips.add('alpha') |
| |
| expect(chips.getValues()).toEqual(['alpha']) |
| expect(chipsEl.querySelectorAll('.chip').length).toEqual(1) |
| }) |
| |
| it('should allow duplicates when allowDuplicates is true', () => { |
| const { chips, chipsEl } = makeChips({ allowDuplicates: true }) |
| |
| chips.add('alpha') |
| chips.add('alpha') |
| |
| expect(chips.getValues()).toEqual(['alpha', 'alpha']) |
| expect(chipsEl.querySelectorAll('.chip').length).toEqual(2) |
| }) |
| |
| it('should enforce the maxChips limit', () => { |
| const { chips } = makeChips({ maxChips: 2 }) |
| |
| chips.add('a') |
| chips.add('b') |
| |
| expect(chips.add('c')).toBeNull() |
| expect(chips.getValues()).toEqual(['a', 'b']) |
| }) |
| |
| it('should not add a chip when the add event is prevented', () => { |
| const { chips, chipsEl } = makeChips() |
| |
| EventHandler.on(chipsEl, 'add.bs.chips', event => event.preventDefault()) |
| |
| expect(chips.add('alpha')).toBeNull() |
| expect(chips.getValues()).toEqual([]) |
| }) |
| |
| it('should fire the add event with the value and related target', () => { |
| const { chips, chipsEl } = makeChips() |
| const spy = jasmine.createSpy('add') |
| |
| EventHandler.on(chipsEl, 'add.bs.chips', spy) |
| chips.add('alpha') |
| |
| expect(spy).toHaveBeenCalled() |
| expect(spy.calls.mostRecent().args[0].value).toEqual('alpha') |
| }) |
| |
| it('should not create a dismiss button when dismissible is false', () => { |
| const { chips, chipsEl } = makeChips({ dismissible: false }) |
| chips.add('alpha') |
| |
| expect(chipsEl.querySelector('.chip-dismiss')).toBeNull() |
| }) |
| |
| it('should keep the default dismiss SVG after sanitization', () => { |
| const { chips, chipsEl } = makeChips() |
| chips.add('alpha') |
| |
| const dismiss = chipsEl.querySelector('.chip-dismiss') |
| expect(dismiss.querySelector('svg')).not.toBeNull() |
| expect(dismiss.querySelector('line')).not.toBeNull() |
| }) |
| |
| it('should sanitize dismissIcon HTML before inserting it', () => { |
| const { chips, chipsEl } = makeChips({ |
| dismissIcon: '<img src="x" onerror="window.__chipsXss=1"><svg class="safe-icon" viewBox="0 0 16 16"><circle cx="8" cy="8" r="4"/></svg>' |
| }) |
| chips.add('alpha') |
| |
| const dismiss = chipsEl.querySelector('.chip-dismiss') |
| expect(dismiss.querySelector('img')).toBeNull() |
| expect(dismiss.innerHTML).not.toMatch(/onerror/i) |
| expect(dismiss.querySelector('svg.safe-icon')).not.toBeNull() |
| expect(window.__chipsXss).toBeUndefined() |
| }) |
| |
| it('should sanitize dismissIcon supplied via data attributes', () => { |
| const { chips, chipsEl } = makeChips( |
| null, |
| '<div class="chips" data-bs-dismiss-icon=\'<img src=x onerror="window.__chipsDataXss=1"><span class="icon-ok">x</span>\'></div>' |
| ) |
| chips.add('alpha') |
| |
| const dismiss = chipsEl.querySelector('.chip-dismiss') |
| expect(dismiss.querySelector('img')).toBeNull() |
| expect(dismiss.innerHTML).not.toMatch(/onerror/i) |
| expect(dismiss.querySelector('.icon-ok')).not.toBeNull() |
| expect(window.__chipsDataXss).toBeUndefined() |
| }) |
| }) |
| |
| describe('remove', () => { |
| it('should remove a single chip and keep values in sync with the DOM', () => { |
| const { chips, chipsEl } = makeChips() |
| |
| chips.add('alpha') |
| chips.add('beta') |
| |
| expect(chips.remove('alpha')).toBeTrue() |
| expect(chips.getValues()).toEqual(['beta']) |
| expect(chipsEl.querySelectorAll('.chip').length).toEqual(1) |
| }) |
| |
| it('should only remove one entry per duplicate value, keeping values and DOM in sync', () => { |
| const { chips, chipsEl } = makeChips({ allowDuplicates: true }) |
| |
| chips.add('alpha') |
| chips.add('alpha') |
| chips.add('beta') |
| |
| const firstChip = chipsEl.querySelector('.chip') |
| expect(chips.remove(firstChip)).toBeTrue() |
| |
| // One 'alpha' entry must remain — both state and DOM stay aligned |
| expect(chips.getValues()).toEqual(['alpha', 'beta']) |
| expect(chipsEl.querySelectorAll('.chip').length).toEqual(2) |
| }) |
| |
| it('should keep values in rendered order when a later duplicate is removed', () => { |
| const { chips, chipsEl } = makeChips({ allowDuplicates: true }) |
| |
| chips.add('a') |
| chips.add('b') |
| chips.add('a') // DOM order: a, b, a |
| |
| // Remove the LAST 'a' chip element (not the first) |
| const chipElements = chipsEl.querySelectorAll('.chip') |
| expect(chips.remove(chipElements[2])).toBeTrue() |
| |
| // Values must match the rendered order a, b — not the mis-ordered b, a |
| expect(chips.getValues()).toEqual(['a', 'b']) |
| }) |
| |
| it('should return false when the value is not present', () => { |
| const { chips } = makeChips() |
| |
| expect(chips.remove('missing')).toBeFalse() |
| }) |
| |
| it('should not remove a chip when the remove event is prevented', () => { |
| const { chips, chipsEl } = makeChips() |
| chips.add('alpha') |
| |
| EventHandler.on(chipsEl, 'remove.bs.chips', event => event.preventDefault()) |
| |
| expect(chips.remove('alpha')).toBeFalse() |
| expect(chips.getValues()).toEqual(['alpha']) |
| }) |
| |
| it('should clear a removed chip from the current selection', () => { |
| const { chips } = makeChips() |
| const chip = chips.add('alpha') |
| |
| chips.selectChip(chip) |
| expect(chips.getSelectedValues()).toEqual(['alpha']) |
| |
| chips.remove(chip) |
| expect(chips.getSelectedValues()).toEqual([]) |
| }) |
| |
| it('should remove a chip when its dismiss button is clicked', () => { |
| const { chips, chipsEl } = makeChips() |
| chips.add('alpha') |
| |
| chipsEl.querySelector('.chip-dismiss').click() |
| |
| expect(chips.getValues()).toEqual([]) |
| }) |
| }) |
| |
| describe('selection', () => { |
| it('should select a single chip', () => { |
| const { chips } = makeChips() |
| const chip = chips.add('alpha') |
| |
| chips.selectChip(chip) |
| |
| expect(chip).toHaveClass('active') |
| expect(chips.getSelectedValues()).toEqual(['alpha']) |
| }) |
| |
| it('should toggle selection when addToSelection is set', () => { |
| const { chips } = makeChips() |
| const chip = chips.add('alpha') |
| |
| chips.selectChip(chip, { addToSelection: true }) |
| expect(chips.getSelectedValues()).toEqual(['alpha']) |
| |
| chips.selectChip(chip, { addToSelection: true }) |
| expect(chips.getSelectedValues()).toEqual([]) |
| }) |
| |
| it('should range-select from the anchor chip', () => { |
| const { chips } = makeChips() |
| const first = chips.add('a') |
| chips.add('b') |
| const third = chips.add('c') |
| |
| chips.selectChip(first) |
| chips.selectChip(third, { rangeSelect: true }) |
| |
| expect(chips.getSelectedValues()).toEqual(['a', 'b', 'c']) |
| }) |
| |
| it('should ignore selection requests for foreign elements', () => { |
| const { chips } = makeChips() |
| chips.add('a') |
| |
| chips.selectChip(document.createElement('span')) |
| |
| expect(chips.getSelectedValues()).toEqual([]) |
| }) |
| |
| it('should clear the selection', () => { |
| const { chips } = makeChips() |
| const chip = chips.add('alpha') |
| chips.selectChip(chip) |
| |
| chips.clearSelection() |
| |
| expect(chip).not.toHaveClass('active') |
| expect(chips.getSelectedValues()).toEqual([]) |
| }) |
| |
| it('should remove every selected chip with removeSelected', () => { |
| const { chips } = makeChips() |
| const a = chips.add('a') |
| const b = chips.add('b') |
| chips.selectChip(a) |
| chips.selectChip(b, { addToSelection: true }) |
| |
| chips.removeSelected() |
| |
| expect(chips.getValues()).toEqual([]) |
| }) |
| |
| it('should select a chip and focus it on click', () => { |
| const { chips, chipsEl } = makeChips() |
| chips.add('alpha') |
| |
| chipsEl.querySelector('.chip').click() |
| |
| expect(chips.getSelectedValues()).toEqual(['alpha']) |
| }) |
| |
| it('should clear the selection when clicking the container background', () => { |
| const { chips, chipsEl } = makeChips() |
| const chip = chips.add('alpha') |
| chips.selectChip(chip) |
| |
| chipsEl.click() |
| |
| expect(chips.getSelectedValues()).toEqual([]) |
| }) |
| }) |
| |
| describe('clear', () => { |
| it('should remove all chips and reset state', () => { |
| const { chips, chipsEl } = makeChips() |
| chips.add('a') |
| chips.add('b') |
| |
| chips.clear() |
| |
| expect(chips.getValues()).toEqual([]) |
| expect(chipsEl.querySelectorAll('.chip').length).toEqual(0) |
| }) |
| }) |
| |
| describe('input handling', () => { |
| it('should split input on the separator and keep the trailing part', () => { |
| const { chips } = makeChips() |
| const input = chips._input |
| |
| input.value = 'a,b,c' |
| input.dispatchEvent(createEvent('input', { bubbles: true })) |
| |
| expect(chips.getValues()).toEqual(['a', 'b']) |
| expect(input.value).toEqual('c') |
| }) |
| |
| it('should create a chip from the input on Enter', () => { |
| const { chips } = makeChips() |
| const input = chips._input |
| |
| input.value = 'alpha' |
| keydown(input, 'Enter') |
| |
| expect(chips.getValues()).toEqual(['alpha']) |
| expect(input.value).toEqual('') |
| }) |
| |
| it('should select the last chip on Backspace with an empty input', () => { |
| const { chips } = makeChips() |
| chips.add('alpha') |
| const input = chips._input |
| |
| input.value = '' |
| keydown(input, 'Backspace') |
| |
| expect(chips.getSelectedValues()).toEqual(['alpha']) |
| }) |
| |
| it('should clear input and blur on Escape', () => { |
| const { chips } = makeChips() |
| const input = chips._input |
| input.value = 'typing' |
| |
| keydown(input, 'Escape') |
| |
| expect(input.value).toEqual('') |
| }) |
| |
| it('should select the last chip on ArrowLeft at the start of an empty input', () => { |
| const { chips } = makeChips() |
| chips.add('alpha') |
| const input = chips._input |
| input.value = '' |
| |
| keydown(input, 'ArrowLeft') |
| |
| expect(chips.getSelectedValues()).toEqual(['alpha']) |
| }) |
| |
| it('should add the last chip to the selection on shift+ArrowLeft from the input', () => { |
| const { chips } = makeChips() |
| chips.add('alpha') |
| const input = chips._input |
| input.value = '' |
| |
| keydown(input, 'ArrowLeft', { shiftKey: true }) |
| |
| expect(chips.getSelectedValues()).toEqual(['alpha']) |
| }) |
| |
| it('should create a chip on blur when createOnBlur is enabled', () => { |
| const { chips } = makeChips() |
| const input = chips._input |
| input.value = 'alpha' |
| |
| input.dispatchEvent(new FocusEvent('blur', { bubbles: true })) |
| |
| expect(chips.getValues()).toEqual(['alpha']) |
| }) |
| |
| it('should not create a chip on blur when createOnBlur is disabled', () => { |
| const { chips } = makeChips({ createOnBlur: false }) |
| const input = chips._input |
| input.value = 'alpha' |
| |
| input.dispatchEvent(new FocusEvent('blur', { bubbles: true })) |
| |
| expect(chips.getValues()).toEqual([]) |
| }) |
| |
| it('should not create a chip on blur when focus moves to a chip', () => { |
| const { chips, chipsEl } = makeChips() |
| chips.add('existing') |
| const input = chips._input |
| input.value = 'pending' |
| |
| input.dispatchEvent(new FocusEvent('blur', { |
| bubbles: true, |
| relatedTarget: chipsEl.querySelector('.chip') |
| })) |
| |
| expect(chips.getValues()).toEqual(['existing']) |
| }) |
| |
| it('should ignore pastes when the separator is null', () => { |
| const { chips } = makeChips({ separator: null }) |
| const input = chips._input |
| |
| const pasteEvent = createEvent('paste', { bubbles: true, cancelable: true }) |
| pasteEvent.clipboardData = { getData: () => 'a,b,c' } |
| input.dispatchEvent(pasteEvent) |
| |
| expect(chips.getValues()).toEqual([]) |
| }) |
| |
| it('should add chips from pasted text containing the separator', () => { |
| const { chips } = makeChips() |
| const input = chips._input |
| |
| const pasteEvent = createEvent('paste', { bubbles: true, cancelable: true }) |
| pasteEvent.clipboardData = { getData: () => 'a,b,c' } |
| input.dispatchEvent(pasteEvent) |
| |
| expect(chips.getValues()).toEqual(['a', 'b', 'c']) |
| }) |
| |
| it('should ignore pasted text without the separator', () => { |
| const { chips } = makeChips() |
| const input = chips._input |
| |
| const pasteEvent = createEvent('paste', { bubbles: true, cancelable: true }) |
| pasteEvent.clipboardData = { getData: () => 'single' } |
| input.dispatchEvent(pasteEvent) |
| |
| expect(chips.getValues()).toEqual([]) |
| }) |
| }) |
| |
| describe('chip keyboard navigation', () => { |
| it('should delete the selected chip on Backspace', () => { |
| const { chips, chipsEl } = makeChips() |
| const chip = chips.add('alpha') |
| chips.selectChip(chip) |
| |
| keydown(chipsEl.querySelector('.chip'), 'Backspace') |
| |
| expect(chips.getValues()).toEqual([]) |
| }) |
| |
| it('should move selection left with the arrow key', () => { |
| const { chips, chipsEl } = makeChips() |
| chips.add('a') |
| const second = chips.add('b') |
| chips.selectChip(second) |
| |
| keydown(chipsEl.querySelectorAll('.chip')[1], 'ArrowLeft') |
| |
| expect(chips.getSelectedValues()).toEqual(['a']) |
| }) |
| |
| it('should move selection right with the arrow key', () => { |
| const { chips, chipsEl } = makeChips() |
| const first = chips.add('a') |
| chips.add('b') |
| chips.selectChip(first) |
| |
| keydown(chipsEl.querySelectorAll('.chip')[0], 'ArrowRight') |
| |
| expect(chips.getSelectedValues()).toEqual(['b']) |
| }) |
| |
| it('should extend the selection with shift+arrow', () => { |
| const { chips, chipsEl } = makeChips() |
| const first = chips.add('a') |
| chips.add('b') |
| chips.selectChip(first) |
| |
| keydown(chipsEl.querySelectorAll('.chip')[0], 'ArrowRight', { shiftKey: true }) |
| |
| expect(chips.getSelectedValues().sort()).toEqual(['a', 'b']) |
| }) |
| |
| it('should keep focus on the first chip when arrowing left past the start', () => { |
| const { chips, chipsEl } = makeChips() |
| const first = chips.add('a') |
| chips.add('b') |
| chips.selectChip(first) |
| |
| keydown(chipsEl.querySelectorAll('.chip')[0], 'ArrowLeft') |
| |
| expect(chips.getSelectedValues()).toEqual(['a']) |
| }) |
| |
| it('should focus and keep the remaining chip after deleting one of several', () => { |
| const { chips, chipsEl } = makeChips() |
| chips.add('a') |
| chips.add('b') |
| const third = chips.add('c') |
| chips.selectChip(third) |
| |
| keydown(chipsEl.querySelectorAll('.chip')[2], 'Backspace') |
| |
| expect(chips.getValues()).toEqual(['a', 'b']) |
| }) |
| |
| it('should select all chips with modifier+a', () => { |
| const { chips, chipsEl } = makeChips() |
| chips.add('a') |
| const second = chips.add('b') |
| chips.selectChip(second) |
| |
| keydown(chipsEl.querySelectorAll('.chip')[1], 'a', { ctrlKey: true }) |
| |
| expect(chips.getSelectedValues().sort()).toEqual(['a', 'b']) |
| }) |
| |
| it('should jump to the first chip on Home', () => { |
| const { chips, chipsEl } = makeChips() |
| chips.add('a') |
| const second = chips.add('b') |
| chips.selectChip(second) |
| |
| keydown(chipsEl.querySelectorAll('.chip')[1], 'Home') |
| |
| expect(chips.getSelectedValues()).toEqual(['a']) |
| }) |
| |
| it('should range-select to the start on shift+Home', () => { |
| const { chips, chipsEl } = makeChips() |
| chips.add('a') |
| const second = chips.add('b') |
| chips.selectChip(second) |
| |
| keydown(chipsEl.querySelectorAll('.chip')[1], 'Home', { shiftKey: true }) |
| |
| expect(chips.getSelectedValues().sort()).toEqual(['a', 'b']) |
| }) |
| |
| it('should return focus to the input when navigating right past the last chip', () => { |
| const { chips, chipsEl } = makeChips() |
| const only = chips.add('a') |
| chips.selectChip(only) |
| |
| keydown(chipsEl.querySelector('.chip'), 'ArrowRight') |
| |
| expect(chips.getSelectedValues()).toEqual([]) |
| }) |
| |
| it('should clear selection and focus the input on End', () => { |
| const { chips, chipsEl } = makeChips() |
| const chip = chips.add('a') |
| chips.selectChip(chip) |
| |
| keydown(chipsEl.querySelector('.chip'), 'End') |
| |
| expect(chips.getSelectedValues()).toEqual([]) |
| }) |
| |
| it('should clear selection and focus the input on Escape', () => { |
| const { chips, chipsEl } = makeChips() |
| const chip = chips.add('a') |
| chips.selectChip(chip) |
| |
| keydown(chipsEl.querySelector('.chip'), 'Escape') |
| |
| expect(chips.getSelectedValues()).toEqual([]) |
| }) |
| |
| it('should do nothing on Backspace when no chip is selected', () => { |
| const { chips, chipsEl } = makeChips() |
| chips.add('a') |
| chips.clearSelection() |
| |
| keydown(chipsEl.querySelector('.chip'), 'Backspace') |
| |
| expect(chips.getValues()).toEqual(['a']) |
| }) |
| |
| it('should not select all on a plain "a" keypress without a modifier', () => { |
| const { chips, chipsEl } = makeChips() |
| chips.add('a') |
| const second = chips.add('b') |
| chips.selectChip(second) |
| |
| keydown(chipsEl.querySelectorAll('.chip')[1], 'a') |
| |
| expect(chips.getSelectedValues()).toEqual(['b']) |
| }) |
| }) |
| }) |