@cbcruk/highlight-kit 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/react.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { a as MatchOptions, i as HighlightSnapshot, p as highlights, t as HighlightController } from "./core-B8g04jvB.js";
1
+ import { F as TokenRule, N as OverlapStrategy, S as ValueRangeElement, a as HighlightSnapshot, m as highlights, o as MatchOptions, t as HighlightController } from "./core-DDCXN2b_.js";
2
2
  import { CSSProperties, ComponentPropsWithoutRef, ElementType, ReactNode, RefObject } from "react";
3
3
  //#region src/react.d.ts
4
4
  /** The controller in scope — the shared singleton unless a provider overrides it. */
@@ -259,5 +259,207 @@ export interface HighlightStylesProps {
259
259
  * {@link useHighlightSearch}.
260
260
  */
261
261
  export declare function HighlightStyles({ styles }: HighlightStylesProps): ReactNode;
262
+ /** Options shared by the form-control hooks. */
263
+ export interface ValueHighlightOptions {
264
+ /**
265
+ * The control's current value, for a controlled component.
266
+ *
267
+ * Assigning `value` collapses every live range and fires no `input` event, so
268
+ * without this the highlight would go stale on a programmatic change (a reset
269
+ * button, loading a template). Omit it for an uncontrolled control: typing is
270
+ * tracked through `input` either way.
271
+ */
272
+ value?: string;
273
+ /**
274
+ * Re-tokenize on the control's `input` events.
275
+ * @default true
276
+ */
277
+ observe?: boolean;
278
+ }
279
+ /** Options for {@link useValueTokens}. */
280
+ export interface UseValueTokensOptions extends ValueHighlightOptions {
281
+ /**
282
+ * How matches covering the same characters resolve.
283
+ * @default 'first'
284
+ */
285
+ overlap?: OverlapStrategy;
286
+ }
287
+ /**
288
+ * Return value of {@link useValueTokens}.
289
+ *
290
+ * @template T - The form control element type
291
+ */
292
+ export interface UseValueTokensResult<T extends ValueRangeElement> {
293
+ /** Attach to the `<input>` or `<textarea>` to tokenize. */
294
+ ref: RefObject<T | null>;
295
+ /** Whether this browser and this element support value ranges. */
296
+ supported: boolean;
297
+ /** Match count per highlight name, with an entry for every rule name. */
298
+ counts: Readonly<Record<string, number>>;
299
+ }
300
+ /**
301
+ * Tokenize a form control's value and highlight each rule's matches.
302
+ *
303
+ * The control's text is re-tokenized on every `input` event, with one highlight
304
+ * name per rule name, and the previous generation of live ranges is released.
305
+ * Rules are compared by content, so an inline rule array is fine.
306
+ *
307
+ * Needs `OpaqueRange` (Chromium 152+). Everywhere else `supported` is false and
308
+ * the hook does nothing, leaving the control to render normally.
309
+ *
310
+ * @template T - The form control element type
311
+ * @param rules - Ordered rules; earlier ones win, see {@link TokenRule}
312
+ * @returns The control ref, support flag, and per-name match counts
313
+ *
314
+ * @example A textarea highlighted like a code editor
315
+ * ```tsx
316
+ * import { HighlightStyles, useValueTokens } from '@cbcruk/highlight-kit/react'
317
+ *
318
+ * const RULES = [
319
+ * { name: 'comment', pattern: /\/\/[^\n]*|\/\*[\s\S]*?\*\// },
320
+ * { name: 'string', pattern: /'[^']*'|"[^"]*"/ },
321
+ * { name: 'keyword', pattern: /\b(?:const|function|return)\b/ },
322
+ * ]
323
+ *
324
+ * function CodeArea() {
325
+ * const { ref, supported, counts } = useValueTokens<HTMLTextAreaElement>(RULES)
326
+ * return (
327
+ * <>
328
+ * <HighlightStyles
329
+ * styles={{
330
+ * comment: { color: '#6b7280' },
331
+ * string: { color: '#16a34a' },
332
+ * keyword: { color: '#7c3aed' },
333
+ * }}
334
+ * />
335
+ * <textarea ref={ref} defaultValue="const x = 1 // note" />
336
+ * {!supported && <p>This browser cannot highlight inside a textarea.</p>}
337
+ * <small>{counts.keyword ?? 0} keywords</small>
338
+ * </>
339
+ * )
340
+ * }
341
+ * ```
342
+ */
343
+ export declare function useValueTokens<T extends ValueRangeElement = HTMLTextAreaElement>(rules: readonly TokenRule[], options?: UseValueTokensOptions): UseValueTokensResult<T>;
344
+ /** Options for {@link useValueHighlight}. */
345
+ export interface UseValueHighlightOptions extends MatchOptions, ValueHighlightOptions {
346
+ /** Text or expression to highlight. Falsy clears this source. */
347
+ query: string | RegExp;
348
+ /** CSS `::highlight()` name. Defaults to a unique per-instance name. */
349
+ name?: string;
350
+ /**
351
+ * Stacking order against other highlight names.
352
+ * @default 0
353
+ */
354
+ priority?: number;
355
+ }
356
+ /**
357
+ * Return value of {@link useValueHighlight}.
358
+ *
359
+ * @template T - The form control element type
360
+ */
361
+ export interface UseValueHighlightResult<T extends ValueRangeElement> extends HighlightSnapshot {
362
+ /** Attach to the `<input>` or `<textarea>` to scan. */
363
+ ref: RefObject<T | null>;
364
+ /** The resolved highlight name (auto-generated if not provided). */
365
+ name: string;
366
+ /** Whether this browser and this element support value ranges. */
367
+ supported: boolean;
368
+ }
369
+ /**
370
+ * Highlight one pattern inside an `<input>` or `<textarea>`.
371
+ *
372
+ * The single-pattern form of {@link useValueTokens} — the counterpart to
373
+ * {@link useHighlight}, which cannot reach into a form control's value.
374
+ *
375
+ * @template T - The form control element type
376
+ *
377
+ * @example Flagging a word as it is typed
378
+ * ```tsx
379
+ * import { useValueHighlight } from '@cbcruk/highlight-kit/react'
380
+ *
381
+ * function Composer() {
382
+ * const { ref, count, supported } = useValueHighlight<HTMLTextAreaElement>({
383
+ * query: /\bexample\b/gi,
384
+ * name: 'flagged',
385
+ * })
386
+ * return (
387
+ * <>
388
+ * <style>{'::highlight(flagged) { background: #fde68a }'}</style>
389
+ * <textarea ref={ref} />
390
+ * {supported && <small>{count} occurrences</small>}
391
+ * </>
392
+ * )
393
+ * }
394
+ * ```
395
+ */
396
+ export declare function useValueHighlight<T extends ValueRangeElement = HTMLInputElement>(options: UseValueHighlightOptions): UseValueHighlightResult<T>;
397
+ /** Options for {@link useValueHighlightSearch}. */
398
+ export interface UseValueHighlightSearchOptions extends MatchOptions, ValueHighlightOptions {
399
+ /**
400
+ * Base highlight name. The active match is registered under
401
+ * `${name}-current` with a higher priority.
402
+ * @default 'search'
403
+ */
404
+ name?: string;
405
+ }
406
+ /**
407
+ * Return value of {@link useValueHighlightSearch}.
408
+ *
409
+ * @template T - The form control element type
410
+ */
411
+ export interface UseValueHighlightSearchResult<T extends ValueRangeElement> {
412
+ /** Attach to the `<input>` or `<textarea>` to search. */
413
+ ref: RefObject<T | null>;
414
+ /** Number of matches in the control's value. */
415
+ count: number;
416
+ /** Index of the active match, or -1 when there are no matches. */
417
+ active: number;
418
+ /** Move to the next match, wrapping to the first after the last. */
419
+ next(): void;
420
+ /** Move to the previous match, wrapping to the last before the first. */
421
+ prev(): void;
422
+ /** Whether this browser and this element support value ranges. */
423
+ supported: boolean;
424
+ }
425
+ /**
426
+ * Search inside an `<input>` or `<textarea>` with next/prev navigation.
427
+ *
428
+ * All matches go to `name`, the active one to `${name}-current` at a higher
429
+ * priority, and the control is scrolled just far enough to reveal the active
430
+ * match. Scrolling adjusts the control's own `scrollTop`/`scrollLeft` rather
431
+ * than calling `setSelectionRange()`, so the caret and the user's selection are
432
+ * left untouched.
433
+ *
434
+ * @template T - The form control element type
435
+ * @param query - Text or expression to find. Falsy clears the highlight
436
+ *
437
+ * @example
438
+ * ```tsx
439
+ * import { useState } from 'react'
440
+ * import {
441
+ * HighlightStyles,
442
+ * useValueHighlightSearch,
443
+ * } from '@cbcruk/highlight-kit/react'
444
+ *
445
+ * function NoteSearch() {
446
+ * const [query, setQuery] = useState('')
447
+ * const { ref, count, active, next, prev } =
448
+ * useValueHighlightSearch<HTMLTextAreaElement>(query)
449
+ *
450
+ * return (
451
+ * <>
452
+ * <HighlightStyles />
453
+ * <input value={query} onChange={(e) => setQuery(e.target.value)} />
454
+ * <span>{count === 0 ? '0/0' : `${active + 1}/${count}`}</span>
455
+ * <button onClick={prev}>Prev</button>
456
+ * <button onClick={next}>Next</button>
457
+ * <textarea ref={ref} rows={10} />
458
+ * </>
459
+ * )
460
+ * }
461
+ * ```
462
+ */
463
+ export declare function useValueHighlightSearch<T extends ValueRangeElement = HTMLTextAreaElement>(query: string | RegExp, options?: UseValueHighlightSearchOptions): UseValueHighlightSearchResult<T>;
262
464
  //#endregion
263
465
  export { highlights };
package/dist/react.js CHANGED
@@ -1,4 +1,4 @@
1
- import { a as generateHighlightCSS, l as isHighlightSupported, r as createHighlightController, s as highlights, t as computeRanges } from "./core-LUnH63zG.js";
1
+ import { _ as highlights, d as computeRanges, h as generateHighlightCSS, i as disconnectValueRanges, n as createValueRangeRegistry, p as createHighlightController, r as createValueRanges, s as scrollValueRangeIntoView, t as createValueHighlighter, u as tokenizeValue, y as isHighlightSupported } from "./value-range-CH2vegQi.js";
2
2
  import { createContext, createElement, forwardRef, useCallback, useContext, useEffect, useId, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
3
3
  import { jsx } from "react/jsx-runtime";
4
4
  //#region src/react.tsx
@@ -316,5 +316,303 @@ const DEFAULT_SEARCH_STYLES = {
316
316
  function HighlightStyles({ styles = DEFAULT_SEARCH_STYLES }) {
317
317
  return /* @__PURE__ */ jsx("style", { children: generateHighlightCSS(styles) });
318
318
  }
319
+ /**
320
+ * Stable identity for a rule list.
321
+ *
322
+ * Rule arrays are almost always written inline, so a new reference arrives on
323
+ * every render. Keying effects on the rules' *content* instead keeps a fresh
324
+ * array from tearing down and rebuilding the highlighter each render, which
325
+ * would discard and recreate every live range.
326
+ */
327
+ function useRulesKey(rules) {
328
+ return useMemo(() => JSON.stringify(rules.map((rule) => [
329
+ rule.name,
330
+ String(rule.pattern),
331
+ rule.caseSensitive ?? false,
332
+ rule.wholeWord ?? false,
333
+ rule.priority ?? 0
334
+ ])), [rules]);
335
+ }
336
+ /**
337
+ * Tokenize a form control's value and highlight each rule's matches.
338
+ *
339
+ * The control's text is re-tokenized on every `input` event, with one highlight
340
+ * name per rule name, and the previous generation of live ranges is released.
341
+ * Rules are compared by content, so an inline rule array is fine.
342
+ *
343
+ * Needs `OpaqueRange` (Chromium 152+). Everywhere else `supported` is false and
344
+ * the hook does nothing, leaving the control to render normally.
345
+ *
346
+ * @template T - The form control element type
347
+ * @param rules - Ordered rules; earlier ones win, see {@link TokenRule}
348
+ * @returns The control ref, support flag, and per-name match counts
349
+ *
350
+ * @example A textarea highlighted like a code editor
351
+ * ```tsx
352
+ * import { HighlightStyles, useValueTokens } from '@cbcruk/highlight-kit/react'
353
+ *
354
+ * const RULES = [
355
+ * { name: 'comment', pattern: /\/\/[^\n]*|\/\*[\s\S]*?\*\// },
356
+ * { name: 'string', pattern: /'[^']*'|"[^"]*"/ },
357
+ * { name: 'keyword', pattern: /\b(?:const|function|return)\b/ },
358
+ * ]
359
+ *
360
+ * function CodeArea() {
361
+ * const { ref, supported, counts } = useValueTokens<HTMLTextAreaElement>(RULES)
362
+ * return (
363
+ * <>
364
+ * <HighlightStyles
365
+ * styles={{
366
+ * comment: { color: '#6b7280' },
367
+ * string: { color: '#16a34a' },
368
+ * keyword: { color: '#7c3aed' },
369
+ * }}
370
+ * />
371
+ * <textarea ref={ref} defaultValue="const x = 1 // note" />
372
+ * {!supported && <p>This browser cannot highlight inside a textarea.</p>}
373
+ * <small>{counts.keyword ?? 0} keywords</small>
374
+ * </>
375
+ * )
376
+ * }
377
+ * ```
378
+ */
379
+ function useValueTokens(rules, options = {}) {
380
+ const { overlap, value, observe = true } = options;
381
+ const controller = useHighlightController();
382
+ const sourceId = useId();
383
+ const ref = useRef(null);
384
+ const rulesKey = useRulesKey(rules);
385
+ const rulesRef = useRef(rules);
386
+ rulesRef.current = rules;
387
+ const highlighterRef = useRef(null);
388
+ const [supported, setSupported] = useState(false);
389
+ useIsomorphicLayoutEffect(() => {
390
+ const element = ref.current;
391
+ if (!element) return;
392
+ const highlighter = createValueHighlighter({
393
+ element,
394
+ rules: rulesRef.current,
395
+ overlap,
396
+ controller,
397
+ sourceId,
398
+ observe
399
+ });
400
+ highlighterRef.current = highlighter;
401
+ setSupported(highlighter.supported);
402
+ return () => {
403
+ highlighterRef.current = null;
404
+ highlighter.dispose();
405
+ };
406
+ }, [
407
+ controller,
408
+ sourceId,
409
+ rulesKey,
410
+ overlap,
411
+ observe
412
+ ]);
413
+ const seenFirstValue = useRef(false);
414
+ useIsomorphicLayoutEffect(() => {
415
+ if (!seenFirstValue.current) {
416
+ seenFirstValue.current = true;
417
+ return;
418
+ }
419
+ highlighterRef.current?.refresh();
420
+ }, [value]);
421
+ const snapshots = useHighlightSnapshots();
422
+ return {
423
+ ref,
424
+ supported,
425
+ counts: useMemo(() => {
426
+ const result = {};
427
+ for (const rule of rulesRef.current) result[rule.name] = snapshots[rule.name]?.count ?? 0;
428
+ return result;
429
+ }, [snapshots, rulesKey])
430
+ };
431
+ }
432
+ /**
433
+ * Highlight one pattern inside an `<input>` or `<textarea>`.
434
+ *
435
+ * The single-pattern form of {@link useValueTokens} — the counterpart to
436
+ * {@link useHighlight}, which cannot reach into a form control's value.
437
+ *
438
+ * @template T - The form control element type
439
+ *
440
+ * @example Flagging a word as it is typed
441
+ * ```tsx
442
+ * import { useValueHighlight } from '@cbcruk/highlight-kit/react'
443
+ *
444
+ * function Composer() {
445
+ * const { ref, count, supported } = useValueHighlight<HTMLTextAreaElement>({
446
+ * query: /\bexample\b/gi,
447
+ * name: 'flagged',
448
+ * })
449
+ * return (
450
+ * <>
451
+ * <style>{'::highlight(flagged) { background: #fde68a }'}</style>
452
+ * <textarea ref={ref} />
453
+ * {supported && <small>{count} occurrences</small>}
454
+ * </>
455
+ * )
456
+ * }
457
+ * ```
458
+ */
459
+ function useValueHighlight(options) {
460
+ const { query, priority = 0, caseSensitive, wholeWord, value, observe } = options;
461
+ const autoId = useId();
462
+ const name = options.name ?? `hk-value-${autoId}`;
463
+ const { ref, supported, counts } = useValueTokens(useMemo(() => query ? [{
464
+ name,
465
+ pattern: query,
466
+ caseSensitive,
467
+ wholeWord,
468
+ priority
469
+ }] : [], [
470
+ name,
471
+ query,
472
+ caseSensitive,
473
+ wholeWord,
474
+ priority
475
+ ]), {
476
+ value,
477
+ observe
478
+ });
479
+ const count = counts[name] ?? 0;
480
+ return {
481
+ ref,
482
+ name,
483
+ supported,
484
+ count,
485
+ active: count > 0
486
+ };
487
+ }
488
+ /**
489
+ * Search inside an `<input>` or `<textarea>` with next/prev navigation.
490
+ *
491
+ * All matches go to `name`, the active one to `${name}-current` at a higher
492
+ * priority, and the control is scrolled just far enough to reveal the active
493
+ * match. Scrolling adjusts the control's own `scrollTop`/`scrollLeft` rather
494
+ * than calling `setSelectionRange()`, so the caret and the user's selection are
495
+ * left untouched.
496
+ *
497
+ * @template T - The form control element type
498
+ * @param query - Text or expression to find. Falsy clears the highlight
499
+ *
500
+ * @example
501
+ * ```tsx
502
+ * import { useState } from 'react'
503
+ * import {
504
+ * HighlightStyles,
505
+ * useValueHighlightSearch,
506
+ * } from '@cbcruk/highlight-kit/react'
507
+ *
508
+ * function NoteSearch() {
509
+ * const [query, setQuery] = useState('')
510
+ * const { ref, count, active, next, prev } =
511
+ * useValueHighlightSearch<HTMLTextAreaElement>(query)
512
+ *
513
+ * return (
514
+ * <>
515
+ * <HighlightStyles />
516
+ * <input value={query} onChange={(e) => setQuery(e.target.value)} />
517
+ * <span>{count === 0 ? '0/0' : `${active + 1}/${count}`}</span>
518
+ * <button onClick={prev}>Prev</button>
519
+ * <button onClick={next}>Next</button>
520
+ * <textarea ref={ref} rows={10} />
521
+ * </>
522
+ * )
523
+ * }
524
+ * ```
525
+ */
526
+ function useValueHighlightSearch(query, options = {}) {
527
+ const { name = "search", caseSensitive, wholeWord, value, observe = true } = options;
528
+ const controller = useHighlightController();
529
+ const sourceId = useId();
530
+ const ref = useRef(null);
531
+ const registryRef = useRef(null);
532
+ const [spans, setSpans] = useState([]);
533
+ const [active, setActive] = useState(0);
534
+ const [supported, setSupported] = useState(false);
535
+ useIsomorphicLayoutEffect(() => {
536
+ const element = ref.current;
537
+ if (!element) return;
538
+ const registry = createValueRangeRegistry({
539
+ element,
540
+ controller,
541
+ sourceId
542
+ });
543
+ registryRef.current = registry;
544
+ setSupported(registry.supported);
545
+ return () => {
546
+ registryRef.current = null;
547
+ registry.dispose();
548
+ };
549
+ }, [controller, sourceId]);
550
+ useIsomorphicLayoutEffect(() => {
551
+ const element = ref.current;
552
+ if (!element) return;
553
+ const recompute = () => {
554
+ const next = query ? tokenizeValue(element.value, [{
555
+ name,
556
+ pattern: query,
557
+ caseSensitive,
558
+ wholeWord
559
+ }]).map(({ start, end }) => ({
560
+ start,
561
+ end
562
+ })) : [];
563
+ setSpans((previous) => previous.length === next.length && previous.every((span, i) => span.start === next[i].start && span.end === next[i].end) ? previous : next);
564
+ };
565
+ recompute();
566
+ if (!observe) return;
567
+ element.addEventListener("input", recompute);
568
+ return () => element.removeEventListener("input", recompute);
569
+ }, [
570
+ name,
571
+ query,
572
+ caseSensitive,
573
+ wholeWord,
574
+ value,
575
+ observe
576
+ ]);
577
+ useIsomorphicLayoutEffect(() => {
578
+ setActive((a) => spans.length === 0 ? 0 : Math.min(a, spans.length - 1));
579
+ }, [spans]);
580
+ useIsomorphicLayoutEffect(() => {
581
+ const registry = registryRef.current;
582
+ if (!registry) return;
583
+ const current = spans[active];
584
+ registry.commit([{
585
+ name,
586
+ priority: 0,
587
+ spans
588
+ }, ...current ? [{
589
+ name: `${name}-current`,
590
+ priority: 1,
591
+ spans: [current]
592
+ }] : []]);
593
+ }, [
594
+ name,
595
+ spans,
596
+ active
597
+ ]);
598
+ useEffect(() => {
599
+ const element = ref.current;
600
+ const current = spans[active];
601
+ if (!element || !current) return;
602
+ const ranges = createValueRanges(element, [current]);
603
+ if (ranges[0]) scrollValueRangeIntoView(element, ranges[0]);
604
+ disconnectValueRanges(ranges);
605
+ }, [spans, active]);
606
+ const next = useCallback(() => setActive((a) => spans.length ? (a + 1) % spans.length : 0), [spans.length]);
607
+ const prev = useCallback(() => setActive((a) => spans.length ? (a - 1 + spans.length) % spans.length : 0), [spans.length]);
608
+ return {
609
+ ref,
610
+ count: spans.length,
611
+ active: spans.length ? active : -1,
612
+ next,
613
+ prev,
614
+ supported
615
+ };
616
+ }
319
617
  //#endregion
320
- export { Highlight, HighlightMatch, HighlightProvider, HighlightRoot, HighlightStyles, highlights, useHighlight, useHighlightController, useHighlightRanges, useHighlightSearch, useHighlightSnapshots, useHighlightState, useHighlightSupport, useTextMatches };
618
+ export { Highlight, HighlightMatch, HighlightProvider, HighlightRoot, HighlightStyles, highlights, useHighlight, useHighlightController, useHighlightRanges, useHighlightSearch, useHighlightSnapshots, useHighlightState, useHighlightSupport, useTextMatches, useValueHighlight, useValueHighlightSearch, useValueTokens };