diff --git a/README.md b/README.md index 39f5d08..23ffe9e 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,9 @@ var Highlight = require('react-highlighter'); ## Props - `search`: The string of text (or Regular Expression) to highlight - `caseSensitive`: Determine whether string matching should be case-sensitive. Not applicable to regular expression searches. Defaults to `false` -- `matchElement`: HTML tag name to wrap around highlighted text. Defaults to `strong` +- `ignoreDiacritics`: Determine whether string matching should ignore diacritics. Defaults to `false` +- `diacriticsBlacklist`: These chars are treated like characters that don't have any diacritics. Not applicable ignoreDiacritics is `false`. Defaults to none +- `matchElement`: HTML tag name to wrap around highlighted text. Defaults to `mark` - `matchClass`: HTML class to wrap around highlighted text. Defaults to `highlight` - `matchStyle`: Custom style for the match element around highlighted text. diff --git a/lib/highlighter.js b/lib/highlighter.js index c5e5bec..cc4d3aa 100644 --- a/lib/highlighter.js +++ b/lib/highlighter.js @@ -5,6 +5,27 @@ var blacklist = require('blacklist'); var createReactClass = require('create-react-class'); var PropTypes = require('prop-types'); +function removeDiacritics(str, blacklist) { + if (!String.prototype.normalize) { + // Fall back to original string + return str; + } + + if (!blacklist) { + // No blacklist, just remove all + return str.normalize('NFD').replace(/[\u0300-\u036f]/g, ''); + } + else { + var blacklistChars = blacklist.split(''); + + // Remove all diacritics that are not a part of a blacklisted character + // First char cannot be a diacritic + return str.normalize('NFD').replace(/.[\u0300-\u036f]+/g, function(m) { + return blacklistChars.indexOf(m.normalize()) > -1 ? m.normalize() : m[0]; + }); + } +} + var Highlighter = createReactClass({ displayName: 'Highlighter', count: 0, @@ -16,6 +37,8 @@ var Highlighter = createReactClass({ RegExpPropType ]).isRequired, caseSensitive: PropTypes.bool, + ignoreDiacritics: PropTypes.bool, + diacriticsBlacklist: PropTypes.string, matchElement: PropTypes.string, matchClass: PropTypes.string, matchStyle: PropTypes.object @@ -26,6 +49,8 @@ var Highlighter = createReactClass({ this.props, 'search', 'caseSensitive', + 'ignoreDiacritics', + 'diacriticsBlacklist', 'matchElement', 'matchClass', 'matchStyle' @@ -92,6 +117,10 @@ var Highlighter = createReactClass({ search = escapeStringRegexp(search); } + if (this.props.ignoreDiacritics) { + search = removeDiacritics(search, this.props.diacriticsBlacklist); + } + return new RegExp(search, flags); }, @@ -131,16 +160,25 @@ var Highlighter = createReactClass({ */ highlightChildren: function(subject, search) { var children = []; - var matchElement = this.props.matchElement; var remaining = subject; while (remaining) { - if (!search.test(remaining)) { + var remainingCleaned = (this.props.ignoreDiacritics + ? removeDiacritics(remaining, this.props.diacriticsBlacklist) + : remaining + ); + + if (!search.test(remainingCleaned)) { children.push(this.renderPlain(remaining)); return children; } - var boundaries = this.getMatchBoundaries(remaining, search); + var boundaries = this.getMatchBoundaries(remainingCleaned, search); + + if (boundaries.first === 0 && boundaries.last === 0) { + // Regex zero-width match + return children; + } // Capture the string that leads up to a match... var nonMatch = remaining.slice(0, boundaries.first); @@ -151,7 +189,7 @@ var Highlighter = createReactClass({ // Now, capture the matching string... var match = remaining.slice(boundaries.first, boundaries.last); if (match) { - children.push(this.renderHighlight(match, matchElement)); + children.push(this.renderHighlight(match)); } // And if there's anything left over, recursively run this method again. @@ -196,7 +234,9 @@ var Highlighter = createReactClass({ Highlighter.defaultProps = { caseSensitive: false, - matchElement: 'strong', + ignoreDiacritics: false, + diacriticsBlacklist: '', + matchElement: 'mark', matchClass: 'highlight', matchStyle: {} }; diff --git a/test/testHighlighter.js b/test/testHighlighter.js index e79397f..cec213b 100644 --- a/test/testHighlighter.js +++ b/test/testHighlighter.js @@ -15,7 +15,7 @@ describe('Highlight element', function() { it('is what it says it is', function() { var element = React.createElement(Highlight, {search: 'world'}, 'Hello World'); var node = TestUtils.renderIntoDocument(element); - var matches = TestUtils.scryRenderedDOMComponentsWithTag(node, 'strong'); + var matches = TestUtils.scryRenderedDOMComponentsWithTag(node, 'mark'); expect(TestUtils.isElement(element)).to.be.true; expect(TestUtils.isElementOfType(element, Highlight)).to.be.true; @@ -57,28 +57,145 @@ describe('Highlight element', function() { it('should support custom style for matching element', function() { var element = React.createElement(Highlight, {search: 'Seek', matchStyle: { color: 'red' }}, 'Hide and Seek'); var node = TestUtils.renderIntoDocument(element); - var matches = TestUtils.scryRenderedDOMComponentsWithTag(node, 'strong'); + var matches = TestUtils.scryRenderedDOMComponentsWithTag(node, 'mark'); expect(matches[0].getAttribute('style')).to.eql('color: red;'); }); it('should support passing props to parent element', function() { var element = React.createElement(Highlight, {search: 'world', className: 'myHighlighter'}, 'Hello World'); var node = TestUtils.renderIntoDocument(element); - var matches = TestUtils.scryRenderedDOMComponentsWithTag(node, 'strong'); + var matches = TestUtils.scryRenderedDOMComponentsWithTag(node, 'mark'); expect(ReactDOM.findDOMNode(node).className).to.equal('myHighlighter'); expect(ReactDOM.findDOMNode(matches[0]).className).to.equal('highlight') }); + it('should support case sensitive searches', function() { + var element = React.createElement(Highlight, {search: 'TEST', caseSensitive: true}, 'test Test TeSt TEST'); + var node = TestUtils.renderIntoDocument(element); + var matches = TestUtils.scryRenderedDOMComponentsWithTag(node, 'mark'); + expect(matches).to.have.length(1); + expect(ReactDOM.findDOMNode(matches[0]).textContent).to.equal('TEST'); + }); + + it('should support matching diacritics exactly', function() { + var text = 'Café has a weird e. Cafééééé has five of them. Cafe has a normal e. Cafeeeee has five of them.'; + var element = React.createElement(Highlight, {search: 'Cafe'}, text); + var node = TestUtils.renderIntoDocument(element); + var matches = TestUtils.scryRenderedDOMComponentsWithTag(node, 'mark'); + expect(matches).to.have.length(2); + expect(ReactDOM.findDOMNode(matches[0]).textContent).to.equal('Cafe'); + expect(ReactDOM.findDOMNode(matches[1]).textContent).to.equal('Cafe'); + + var element = React.createElement(Highlight, {search: 'Café'}, text); + var node = TestUtils.renderIntoDocument(element); + var matches = TestUtils.scryRenderedDOMComponentsWithTag(node, 'mark'); + expect(matches).to.have.length(2); + expect(ReactDOM.findDOMNode(matches[0]).textContent).to.equal('Café'); + expect(ReactDOM.findDOMNode(matches[1]).textContent).to.equal('Café'); + }); + + it('should support ignoring diacritics', function() { + var text = 'Café has a weird e. Cafééééé has five of them. Cafe has a normal e. Cafeeeee has five of them.'; + var element = React.createElement(Highlight, {search: 'Cafe', ignoreDiacritics: true}, text); + var node = TestUtils.renderIntoDocument(element); + var matches = TestUtils.scryRenderedDOMComponentsWithTag(node, 'mark'); + expect(matches).to.have.length(4); + expect(ReactDOM.findDOMNode(matches[0]).textContent).to.equal('Café'); + expect(ReactDOM.findDOMNode(matches[1]).textContent).to.equal('Café'); + expect(ReactDOM.findDOMNode(matches[2]).textContent).to.equal('Cafe'); + expect(ReactDOM.findDOMNode(matches[3]).textContent).to.equal('Cafe'); + }); + it('should support regular expressions in search', function() { var element = React.createElement(Highlight, {search: /[A-Za-z]+/}, 'Easy as 123, ABC...'); var node = TestUtils.renderIntoDocument(element); - var matches = TestUtils.scryRenderedDOMComponentsWithTag(node, 'strong'); + var matches = TestUtils.scryRenderedDOMComponentsWithTag(node, 'mark'); expect(ReactDOM.findDOMNode(matches[0]).textContent).to.equal('Easy'); expect(ReactDOM.findDOMNode(matches[1]).textContent).to.equal('as'); expect(ReactDOM.findDOMNode(matches[2]).textContent).to.equal('ABC'); }); + it('should work when regular expressions in search do not match anything', function() { + var element = React.createElement(Highlight, {search: /z+/}, 'Easy as 123, ABC...'); + var node = TestUtils.renderIntoDocument(element); + var matches = TestUtils.scryRenderedDOMComponentsWithTag(node, 'mark'); + expect(matches).to.have.length(0); + }); + + it('should stop immediately if regex matches an empty string', function() { + var element = React.createElement(Highlight, {search: /z*/}, 'Ez as 123, ABC...'); + var node = TestUtils.renderIntoDocument(element); + var matches = TestUtils.scryRenderedDOMComponentsWithTag(node, 'mark'); + expect(matches).to.have.length(0); + + var element = React.createElement(Highlight, {search: /z*/}, 'zzz Ez as 123, ABC...'); + var node = TestUtils.renderIntoDocument(element); + var matches = TestUtils.scryRenderedDOMComponentsWithTag(node, 'mark'); + expect(matches).to.have.length(1); + expect(ReactDOM.findDOMNode(matches[0]).textContent).to.equal('zzz'); + }); + + it('should support matching diacritics exactly with regex', function() { + var text = 'Café has a weird e. Cafééééé has five of them. Cafe has a normal e. Cafeeeee has five of them.'; + var element = React.createElement(Highlight, {search: /Cafe/}, text); + var node = TestUtils.renderIntoDocument(element); + var matches = TestUtils.scryRenderedDOMComponentsWithTag(node, 'mark'); + expect(matches).to.have.length(2); + expect(ReactDOM.findDOMNode(matches[0]).textContent).to.equal('Cafe'); + expect(ReactDOM.findDOMNode(matches[1]).textContent).to.equal('Cafe'); + + var element = React.createElement(Highlight, {search: /Café/}, text); + var node = TestUtils.renderIntoDocument(element); + var matches = TestUtils.scryRenderedDOMComponentsWithTag(node, 'mark'); + expect(matches).to.have.length(2); + expect(ReactDOM.findDOMNode(matches[0]).textContent).to.equal('Café'); + expect(ReactDOM.findDOMNode(matches[1]).textContent).to.equal('Café'); + }); + + it('should support ignoring diacritics with regex', function() { + var text = 'Café has a weird e. Cafééééé has five of them. Cafe has a normal e. Cafeeeee has five of them.'; + var element = React.createElement(Highlight, {search: /Cafe+/, ignoreDiacritics: true}, text); + var node = TestUtils.renderIntoDocument(element); + var matches = TestUtils.scryRenderedDOMComponentsWithTag(node, 'mark'); + expect(matches).to.have.length(4); + expect(ReactDOM.findDOMNode(matches[0]).textContent).to.equal('Café'); + expect(ReactDOM.findDOMNode(matches[1]).textContent).to.equal('Cafééééé'); + expect(ReactDOM.findDOMNode(matches[2]).textContent).to.equal('Cafe'); + expect(ReactDOM.findDOMNode(matches[3]).textContent).to.equal('Cafeeeee'); + }); + + it('should support ignoring diacritics with blacklist', function() { + var text = 'Letter ä is a normal letter here: Ääkkösiä'; + var element = React.createElement(Highlight, {search: 'Aakkosia', ignoreDiacritics: true, diacriticsBlacklist: 'Ää'}, text); + var node = TestUtils.renderIntoDocument(element); + var matches = TestUtils.scryRenderedDOMComponentsWithTag(node, 'mark'); + expect(matches).to.have.length(0); + + var element = React.createElement(Highlight, {search: 'Ääkkösiä', ignoreDiacritics: true, diacriticsBlacklist: 'Ää'}, text); + var node = TestUtils.renderIntoDocument(element); + var matches = TestUtils.scryRenderedDOMComponentsWithTag(node, 'mark'); + expect(matches).to.have.length(1); + expect(ReactDOM.findDOMNode(matches[0]).textContent).to.equal('Ääkkösiä'); + }); + + it('should support ignoring diacritics with blacklist with regex', function() { + var text = 'Letter ä is a normal letter here: Ääkkösiä'; + var element = React.createElement(Highlight, {search: /k+o/i, ignoreDiacritics: true, diacriticsBlacklist: 'Ää'}, text); + var node = TestUtils.renderIntoDocument(element); + var matches = TestUtils.scryRenderedDOMComponentsWithTag(node, 'mark'); + expect(matches).to.have.length(1); + expect(ReactDOM.findDOMNode(matches[0]).textContent).to.equal('kkö'); + + var element = React.createElement(Highlight, {search: /ä+/i, ignoreDiacritics: true, diacriticsBlacklist: 'Ää'}, text); + var node = TestUtils.renderIntoDocument(element); + var matches = TestUtils.scryRenderedDOMComponentsWithTag(node, 'mark'); + expect(matches).to.have.length(3); + expect(ReactDOM.findDOMNode(matches[0]).textContent).to.equal('ä'); + expect(ReactDOM.findDOMNode(matches[1]).textContent).to.equal('Ää'); + expect(ReactDOM.findDOMNode(matches[2]).textContent).to.equal('ä'); + }); + it('should support escaping arbitrary string in search', function() { var element = React.createElement(Highlight, {search: 'Test ('}, 'Test (should not throw)'); expect(TestUtils.renderIntoDocument.bind(TestUtils, element)).to.not.throw(Error);