From 5ca0350e8ea5007295a708539243eee75751ffbb Mon Sep 17 00:00:00 2001 From: Xon <635541+Xon@users.noreply.github.com> Date: Thu, 10 Jul 2025 03:56:32 +0800 Subject: [PATCH] When resolving the remove item/label/icon, add a 3rd argument `item` argument. Update default remove item label to use (Fixes #1296) --- README.md | 6 ++-- src/scripts/choices.ts | 45 +++++++++++--------------- src/scripts/defaults.ts | 6 ++-- src/scripts/interfaces/event-choice.ts | 1 + src/scripts/interfaces/input-choice.ts | 3 +- src/scripts/interfaces/options.ts | 4 +-- src/scripts/interfaces/types.ts | 4 ++- src/scripts/lib/utils.ts | 31 +++++++++++++++--- src/scripts/templates.ts | 6 ++-- 9 files changed, 65 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 48bd2e76..d6944795 100644 --- a/README.md +++ b/README.md @@ -703,21 +703,23 @@ Return type must be safe to insert into HTML (ie use the 1st argument which is s ### removeItemIconText -**Type:** `String/Function` **Default:** `Remove item"` **Arguments:** `value`, `valueRaw` +**Type:** `String/Function` **Default:** `Remove item"` **Arguments:** `value`, `valueRaw`, `item` **Input types affected:** `text`, `select-one`, `select-multiple` **Usage:** The text/icon for the remove button. To access the item's value, pass a function with a `value` argument (see the **default config** [https://github.com/jshjohnson/Choices#setup] for an example), otherwise pass a string. +To access the item's label, use the 3rd argument. *Note*; this label is not escaped. Return type must be safe to insert into HTML (ie use the 1st argument which is sanitised) ### removeItemLabelText -**Type:** `String/Function` **Default:** `Remove item: ${value}"` **Arguments:** `value`, `valueRaw` +**Type:** `String/Function` **Default:** `Remove item: ${value}"` **Arguments:** `value`, `valueRaw`, `item` **Input types affected:** `text`, `select-one`, `select-multiple` **Usage:** The text for the remove button's aria label. To access the item's value, pass a function with a `value` argument (see the **default config** [https://github.com/jshjohnson/Choices#setup] for an example), otherwise pass a string. +To access the item's label, use the 3rd argument. *Note*; this label is not escaped. Return type must be safe to insert into HTML (ie use the 1st argument which is sanitised) diff --git a/src/scripts/choices.ts b/src/scripts/choices.ts index f3badbd2..eb44c816 100644 --- a/src/scripts/choices.ts +++ b/src/scripts/choices.ts @@ -13,6 +13,7 @@ import { escapeForTemplate, generateId, getAdjacentEl, + getChoiceForOutput, getClassNames, getClassNamesSelector, isScrolledIntoView, @@ -415,7 +416,7 @@ class Choices { this._store.dispatch(highlightItem(choice, true)); if (runEvent) { - this.passedElement.triggerEvent(EventType.highlightItem, this._getChoiceForOutput(choice)); + this.passedElement.triggerEvent(EventType.highlightItem, getChoiceForOutput(choice)); } return this; @@ -433,7 +434,7 @@ class Choices { this._store.dispatch(highlightItem(choice, false)); if (runEvent) { - this.passedElement.triggerEvent(EventType.unhighlightItem, this._getChoiceForOutput(choice)); + this.passedElement.triggerEvent(EventType.unhighlightItem, getChoiceForOutput(choice)); } return this; @@ -445,7 +446,7 @@ class Choices { if (!item.highlighted) { this._store.dispatch(highlightItem(item, true)); - this.passedElement.triggerEvent(EventType.highlightItem, this._getChoiceForOutput(item)); + this.passedElement.triggerEvent(EventType.highlightItem, getChoiceForOutput(item)); } }); }); @@ -459,7 +460,7 @@ class Choices { if (item.highlighted) { this._store.dispatch(highlightItem(item, false)); - this.passedElement.triggerEvent(EventType.highlightItem, this._getChoiceForOutput(item)); + this.passedElement.triggerEvent(EventType.highlightItem, getChoiceForOutput(item)); } }); }); @@ -545,7 +546,7 @@ class Choices { getValue(valueOnly?: B): EventChoiceValueType | EventChoiceValueType[] { const values = this._store.items.map((item) => { - return (valueOnly ? item.value : this._getChoiceForOutput(item)) as EventChoiceValueType; + return (valueOnly ? item.value : getChoiceForOutput(item)) as EventChoiceValueType; }); return this._isSelectOneElement || this.config.singleModeForMultiSelect ? values[0] : values; @@ -848,7 +849,7 @@ class Choices { this._searcher.reset(); if (choice.selected) { - this.passedElement.triggerEvent(EventType.removeItem, this._getChoiceForOutput(choice)); + this.passedElement.triggerEvent(EventType.removeItem, getChoiceForOutput(choice)); } return this; @@ -1178,23 +1179,12 @@ class Choices { } } + /** + * @deprecated Use utils.getChoiceForOutput + */ // eslint-disable-next-line class-methods-use-this _getChoiceForOutput(choice: ChoiceFull, keyCode?: number): EventChoice { - return { - id: choice.id, - highlighted: choice.highlighted, - labelClass: choice.labelClass, - labelDescription: choice.labelDescription, - customProperties: choice.customProperties, - disabled: choice.disabled, - active: choice.active, - label: choice.label, - placeholder: choice.placeholder, - value: choice.value, - groupValue: choice.group ? choice.group.label : undefined, - element: choice.element, - keyCode, - }; + return getChoiceForOutput(choice, keyCode); } _triggerChange(value): void { @@ -1423,7 +1413,7 @@ class Choices { if (canAddItem && typeof config.addItemFilter === 'function' && !config.addItemFilter(value)) { canAddItem = false; - notice = resolveNoticeFunction(config.customAddItemText, value); + notice = resolveNoticeFunction(config.customAddItemText, value, undefined); } if (canAddItem) { @@ -1437,13 +1427,13 @@ class Choices { } if (!config.duplicateItemsAllowed) { canAddItem = false; - notice = resolveNoticeFunction(config.uniqueItemText, value); + notice = resolveNoticeFunction(config.uniqueItemText, value, undefined); } } } if (canAddItem) { - notice = resolveNoticeFunction(config.addItemText, value); + notice = resolveNoticeFunction(config.addItemText, value, undefined); } if (notice) { @@ -2075,10 +2065,11 @@ class Choices { this._store.dispatch(addItem(item)); if (withEvents) { - this.passedElement.triggerEvent(EventType.addItem, this._getChoiceForOutput(item)); + const eventChoice = getChoiceForOutput(item); + this.passedElement.triggerEvent(EventType.addItem, eventChoice); if (userTriggered) { - this.passedElement.triggerEvent(EventType.choice, this._getChoiceForOutput(item)); + this.passedElement.triggerEvent(EventType.choice, eventChoice); } } } @@ -2094,7 +2085,7 @@ class Choices { this._clearNotice(); } - this.passedElement.triggerEvent(EventType.removeItem, this._getChoiceForOutput(item)); + this.passedElement.triggerEvent(EventType.removeItem, getChoiceForOutput(item)); } _addChoice(choice: ChoiceFull, withEvents: boolean = true, userTriggered = false): void { diff --git a/src/scripts/defaults.ts b/src/scripts/defaults.ts index 803a77bc..e243abe4 100644 --- a/src/scripts/defaults.ts +++ b/src/scripts/defaults.ts @@ -1,6 +1,7 @@ import { ClassNames } from './interfaces/class-names'; import { Options } from './interfaces/options'; -import { sortByAlpha } from './lib/utils'; +import { sanitise, sortByAlpha } from './lib/utils'; +import { EventChoice } from './interfaces'; export const DEFAULT_CLASSNAMES: ClassNames = { containerOuter: ['choices'], @@ -79,7 +80,8 @@ export const DEFAULT_CONFIG: Options = { customAddItemText: 'Only values matching specific conditions can be added', addItemText: (value: string) => `Press Enter to add "${value}"`, removeItemIconText: (): string => `Remove item`, - removeItemLabelText: (value: string): string => `Remove item: ${value}`, + removeItemLabelText: (value: string, _valueRaw: string, i?: EventChoice): string => + `Remove item: ${i ? sanitise(i.label) : value}`, maxItemText: (maxItemCount: number): string => `Only ${maxItemCount} values can be added`, valueComparer: (value1: string, value2: string): boolean => value1 === value2, fuseOptions: { diff --git a/src/scripts/interfaces/event-choice.ts b/src/scripts/interfaces/event-choice.ts index 9eb58bc8..6c532ba4 100644 --- a/src/scripts/interfaces/event-choice.ts +++ b/src/scripts/interfaces/event-choice.ts @@ -1,3 +1,4 @@ +// eslint-disable-next-line import/no-cycle import { InputChoice } from './input-choice'; export type EventChoiceValueType = B extends true ? string : EventChoice; diff --git a/src/scripts/interfaces/input-choice.ts b/src/scripts/interfaces/input-choice.ts index 012adeea..9740cb04 100644 --- a/src/scripts/interfaces/input-choice.ts +++ b/src/scripts/interfaces/input-choice.ts @@ -1,4 +1,5 @@ import { StringUntrusted } from './string-untrusted'; +// eslint-disable-next-line import { Types } from './types'; export interface InputChoice { @@ -13,5 +14,5 @@ export interface InputChoice { placeholder?: boolean; selected?: boolean; // eslint-disable-next-line @typescript-eslint/no-explicit-any - value: any; + value: any; // string; } diff --git a/src/scripts/interfaces/options.ts b/src/scripts/interfaces/options.ts index f65a58b8..a8dae4b5 100644 --- a/src/scripts/interfaces/options.ts +++ b/src/scripts/interfaces/options.ts @@ -200,7 +200,7 @@ export interface Options { * * @default * ``` - * (value, valueRaw) => `Remove item`; + * (value, valueRaw, item) => `Remove item`; * ``` */ removeItemIconText: string | Types.NoticeStringFunction; @@ -215,7 +215,7 @@ export interface Options { * * @default * ``` - * (value, valueRaw) => `Remove item: ${value}`; + * (value, valueRaw, item) => `Remove item: ${value}`; * ``` */ removeItemLabelText: string | Types.NoticeStringFunction; diff --git a/src/scripts/interfaces/types.ts b/src/scripts/interfaces/types.ts index 77a97c5e..11377ee4 100644 --- a/src/scripts/interfaces/types.ts +++ b/src/scripts/interfaces/types.ts @@ -1,12 +1,14 @@ import { StringUntrusted } from './string-untrusted'; import { StringPreEscaped } from './string-pre-escaped'; +// eslint-disable-next-line import/no-cycle +import { EventChoice } from './event-choice'; export namespace Types { export type StrToEl = (str: string) => HTMLElement | HTMLInputElement | HTMLOptionElement; export type EscapeForTemplateFn = (allowHTML: boolean, s: StringUntrusted | StringPreEscaped | string) => string; export type GetClassNamesFn = (s: string | Array) => string; export type StringFunction = () => string; - export type NoticeStringFunction = (value: string, valueRaw: string) => string; + export type NoticeStringFunction = (value: string, valueRaw: string, item?: EventChoice) => string; export type NoticeLimitFunction = (maxItemCount: number) => string; export type FilterFunction = (value: string) => boolean; export type ValueCompareFunction = (value1: string, value2: string) => boolean; diff --git a/src/scripts/lib/utils.ts b/src/scripts/lib/utils.ts index 29b3c423..b63de34b 100644 --- a/src/scripts/lib/utils.ts +++ b/src/scripts/lib/utils.ts @@ -4,6 +4,7 @@ import { StringPreEscaped } from '../interfaces/string-pre-escaped'; import { ChoiceFull } from '../interfaces/choice-full'; import { Types } from '../interfaces/types'; import { canUseDom } from '../interfaces/build-flags'; +import { EventChoice } from '../interfaces'; const getRandomNumber = (min: number, max: number): number => Math.floor(Math.random() * (max - min) + min); @@ -91,10 +92,6 @@ export const strToEl = ((): ((str: string) => Element) => { }; })(); -export const resolveNoticeFunction = (fn: Types.NoticeStringFunction | string, value: string): string => { - return typeof fn === 'function' ? fn(sanitise(value), value) : fn; -}; - export const resolveStringFunction = (fn: Types.StringFunction | string): string => { return typeof fn === 'function' ? fn() : fn; }; @@ -133,6 +130,32 @@ export const unwrapStringForEscaped = (s?: StringUntrusted | StringPreEscaped | return ''; }; +export const getChoiceForOutput = (choice: ChoiceFull, keyCode?: number): EventChoice => { + return { + id: choice.id, + highlighted: choice.highlighted, + labelClass: choice.labelClass, + labelDescription: choice.labelDescription, + customProperties: choice.customProperties, + disabled: choice.disabled, + active: choice.active, + label: choice.label, + placeholder: choice.placeholder, + value: choice.value, + groupValue: choice.group ? choice.group.label : undefined, + element: choice.element, + keyCode, + }; +}; + +export const resolveNoticeFunction = ( + fn: Types.NoticeStringFunction | string, + value: StringUntrusted | StringPreEscaped | string, + item?: EventChoice, +): string => { + return typeof fn === 'function' ? fn(sanitise(value), unwrapStringForRaw(value), item) : fn; +}; + export const escapeForTemplate = (allowHTML: boolean, s: StringUntrusted | StringPreEscaped | string): string => allowHTML ? unwrapStringForEscaped(s) : (sanitise(s) as string); diff --git a/src/scripts/templates.ts b/src/scripts/templates.ts index 84749fb0..04fd6a42 100644 --- a/src/scripts/templates.ts +++ b/src/scripts/templates.ts @@ -16,6 +16,7 @@ import { escapeForTemplate, addClassesToElement, removeClassesFromElement, + getChoiceForOutput, } from './lib/utils'; import { NoticeType, NoticeTypes, TemplateOptions, Templates as TemplatesInterface } from './interfaces/templates'; import { StringUntrusted } from './interfaces/string-untrusted'; @@ -189,9 +190,10 @@ const templates: TemplatesInterface = { const removeButton = document.createElement('button'); removeButton.type = 'button'; addClassesToElement(removeButton, button); - setElementHtml(removeButton, true, resolveNoticeFunction(removeItemIconText, choice.value)); + const eventChoice = getChoiceForOutput(choice); + setElementHtml(removeButton, true, resolveNoticeFunction(removeItemIconText, choice.value, eventChoice)); - const REMOVE_ITEM_LABEL = resolveNoticeFunction(removeItemLabelText, choice.value); + const REMOVE_ITEM_LABEL = resolveNoticeFunction(removeItemLabelText, choice.value, eventChoice); if (REMOVE_ITEM_LABEL) { removeButton.setAttribute('aria-label', REMOVE_ITEM_LABEL); }