text-strikethrough.tsx 2.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990
  1. import type { JSX } from "solid-js"
  2. import { createEffect, onCleanup, onMount } from "solid-js"
  3. import { createStore } from "solid-js/store"
  4. import { useSpring } from "./motion-spring"
  5. export function TextStrikethrough(props: {
  6. /** Whether the strikethrough is active (line drawn across). */
  7. active: boolean
  8. /** The text to display. Rendered twice internally (base + decoration overlay). */
  9. text: string
  10. /** Spring visual duration in seconds. Default 0.35. */
  11. visualDuration?: number
  12. class?: string
  13. style?: JSX.CSSProperties
  14. }) {
  15. const progress = useSpring(
  16. () => (props.active ? 1 : 0),
  17. () => ({ visualDuration: props.visualDuration ?? 0.35, bounce: 0 }),
  18. )
  19. let baseRef: HTMLSpanElement | undefined
  20. let containerRef: HTMLSpanElement | undefined
  21. const [state, setState] = createStore({
  22. textWidth: 0,
  23. containerWidth: 0,
  24. })
  25. const textWidth = () => state.textWidth
  26. const containerWidth = () => state.containerWidth
  27. const measure = () => {
  28. if (baseRef) setState("textWidth", baseRef.scrollWidth)
  29. if (containerRef) setState("containerWidth", containerRef.offsetWidth)
  30. }
  31. onMount(measure)
  32. createEffect(() => {
  33. const el = containerRef
  34. if (!el) return
  35. const observer = new ResizeObserver(measure)
  36. observer.observe(el)
  37. onCleanup(() => observer.disconnect())
  38. })
  39. // Revealed pixels from left = progress * textWidth
  40. const revealedPx = () => {
  41. const tw = textWidth()
  42. return tw > 0 ? progress() * tw : 0
  43. }
  44. // Overlay clip: hide everything to the right of revealed area
  45. const overlayClip = () => {
  46. const cw = containerWidth()
  47. const tw = textWidth()
  48. if (cw <= 0 || tw <= 0) return `inset(0 ${(1 - progress()) * 100}% 0 0)`
  49. const remaining = Math.max(0, cw - revealedPx())
  50. return `inset(0 ${remaining}px 0 0)`
  51. }
  52. // Base clip: hide everything to the left of revealed area (complementary)
  53. const baseClip = () => {
  54. const px = revealedPx()
  55. if (px <= 0.5) return "none"
  56. return `inset(0 0 0 ${px}px)`
  57. }
  58. return (
  59. <span
  60. data-component="text-strikethrough"
  61. class={props.class}
  62. style={{ display: "grid", ...props.style }}
  63. ref={containerRef}
  64. >
  65. <span ref={baseRef} style={{ "grid-area": "1 / 1", "clip-path": baseClip() }}>
  66. {props.text}
  67. </span>
  68. <span
  69. aria-hidden="true"
  70. style={{
  71. "grid-area": "1 / 1",
  72. "text-decoration": "line-through",
  73. "pointer-events": "none",
  74. "clip-path": overlayClip(),
  75. }}
  76. >
  77. {props.text}
  78. </span>
  79. </span>
  80. )
  81. }