mirror of
https://github.com/Choices-js/Choices.git
synced 2026-03-14 14:45:47 +01:00
refactor(input): replace Intl.Segmenter with DOM-based width measurement
Remove isWideChar() and getStringWidth() entirely. Instead, measure text width by inserting a hidden <span> that inherits the input element's CSS classes, setting innerText, and reading clientWidth. Dividing by the span's own 1ch pixel value converts the measurement back into ch units. This approach correctly handles CJK, Hangul, fullwidth forms, emoji, and any font without hard-coded code-point ranges or reliance on Intl.Segmenter. Remove unit tests for setWidth() that depended on jsdom layout measurement (jsdom has no layout engine). Width behaviour is verified by e2e tests. Addresses Xon's review feedback on PR #1384.
This commit is contained in:
parent
13408b1078
commit
e467bc0de0
2 changed files with 37 additions and 107 deletions
|
|
@ -1,57 +1,6 @@
|
|||
import { ClassNames } from '../interfaces/class-names';
|
||||
import { PassedElementType, PassedElementTypes } from '../interfaces/passed-element-type';
|
||||
|
||||
/**
|
||||
* Returns true for Unicode code points that render as double-width
|
||||
* (CJK, Hangul, fullwidth forms, etc.) relative to the CSS `ch` unit.
|
||||
*/
|
||||
function isWideChar(code: number): boolean {
|
||||
/* eslint-disable no-mixed-operators */
|
||||
return (
|
||||
(code >= 0x1100 && code <= 0x115f) || // Hangul Jamo
|
||||
code === 0x2329 ||
|
||||
code === 0x232a ||
|
||||
(code >= 0x2e80 && code <= 0x303e) || // CJK Radicals, Kangxi, CJK Symbols
|
||||
(code >= 0x3041 && code <= 0x33bf) || // Hiragana, Katakana, Bopomofo, CJK Compat
|
||||
(code >= 0x3400 && code <= 0x4dbf) || // CJK Extension A
|
||||
(code >= 0x4e00 && code <= 0x9fff) || // CJK Unified Ideographs
|
||||
(code >= 0xa000 && code <= 0xa4cf) || // Yi Syllables / Radicals
|
||||
(code >= 0xa960 && code <= 0xa97f) || // Hangul Jamo Extended-A
|
||||
(code >= 0xac00 && code <= 0xd7af) || // Hangul Syllables
|
||||
(code >= 0xf900 && code <= 0xfaff) || // CJK Compatibility Ideographs
|
||||
(code >= 0xfe10 && code <= 0xfe1f) || // Vertical Forms
|
||||
(code >= 0xfe30 && code <= 0xfe6f) || // CJK Compatibility Forms
|
||||
(code >= 0xff01 && code <= 0xff60) || // Fullwidth Latin / Punctuation
|
||||
(code >= 0xffe0 && code <= 0xffe6) || // Fullwidth Signs
|
||||
(code >= 0x1b000 && code <= 0x1b001) || // Kana Supplement
|
||||
(code >= 0x20000 && code <= 0x2fffd) || // CJK Extension B–D
|
||||
(code >= 0x30000 && code <= 0x3fffd) // CJK Extension E+
|
||||
);
|
||||
/* eslint-enable no-mixed-operators */
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the display width of a string in `ch` units.
|
||||
* Uses `Intl.Segmenter` to split grapheme clusters, then counts wide
|
||||
* characters (CJK, Hangul, fullwidth forms, etc.) as 2 and all others as 1.
|
||||
*
|
||||
* Falls back to `str.length` on environments without `Intl.Segmenter`.
|
||||
* For accurate CJK width in those environments, add a polyfill:
|
||||
* - https://github.com/cometkim/unicode-segmenter
|
||||
* - https://github.com/surferseo/intl-segmenter-polyfill
|
||||
*/
|
||||
function getStringWidth(str: string): number {
|
||||
// @ts-expect-error choices.js targets ES2020, but Intl.Segmenter is defined in ES2022
|
||||
if (typeof Intl !== 'undefined' && typeof Intl.Segmenter === 'function') {
|
||||
// @ts-expect-error choices.js targets ES2020, but Intl.Segmenter is defined in ES2022
|
||||
return [...new Intl.Segmenter().segment(str)].reduce(
|
||||
(width, { segment }) => width + (isWideChar(segment.codePointAt(0) ?? 0) ? 2 : 1),
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
return str.length;
|
||||
}
|
||||
import { addClassesToElement } from '../lib/utils';
|
||||
|
||||
export default class Input {
|
||||
element: HTMLInputElement;
|
||||
|
|
@ -158,17 +107,45 @@ export default class Input {
|
|||
}
|
||||
|
||||
/**
|
||||
* Set the correct input width based on placeholder
|
||||
* value or input value
|
||||
* Set the correct input width based on placeholder value or input value.
|
||||
* Renders text into a hidden off-screen span that inherits the input's
|
||||
* CSS classes and measures its pixel width, then converts to `ch` units.
|
||||
* This correctly handles CJK, Hangul, fullwidth forms, emoji, and any
|
||||
* font — no hard-coded code-point ranges required.
|
||||
*/
|
||||
setWidth(): void {
|
||||
// Resize input to contents or placeholder.
|
||||
// Uses getStringWidth() instead of .length so that wide characters
|
||||
// (CJK, Hangul, fullwidth forms, etc.) are counted as 2ch rather than 1ch,
|
||||
// preventing placeholder text truncation for languages like Japanese.
|
||||
const { element } = this;
|
||||
element.style.minWidth = `${getStringWidth(element.placeholder) + 1}ch`;
|
||||
element.style.width = `${getStringWidth(element.value) + 1}ch`;
|
||||
const { value, placeholder } = element;
|
||||
let minWidth = 0;
|
||||
let width = 0;
|
||||
|
||||
if (value || placeholder) {
|
||||
const e = document.createElement('span');
|
||||
e.style.position = 'absolute';
|
||||
e.style.visibility = 'hidden';
|
||||
e.style.whiteSpace = 'pre';
|
||||
e.style.height = 'auto';
|
||||
e.style.width = 'auto';
|
||||
e.style.minWidth = '1ch';
|
||||
addClassesToElement(e, Array.from(element.classList));
|
||||
element.after(e);
|
||||
const chInPx = e.clientWidth;
|
||||
|
||||
if (placeholder) {
|
||||
e.innerText = placeholder;
|
||||
minWidth = e.clientWidth / chInPx;
|
||||
}
|
||||
|
||||
if (value) {
|
||||
e.innerText = value;
|
||||
width = e.clientWidth / chInPx;
|
||||
}
|
||||
|
||||
e.remove();
|
||||
}
|
||||
|
||||
element.style.minWidth = `${Math.ceil(minWidth) + 1}ch`;
|
||||
element.style.width = `${Math.ceil(width) + 1}ch`;
|
||||
}
|
||||
|
||||
setActiveDescendant(activeDescendantID: string): void {
|
||||
|
|
|
|||
|
|
@ -261,53 +261,6 @@ describe('components/input', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('setWidth', () => {
|
||||
it('sets the width of the element based on input value and placeholder', () => {
|
||||
instance.placeholder = 'This is a placeholder';
|
||||
instance.element.value = 'This is a value';
|
||||
expect(instance.element.style.width).to.not.equal('16ch');
|
||||
instance.setWidth();
|
||||
expect(instance.element.style.width).to.equal('16ch');
|
||||
expect(instance.element.style.minWidth).to.equal('22ch');
|
||||
});
|
||||
|
||||
describe('wide (CJK / fullwidth) characters', () => {
|
||||
it('counts each Japanese character as 2ch for placeholder min-width', () => {
|
||||
// 'エリアを選択してください' = 12 wide chars → 12×2 = 24ch → minWidth 25ch
|
||||
instance.placeholder = 'エリアを選択してください';
|
||||
instance.element.value = '';
|
||||
instance.setWidth();
|
||||
expect(instance.element.style.minWidth).to.equal('25ch');
|
||||
});
|
||||
|
||||
it('counts each Japanese character as 2ch for value width', () => {
|
||||
// 'テスト' = 3 wide chars → 3×2 = 6ch → width 7ch
|
||||
instance.placeholder = '';
|
||||
instance.element.value = 'テスト';
|
||||
instance.setWidth();
|
||||
expect(instance.element.style.width).to.equal('7ch');
|
||||
});
|
||||
|
||||
it('mixes ASCII and CJK characters correctly', () => {
|
||||
// 'abc' (3×1) + 'テ' (1×2) = 5ch → width 6ch
|
||||
instance.placeholder = '';
|
||||
instance.element.value = 'abcテ';
|
||||
instance.setWidth();
|
||||
expect(instance.element.style.width).to.equal('6ch');
|
||||
});
|
||||
|
||||
it('counts a combining character sequence as a single character', () => {
|
||||
// 'e\u0301' is e + combining acute accent = 1 visible glyph → 1ch → width 2ch
|
||||
// Intl.Segmenter treats this as one grapheme cluster; code-point fallback
|
||||
// would count it as two code points but both are non-wide so still 2ch.
|
||||
instance.placeholder = '';
|
||||
instance.element.value = 'e\u0301'; // é as two code points
|
||||
instance.setWidth();
|
||||
expect(instance.element.style.width).to.equal('2ch');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('placeholder setter', () => {
|
||||
it('sets value of element to passed placeholder', () => {
|
||||
const placeholder = 'test';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue