@seekalfred/primitives 0.6.0 → 0.7.3
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/README.md +214 -15
- package/dist/activity-heatmap/activity-heatmap.d.ts +92 -0
- package/dist/activity-heatmap/activity-heatmap.js +699 -0
- package/dist/activity-heatmap/activity-heatmap.js.map +1 -0
- package/dist/activity-heatmap/index.d.ts +2 -0
- package/dist/activity-heatmap/index.js +2 -0
- package/dist/alert/alert.d.ts +53 -4
- package/dist/alert/alert.js +100 -11
- package/dist/alert/alert.js.map +1 -1
- package/dist/alert/index.d.ts +2 -2
- package/dist/alert/index.js +2 -2
- package/dist/alert-dialog/alert-dialog.d.ts +58 -18
- package/dist/alert-dialog/alert-dialog.js +152 -9
- package/dist/alert-dialog/alert-dialog.js.map +1 -1
- package/dist/alert-dialog/index.d.ts +2 -2
- package/dist/alert-dialog/index.js +2 -2
- package/dist/animated-counter/animated-counter.d.ts +38 -0
- package/dist/animated-counter/animated-counter.js +230 -0
- package/dist/animated-counter/animated-counter.js.map +1 -0
- package/dist/animated-counter/index.d.ts +2 -0
- package/dist/animated-counter/index.js +2 -0
- package/dist/announcement-bar/announcement-bar.d.ts +67 -0
- package/dist/announcement-bar/announcement-bar.js +364 -0
- package/dist/announcement-bar/announcement-bar.js.map +1 -0
- package/dist/announcement-bar/index.d.ts +2 -0
- package/dist/announcement-bar/index.js +2 -0
- package/dist/badge/badge.d.ts +1 -1
- package/dist/bar-chart/bar-chart.d.ts +51 -0
- package/dist/bar-chart/bar-chart.js +565 -0
- package/dist/bar-chart/bar-chart.js.map +1 -0
- package/dist/bar-chart/index.d.ts +2 -0
- package/dist/bar-chart/index.js +2 -0
- package/dist/breadcrumb/breadcrumb.d.ts +2 -2
- package/dist/breadcrumb/breadcrumb.js +12 -3
- package/dist/breadcrumb/breadcrumb.js.map +1 -1
- package/dist/brush-chart/brush-chart.d.ts +64 -0
- package/dist/brush-chart/brush-chart.js +1056 -0
- package/dist/brush-chart/brush-chart.js.map +1 -0
- package/dist/brush-chart/index.d.ts +2 -0
- package/dist/brush-chart/index.js +2 -0
- package/dist/button/button.d.ts +1 -1
- package/dist/button/button.js +31 -21
- package/dist/button/button.js.map +1 -1
- package/dist/button-group/button-group.js +3 -4
- package/dist/button-group/button-group.js.map +1 -1
- package/dist/calendar/calendar.d.ts +1 -1
- package/dist/card/card.d.ts +22 -1
- package/dist/card/card.js +60 -7
- package/dist/card/card.js.map +1 -1
- package/dist/card-stack/card-stack.d.ts +54 -0
- package/dist/card-stack/card-stack.js +492 -0
- package/dist/card-stack/card-stack.js.map +1 -0
- package/dist/card-stack/index.d.ts +2 -0
- package/dist/card-stack/index.js +2 -0
- package/dist/carousel/carousel.d.ts +77 -0
- package/dist/carousel/carousel.js +415 -0
- package/dist/carousel/carousel.js.map +1 -0
- package/dist/carousel/index.d.ts +2 -0
- package/dist/carousel/index.js +2 -0
- package/dist/checkbox/checkbox.js +4 -4
- package/dist/checkbox/checkbox.js.map +1 -1
- package/dist/chip-group/chip-group.d.ts +37 -0
- package/dist/chip-group/chip-group.js +148 -0
- package/dist/chip-group/chip-group.js.map +1 -0
- package/dist/chip-group/index.d.ts +2 -0
- package/dist/chip-group/index.js +2 -0
- package/dist/copy-button/copy-button.d.ts +18 -2
- package/dist/copy-button/copy-button.js +29 -8
- package/dist/copy-button/copy-button.js.map +1 -1
- package/dist/countdown/countdown.d.ts +45 -0
- package/dist/countdown/countdown.js +298 -0
- package/dist/countdown/countdown.js.map +1 -0
- package/dist/countdown/index.d.ts +2 -0
- package/dist/countdown/index.js +2 -0
- package/dist/date-picker/picker-parts.js +1 -1
- package/dist/date-picker/picker-parts.js.map +1 -1
- package/dist/dialog/dialog.js +1 -1
- package/dist/dialog/dialog.js.map +1 -1
- package/dist/donut-chart/donut-chart.d.ts +86 -0
- package/dist/donut-chart/donut-chart.js +593 -0
- package/dist/donut-chart/donut-chart.js.map +1 -0
- package/dist/donut-chart/index.d.ts +2 -0
- package/dist/donut-chart/index.js +2 -0
- package/dist/empty-state/empty-state.d.ts +42 -10
- package/dist/empty-state/empty-state.js +44 -26
- package/dist/empty-state/empty-state.js.map +1 -1
- package/dist/empty-state/index.d.ts +2 -2
- package/dist/empty-state/index.js +2 -2
- package/dist/file-drop-zone/file-drop-zone.js +1 -1
- package/dist/file-drop-zone/file-drop-zone.js.map +1 -1
- package/dist/filter-bar/filter-bar.d.ts +50 -26
- package/dist/filter-bar/filter-bar.js +317 -157
- package/dist/filter-bar/filter-bar.js.map +1 -1
- package/dist/gauge/gauge.d.ts +45 -0
- package/dist/gauge/gauge.js +219 -0
- package/dist/gauge/gauge.js.map +1 -0
- package/dist/gauge/index.d.ts +2 -0
- package/dist/gauge/index.js +2 -0
- package/dist/in-view-title/in-view-title.d.ts +33 -0
- package/dist/in-view-title/in-view-title.js +119 -0
- package/dist/in-view-title/in-view-title.js.map +1 -0
- package/dist/in-view-title/index.d.ts +2 -0
- package/dist/in-view-title/index.js +2 -0
- package/dist/index.d.ts +53 -10
- package/dist/index.js +52 -12
- package/dist/input/input.js +4 -4
- package/dist/input/input.js.map +1 -1
- package/dist/lib/cn.js +11 -1
- package/dist/lib/cn.js.map +1 -1
- package/dist/lib/field-message.js +28 -10
- package/dist/lib/field-message.js.map +1 -1
- package/dist/lib/field-shell.js +10 -3
- package/dist/lib/field-shell.js.map +1 -1
- package/dist/lib/field-surface.js +31 -9
- package/dist/lib/field-surface.js.map +1 -1
- package/dist/lib/flag-emoji.js +50 -0
- package/dist/lib/flag-emoji.js.map +1 -0
- package/dist/lib/flip.js +11 -3
- package/dist/lib/flip.js.map +1 -1
- package/dist/lib/menu-highlight.js +64 -0
- package/dist/lib/menu-highlight.js.map +1 -0
- package/dist/lib/motion.js +9 -2
- package/dist/lib/motion.js.map +1 -1
- package/dist/lib/rolling-value.js +1 -1
- package/dist/lib/rolling-value.js.map +1 -1
- package/dist/lib/spring-tween.js +500 -0
- package/dist/lib/spring-tween.js.map +1 -0
- package/dist/lib/swap-text.js +3 -1
- package/dist/lib/swap-text.js.map +1 -1
- package/dist/lib/use-copy-feedback.js +17 -3
- package/dist/lib/use-copy-feedback.js.map +1 -1
- package/dist/line-chart/index.d.ts +2 -0
- package/dist/line-chart/index.js +2 -0
- package/dist/line-chart/line-chart.d.ts +116 -0
- package/dist/line-chart/line-chart.js +740 -0
- package/dist/line-chart/line-chart.js.map +1 -0
- package/dist/mention-input/index.d.ts +2 -0
- package/dist/mention-input/index.js +2 -0
- package/dist/mention-input/mention-input.d.ts +98 -0
- package/dist/mention-input/mention-input.js +576 -0
- package/dist/mention-input/mention-input.js.map +1 -0
- package/dist/menu/menu.d.ts +2 -0
- package/dist/menu/menu.js +23 -12
- package/dist/menu/menu.js.map +1 -1
- package/dist/money-input/index.d.ts +2 -0
- package/dist/money-input/index.js +2 -0
- package/dist/money-input/money-input.d.ts +86 -0
- package/dist/money-input/money-input.js +613 -0
- package/dist/money-input/money-input.js.map +1 -0
- package/dist/morph-select/index.d.ts +2 -0
- package/dist/morph-select/index.js +2 -0
- package/dist/morph-select/morph-select.d.ts +89 -0
- package/dist/morph-select/morph-select.js +712 -0
- package/dist/morph-select/morph-select.js.map +1 -0
- package/dist/multi-select/index.d.ts +2 -0
- package/dist/multi-select/index.js +2 -0
- package/dist/multi-select/multi-select.d.ts +77 -0
- package/dist/multi-select/multi-select.js +209 -0
- package/dist/multi-select/multi-select.js.map +1 -0
- package/dist/number-field/index.d.ts +2 -0
- package/dist/number-field/index.js +2 -0
- package/dist/number-field/number-field.d.ts +42 -0
- package/dist/number-field/number-field.js +199 -0
- package/dist/number-field/number-field.js.map +1 -0
- package/dist/onboarding-checklist/index.d.ts +2 -0
- package/dist/onboarding-checklist/index.js +2 -0
- package/dist/onboarding-checklist/onboarding-checklist.d.ts +55 -0
- package/dist/onboarding-checklist/onboarding-checklist.js +577 -0
- package/dist/onboarding-checklist/onboarding-checklist.js.map +1 -0
- package/dist/password-field/password-field.d.ts +16 -0
- package/dist/password-field/password-field.js +39 -10
- package/dist/password-field/password-field.js.map +1 -1
- package/dist/phone-input/index.d.ts +3 -0
- package/dist/phone-input/index.js +3 -0
- package/dist/phone-input/phone-countries.d.ts +29 -0
- package/dist/phone-input/phone-countries.js +170 -0
- package/dist/phone-input/phone-countries.js.map +1 -0
- package/dist/phone-input/phone-input.d.ts +66 -0
- package/dist/phone-input/phone-input.js +440 -0
- package/dist/phone-input/phone-input.js.map +1 -0
- package/dist/popover/index.d.ts +2 -2
- package/dist/popover/index.js +2 -2
- package/dist/popover/popover.d.ts +51 -2
- package/dist/popover/popover.js +59 -8
- package/dist/popover/popover.js.map +1 -1
- package/dist/progress/progress.d.ts +16 -2
- package/dist/progress/progress.js +16 -9
- package/dist/progress/progress.js.map +1 -1
- package/dist/radio-group/index.d.ts +2 -2
- package/dist/radio-group/index.js +2 -2
- package/dist/radio-group/radio-group.d.ts +33 -3
- package/dist/radio-group/radio-group.js +110 -10
- package/dist/radio-group/radio-group.js.map +1 -1
- package/dist/reactions/index.d.ts +2 -0
- package/dist/reactions/index.js +2 -0
- package/dist/reactions/reactions.d.ts +50 -0
- package/dist/reactions/reactions.js +315 -0
- package/dist/reactions/reactions.js.map +1 -0
- package/dist/rich-text-editor/index.d.ts +3 -0
- package/dist/rich-text-editor/index.js +2 -0
- package/dist/rich-text-editor/rich-text-editor.d.ts +70 -0
- package/dist/rich-text-editor/rich-text-editor.js +909 -0
- package/dist/rich-text-editor/rich-text-editor.js.map +1 -0
- package/dist/rich-text-editor/rich-text-engine.d.ts +16 -0
- package/dist/rich-text-editor/rich-text-engine.js +366 -0
- package/dist/rich-text-editor/rich-text-engine.js.map +1 -0
- package/dist/rich-text-editor/rich-text-html.js +174 -0
- package/dist/rich-text-editor/rich-text-html.js.map +1 -0
- package/dist/rich-text-editor/rich-text-icons.js +43 -0
- package/dist/rich-text-editor/rich-text-icons.js.map +1 -0
- package/dist/ridgeline/index.d.ts +2 -0
- package/dist/ridgeline/index.js +2 -0
- package/dist/ridgeline/ridgeline.d.ts +55 -0
- package/dist/ridgeline/ridgeline.js +701 -0
- package/dist/ridgeline/ridgeline.js.map +1 -0
- package/dist/scroll-area/index.d.ts +2 -2
- package/dist/scroll-area/index.js +2 -2
- package/dist/scroll-area/scroll-area.d.ts +68 -31
- package/dist/scroll-area/scroll-area.js +212 -62
- package/dist/scroll-area/scroll-area.js.map +1 -1
- package/dist/scrub-input/evaluate.d.ts +14 -0
- package/dist/scrub-input/evaluate.js +72 -0
- package/dist/scrub-input/evaluate.js.map +1 -0
- package/dist/scrub-input/index.d.ts +3 -0
- package/dist/scrub-input/index.js +3 -0
- package/dist/scrub-input/scrub-input.d.ts +74 -0
- package/dist/scrub-input/scrub-input.js +486 -0
- package/dist/scrub-input/scrub-input.js.map +1 -0
- package/dist/search-field/search-field.d.ts +24 -2
- package/dist/search-field/search-field.js +24 -9
- package/dist/search-field/search-field.js.map +1 -1
- package/dist/select/select.d.ts +3 -1
- package/dist/select/select.js +28 -14
- package/dist/select/select.js.map +1 -1
- package/dist/separator/separator.d.ts +22 -10
- package/dist/separator/separator.js +33 -11
- package/dist/separator/separator.js.map +1 -1
- package/dist/sheet/sheet.js +2 -1
- package/dist/sheet/sheet.js.map +1 -1
- package/dist/shortcut-recorder/index.d.ts +3 -0
- package/dist/shortcut-recorder/index.js +3 -0
- package/dist/shortcut-recorder/shortcut-model.d.ts +27 -0
- package/dist/shortcut-recorder/shortcut-model.js +206 -0
- package/dist/shortcut-recorder/shortcut-model.js.map +1 -0
- package/dist/shortcut-recorder/shortcut-recorder.d.ts +93 -0
- package/dist/shortcut-recorder/shortcut-recorder.js +489 -0
- package/dist/shortcut-recorder/shortcut-recorder.js.map +1 -0
- package/dist/slider/index.d.ts +2 -2
- package/dist/slider/slider.d.ts +46 -14
- package/dist/slider/slider.js +151 -67
- package/dist/slider/slider.js.map +1 -1
- package/dist/slope-chart/index.d.ts +2 -0
- package/dist/slope-chart/index.js +2 -0
- package/dist/slope-chart/slope-chart.d.ts +54 -0
- package/dist/slope-chart/slope-chart.js +568 -0
- package/dist/slope-chart/slope-chart.js.map +1 -0
- package/dist/sparkline/index.d.ts +2 -0
- package/dist/sparkline/index.js +2 -0
- package/dist/sparkline/sparkline.d.ts +46 -0
- package/dist/sparkline/sparkline.js +390 -0
- package/dist/sparkline/sparkline.js.map +1 -0
- package/dist/split-bar/index.d.ts +2 -0
- package/dist/split-bar/index.js +2 -0
- package/dist/split-bar/split-bar.d.ts +62 -0
- package/dist/split-bar/split-bar.js +513 -0
- package/dist/split-bar/split-bar.js.map +1 -0
- package/dist/split-button/split-button.d.ts +1 -1
- package/dist/split-button/split-button.js +12 -11
- package/dist/split-button/split-button.js.map +1 -1
- package/dist/stat-card/index.d.ts +2 -2
- package/dist/stat-card/index.js +2 -2
- package/dist/stat-card/stat-card.d.ts +58 -5
- package/dist/stat-card/stat-card.js +144 -17
- package/dist/stat-card/stat-card.js.map +1 -1
- package/dist/stepper/index.d.ts +2 -0
- package/dist/stepper/index.js +2 -0
- package/dist/stepper/stepper.d.ts +51 -0
- package/dist/stepper/stepper.js +215 -0
- package/dist/stepper/stepper.js.map +1 -0
- package/dist/streamgraph/index.d.ts +2 -0
- package/dist/streamgraph/index.js +2 -0
- package/dist/streamgraph/streamgraph.d.ts +70 -0
- package/dist/streamgraph/streamgraph.js +613 -0
- package/dist/streamgraph/streamgraph.js.map +1 -0
- package/dist/styles.css +1 -1
- package/dist/swipe-actions/index.d.ts +2 -0
- package/dist/swipe-actions/index.js +2 -0
- package/dist/swipe-actions/swipe-actions.d.ts +67 -0
- package/dist/swipe-actions/swipe-actions.js +552 -0
- package/dist/swipe-actions/swipe-actions.js.map +1 -0
- package/dist/switch/switch.js +2 -2
- package/dist/switch/switch.js.map +1 -1
- package/dist/table/index.d.ts +2 -2
- package/dist/table/index.js +2 -2
- package/dist/table/table.d.ts +55 -3
- package/dist/table/table.js +340 -165
- package/dist/table/table.js.map +1 -1
- package/dist/tabs/tabs.js +2 -2
- package/dist/tabs/tabs.js.map +1 -1
- package/dist/tag-input/index.d.ts +2 -0
- package/dist/tag-input/index.js +2 -0
- package/dist/tag-input/tag-input.d.ts +40 -0
- package/dist/tag-input/tag-input.js +269 -0
- package/dist/tag-input/tag-input.js.map +1 -0
- package/dist/text-morph/index.d.ts +2 -0
- package/dist/text-morph/index.js +2 -0
- package/dist/text-morph/text-morph.d.ts +23 -0
- package/dist/text-morph/text-morph.js +21 -0
- package/dist/text-morph/text-morph.js.map +1 -0
- package/dist/text-reveal/index.d.ts +2 -0
- package/dist/text-reveal/index.js +2 -0
- package/dist/text-reveal/text-reveal.d.ts +27 -0
- package/dist/text-reveal/text-reveal.js +51 -0
- package/dist/text-reveal/text-reveal.js.map +1 -0
- package/dist/text-shimmer/index.d.ts +2 -0
- package/dist/text-shimmer/index.js +2 -0
- package/dist/text-shimmer/text-shimmer.d.ts +29 -0
- package/dist/text-shimmer/text-shimmer.js +29 -0
- package/dist/text-shimmer/text-shimmer.js.map +1 -0
- package/dist/time-picker/time-picker.d.ts +36 -1
- package/dist/time-picker/time-picker.js +35 -7
- package/dist/time-picker/time-picker.js.map +1 -1
- package/dist/toast/index.d.ts +2 -2
- package/dist/toast/index.js +2 -2
- package/dist/toast/toast.d.ts +7 -1
- package/dist/toast/toast.js +82 -40
- package/dist/toast/toast.js.map +1 -1
- package/dist/tooltip/tooltip.d.ts +9 -2
- package/dist/tooltip/tooltip.js +7 -7
- package/dist/tooltip/tooltip.js.map +1 -1
- package/dist/treemap/index.d.ts +2 -0
- package/dist/treemap/index.js +2 -0
- package/dist/treemap/treemap.d.ts +53 -0
- package/dist/treemap/treemap.js +699 -0
- package/dist/treemap/treemap.js.map +1 -0
- package/dist/usage-meter/index.d.ts +2 -0
- package/dist/usage-meter/index.js +2 -0
- package/dist/usage-meter/usage-meter.d.ts +80 -0
- package/dist/usage-meter/usage-meter.js +458 -0
- package/dist/usage-meter/usage-meter.js.map +1 -0
- package/dist/waffle-chart/index.d.ts +2 -0
- package/dist/waffle-chart/index.js +2 -0
- package/dist/waffle-chart/waffle-chart.d.ts +57 -0
- package/dist/waffle-chart/waffle-chart.js +426 -0
- package/dist/waffle-chart/waffle-chart.js.map +1 -0
- package/package.json +165 -3
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"filter-bar.js","names":[],"sources":["../../src/filter-bar/filter-bar.tsx"],"sourcesContent":["\"use client\";\n\nimport { cva } from \"class-variance-authority\";\nimport { forwardRef, useCallback, useEffect, useRef, useState, type ReactNode } from \"react\";\nimport type { Key } from \"react-aria-components\";\n\nimport { Badge } from \"../badge/badge.js\";\nimport { Button } from \"../button/button.js\";\nimport { cn, cnAll } from \"../lib/cn.js\";\nimport { useHeightFollow } from \"../lib/height-follow.js\";\nimport { useLeavingList } from \"../lib/leaving-list.js\";\nimport { prefersReducedMotion, useExitBackstop, useIsomorphicLayoutEffect, useMorphWidth } from \"../lib/motion.js\";\nimport { SwapText } from \"../lib/swap-text.js\";\nimport { Popover, PopoverTrigger } from \"../popover/popover.js\";\nimport { Select, SelectItem } from \"../select/select.js\";\n\n/**\n * FilterBar -- a list toolbar that keeps the controls people use every time\n * in the row and puts every other dropdown filter behind ONE \"Filters\"\n * button.\n *\n * Why it exists: list screens grew a row of four to seven dropdowns beside\n * the search box and the status tabs. At desktop width that row wraps onto a\n * second line; on a phone it scrolls sideways. The bar keeps the row to one\n * line and makes what IS filtered visible as chips instead of as the\n * resting text of a dropdown.\n *\n * Three regions:\n *\n * - `inline`: always in the row, at its start (status tabs, the search box).\n * - the Filters button: a popover with a one-column form, one full-width\n * Select per filter with its label above. The button carries a count badge\n * of the filters that are set.\n * - `end`: always in the row, at its end (a count, Export, a view toggle).\n *\n * While any filter is set, a list of chips sits under the row, each reading\n * \"Label: option label\" with its own remove button, plus \"Clear all\".\n *\n * The bar holds NO filter state. Every value and every change belongs to the\n * caller: removing a chip hands the filter's `emptyValue` back to its\n * `onChange`; Clear all does that for each set filter, or calls\n * `onClearAll` when the caller resets its filters in one step.\n *\n * Accessibility comes from the parts: the popover is a dialog named by its\n * trigger, the trigger carries `aria-expanded`, focus moves into the popover\n * and is contained there, Escape closes it and focus returns to the trigger.\n */\n\nexport const filterBarVariants = cva([\"flex flex-col gap-2 box-border font-sans\"]);\n\nexport const filterBarRowVariants = cva([\"flex flex-wrap items-center gap-2\"]);\n\nexport const filterBarChipVariants = cva([\n \"inline-flex items-center gap-1 rounded-full\",\n \"border-(length:--border-width) border-border bg-muted\",\n \"ps-3 pe-1 text-xs leading-(--leading-snug) text-foreground\",\n \"box-border font-sans\",\n]);\n\n/** One option of a dropdown filter. */\nexport interface FilterBarOption {\n /** The option's value, handed to `onChange`. */\n id: Key;\n /** The option's text: in the list, on the trigger and on the chip. */\n label: string;\n}\n\n/** A dropdown filter, rendered as a Select in the popover. */\nexport interface FilterBarSelectField {\n /** Stable identity, used as the React key. */\n id: string;\n /** The field's name: the label above the dropdown and the start of its chip. */\n label: string;\n /** The current value. `null` reads as not set. */\n value: Key | null;\n /** The value that means \"not filtered\" (the \"All\" option's id). */\n emptyValue: Key;\n /** The options, the \"All\" option included. */\n options: readonly FilterBarOption[];\n /** Told the chosen option's id, or `emptyValue` when the chip is removed. */\n onChange: (value: Key) => void;\n}\n\n/**\n * A filter that is not a plain dropdown (a date range, a searchable picker):\n * the caller renders the control and says what its chip reads.\n */\nexport interface FilterBarCustomField {\n /** Stable identity, used as the React key. */\n id: string;\n /** The start of its chip. The control rendered by `render` names itself. */\n label: string;\n /** The control, rendered in the popover. */\n render: () => ReactNode;\n /** The chip's value text while the filter is set, or `null` while it is not. */\n activeLabel: string | null;\n /** Unsets the filter: the chip's remove button and Clear all call it. */\n onClear: () => void;\n}\n\nexport type FilterBarField = FilterBarSelectField | FilterBarCustomField;\n\nexport interface FilterBarProps {\n /** Always in the row, at its start: status tabs and the search box. */\n inline?: ReactNode;\n /** Always in the row, at its end: a count, Export, a view toggle. */\n end?: ReactNode;\n /** The filters behind the Filters button. With none, no button renders. */\n filters: readonly FilterBarField[];\n /** Resets every filter in one step. Without it, each set filter is handed its `emptyValue`. */\n onClearAll?: () => void;\n /**\n * The button's text, and the popover's name.\n *\n * @default \"Filters\"\n */\n buttonLabel?: string;\n /**\n * The text of both Clear all buttons.\n *\n * @default \"Clear all\"\n */\n clearAllLabel?: string;\n /**\n * The text of the button that closes the popover.\n *\n * @default \"Done\"\n */\n doneLabel?: string;\n /**\n * The accessible name of the list of chips.\n *\n * @default \"Filters set\"\n */\n chipsLabel?: string;\n /**\n * The accessible name of a chip's remove button, given the chip's text.\n *\n * @default (text) => `Remove filter ${text}`\n */\n removeLabel?: (chipText: string) => string;\n /** Extra classes on the root. Merged last, so they win conflicts with ours. */\n className?: string;\n}\n\nconst isCustom = (f: FilterBarField): f is FilterBarCustomField => \"render\" in f;\n\ninterface Chip {\n id: string;\n text: string;\n clear: () => void;\n}\n\n/** The set filters, each with its chip text and how to unset it. */\nfunction chipsFor(filters: readonly FilterBarField[]): Chip[] {\n const chips: Chip[] = [];\n for (const f of filters) {\n if (isCustom(f)) {\n if (f.activeLabel != null) chips.push({ id: f.id, text: `${f.label}: ${f.activeLabel}`, clear: f.onClear });\n continue;\n }\n if (f.value == null || f.value === f.emptyValue) continue;\n const option = f.options.find((o) => o.id === f.value);\n chips.push({\n id: f.id,\n text: `${f.label}: ${option ? option.label : String(f.value)}`,\n clear: () => f.onChange(f.emptyValue),\n });\n }\n return chips;\n}\n\n/** A funnel, drawn inline: this package ships no icon dependency. */\nfunction FunnelGlyph() {\n return (\n <svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" aria-hidden=\"true\">\n <path d=\"M2 3h12M4.5 8h7M7 13h2\" strokeLinecap=\"round\" />\n </svg>\n );\n}\n\nfunction CrossGlyph() {\n return (\n <svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" aria-hidden=\"true\">\n <path d=\"M4 4l8 8M12 4l-8 8\" strokeLinecap=\"round\" />\n </svg>\n );\n}\n\n/**\n * A list toolbar with its dropdown filters behind one Filters button.\n *\n * @example\n * <FilterBar\n * inline={<SearchField aria-label=\"Search\" />}\n * end={<Button>Export</Button>}\n * filters={[{ id: \"region\", label: \"Region\", value, emptyValue: \"all\", onChange: setValue,\n * options: [{ id: \"all\", label: \"All regions\" }, { id: \"nsw\", label: \"NSW\" }] }]}\n * />\n */\n/**\n * One place in the chip row, for a chip or for Clear all. A slot added after the\n * first render opens from nothing while its content fades up and scales in, so the\n * chips beside it glide over; one that is removed stays, shrinking and collapsing,\n * until its exit ends. Nothing animates at first render.\n */\nfunction Slot({\n leaving,\n ready,\n onDone,\n id,\n children,\n}: {\n leaving: boolean;\n ready: boolean;\n onDone: (id: string) => void;\n id: string;\n children: ReactNode;\n}) {\n // Decided once, at this slot's own mount.\n const [fresh] = useState(ready);\n const remove = useCallback(() => onDone(id), [onDone, id]);\n useExitBackstop(leaving, remove, 900);\n // Reduced motion removes it at once: an animation that is switched off never ends.\n useIsomorphicLayoutEffect(() => {\n if (leaving && prefersReducedMotion()) remove();\n }, [leaving, remove]);\n return (\n <li\n data-slot={leaving ? \"filter-bar-leaving\" : undefined}\n aria-hidden={leaving || undefined}\n ref={(node) => {\n if (!node) return;\n if (leaving) node.setAttribute(\"inert\", \"\");\n else node.removeAttribute(\"inert\");\n }}\n onAnimationEnd={(event) => {\n if (leaving && event.target === event.currentTarget && event.animationName === \"chip-slot-out\")\n remove();\n }}\n className={cnAll(\n \"min-w-0 flex-none [interpolate-size:allow-keywords]\",\n leaving\n ? \"pointer-events-none motion-safe:animate-chip-out\"\n : fresh && \"motion-safe:animate-chip-in motion-reduce:animate-fade-in\",\n )}\n >\n {children}\n </li>\n );\n}\n\n/** A chip's text: a changed value swaps (the new text rises in) and the chip's width springs to it. */\nfunction ChipText({ text }: { text: string }) {\n const content = useRef<HTMLSpanElement>(null);\n useMorphWidth(content, text);\n return (\n <span className=\"button-slot\">\n <span ref={content} className=\"inline-flex flex-none items-center\">\n <SwapText swapKey={text} className=\"inline-block\">\n {text}\n </SwapText>\n </span>\n </span>\n );\n}\n\nexport const FilterBar = forwardRef<HTMLDivElement, FilterBarProps>(function FilterBar(\n {\n inline,\n end,\n filters,\n onClearAll,\n buttonLabel = \"Filters\",\n clearAllLabel = \"Clear all\",\n doneLabel = \"Done\",\n chipsLabel = \"Filters set\",\n removeLabel = (text) => `Remove filter ${text}`,\n className,\n },\n ref,\n) {\n const [open, setOpen] = useState(false);\n const chips = chipsFor(filters);\n const [ready, setReady] = useState(false);\n useEffect(() => {\n // A frame after mount: chips added from then on arrive animated, the first ones do not.\n const frame = requestAnimationFrame(() => setReady(true));\n return () => cancelAnimationFrame(frame);\n }, []);\n // The chips, with Clear all as the row's last place, each kept on screen while it leaves.\n const places = useLeavingList([\n ...chips.map((chip) => ({ id: chip.id, chip })),\n ...(chips.length > 0 ? [{ id: \"\\u0000clear\", chip: null }] : []),\n ]);\n const frame = useRef<HTMLDivElement>(null);\n const row = useRef<HTMLUListElement>(null);\n // The row follows its height when a chip makes a line wrap or unwrap.\n useHeightFollow(frame, row, chips.map((chip) => chip.id).join());\n const clearAll = () => {\n if (onClearAll) onClearAll();\n else for (const c of chips) c.clear();\n };\n\n return (\n <div ref={ref} data-slot=\"filter-bar\" className={cn(filterBarVariants(), className)}>\n <div data-slot=\"filter-bar-row\" className={filterBarRowVariants()}>\n {inline}\n {filters.length > 0 ? (\n <PopoverTrigger isOpen={open} onOpenChange={setOpen}>\n <Button variant=\"outlineNeutral\" size=\"sm\" startIcon={<FunnelGlyph />} data-slot=\"filter-bar-button\">\n {buttonLabel}\n {chips.length > 0 ? (\n <Badge tone=\"primary\" appearance=\"solid\" size=\"sm\" shape=\"pill\" data-slot=\"filter-bar-count\">\n {chips.length}\n </Badge>\n ) : null}\n </Button>\n <Popover placement=\"bottom start\" className=\"w-80 max-w-[calc(100vw-2rem)]\">\n <div data-slot=\"filter-bar-panel\" className=\"flex flex-col gap-3\">\n <p className=\"m-0 text-sm font-semibold\">{buttonLabel}</p>\n {filters.map((f) =>\n isCustom(f) ? (\n <div key={f.id}>{f.render()}</div>\n ) : (\n <Select\n key={f.id}\n label={f.label}\n size=\"sm\"\n selectedKey={f.value}\n onSelectionChange={(key) => {\n if (key != null) f.onChange(key);\n }}\n >\n {f.options.map((o) => (\n <SelectItem key={o.id} id={o.id} textValue={o.label}>\n {o.label}\n </SelectItem>\n ))}\n </Select>\n ),\n )}\n <div className=\"flex items-center justify-between gap-2 pt-1\">\n <Button variant=\"ghost\" size=\"sm\" onPress={clearAll} isDisabled={chips.length === 0}>\n {clearAllLabel}\n </Button>\n <Button variant=\"primary\" size=\"sm\" onPress={() => setOpen(false)}>\n {doneLabel}\n </Button>\n </div>\n </div>\n </Popover>\n </PopoverTrigger>\n ) : null}\n {end ? <div className=\"ms-auto flex flex-wrap items-center gap-2\">{end}</div> : null}\n </div>\n {places.list.length > 0 ? (\n <div ref={frame} data-slot=\"filter-bar-chips-frame\" className=\"height-follow\">\n <ul\n ref={row}\n aria-label={chipsLabel}\n data-slot=\"filter-bar-chips\"\n className=\"m-0 flex list-none flex-wrap items-center gap-2 p-0\"\n >\n {places.list.map(({ item, leaving }) =>\n item.chip ? (\n <Slot key={item.id} id={item.id} leaving={leaving} ready={ready} onDone={places.done}>\n <span data-slot=\"filter-bar-chip\" className={filterBarChipVariants()}>\n <ChipText text={item.chip.text} />\n <Button\n variant=\"ghost\"\n size=\"sm\"\n aria-label={removeLabel(item.chip.text)}\n onPress={item.chip.clear}\n className=\"h-6 px-1\"\n >\n <CrossGlyph />\n </Button>\n </span>\n </Slot>\n ) : (\n <Slot key={item.id} id={item.id} leaving={leaving} ready={ready} onDone={places.done}>\n <Button variant=\"link\" size=\"sm\" onPress={clearAll}>\n {clearAllLabel}\n </Button>\n </Slot>\n ),\n )}\n </ul>\n </div>\n ) : null}\n </div>\n );\n});\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"filter-bar.js","names":["Button","AriaMenu","MenuItem","Popover"],"sources":["../../src/filter-bar/filter-bar.tsx"],"sourcesContent":["\"use client\";\n\nimport { cva } from \"class-variance-authority\";\nimport { forwardRef, useCallback, useEffect, useRef, useState, type ReactNode } from \"react\";\nimport { Menu as AriaMenu, type Key } from \"react-aria-components\";\n\nimport { Button } from \"../button/button.js\";\nimport { cn, cnAll } from \"../lib/cn.js\";\nimport { useHeightFollow } from \"../lib/height-follow.js\";\nimport { useLeavingList } from \"../lib/leaving-list.js\";\nimport { useMenuHighlight } from \"../lib/menu-highlight.js\";\nimport { prefersReducedMotion, useExitBackstop, useIsomorphicLayoutEffect, useMorphWidth } from \"../lib/motion.js\";\nimport { SwapText } from \"../lib/swap-text.js\";\nimport { MenuItem, menuVariants } from \"../menu/menu.js\";\nimport { Popover, PopoverTrigger } from \"../popover/popover.js\";\n\n/**\n * FilterBar -- a filter toolbar: one pill that shows what is filtered as\n * removable chips (\"No filters applied\" while nothing is), with an \"Add filter\"\n * button that opens a menu of the filters.\n *\n * Why it exists: list screens grew a row of four to seven dropdowns beside\n * the search box and the status tabs. At desktop width that row wraps onto a\n * second line; on a phone it scrolls sideways. The bar keeps the row to one\n * line and makes what IS filtered visible as chips instead of as the\n * resting text of a dropdown.\n *\n * Three regions, all inside the one pill:\n *\n * - `inline`: always in the pill, at its start (status tabs, a search box).\n * - the chips, then \"Clear all\" once any filter is set; or the empty note.\n * - the Add filter button, then `end`: always in the pill, at its end (a\n * count, Export, a view toggle).\n *\n * The button opens a menu of the filters: each row is the filter's icon and\n * name, the value it holds now, and a chevron. A row opens that filter's own\n * options in the same panel (a back button returns to the list); choosing an\n * option sets the filter and closes the menu. A custom filter shows its own\n * control in that step.\n *\n * The bar holds NO filter state. Every value and every change belongs to the\n * caller: removing a chip hands the filter's `emptyValue` back to its\n * `onChange`; Clear all does that for each set filter, or calls\n * `onClearAll` when the caller resets its filters in one step.\n *\n * Accessibility comes from the parts: the panel is a dialog named by its\n * trigger, the trigger carries `aria-expanded`, focus moves into the panel and\n * is contained there, Escape closes it and focus returns to the trigger, and\n * each list is a real menu with arrow keys and type-ahead. Removing a chip, by\n * keyboard or pointer, moves focus to the chip that takes its place (the one\n * before it when it was last); removing the last chip, or Clear all, moves it to\n * the Add filter button.\n */\n\n/** The toolbar: one pill with a light border on the surface. */\nexport const filterBarVariants = cva([\n \"flex flex-wrap items-center gap-x-3 gap-y-2 rounded-full\",\n \"border-(length:--border-width) border-border bg-surface\",\n \"px-4 py-2 box-border font-sans\",\n]);\n\n/** The group at the pill's end: the Add filter button and the caller's `end`. */\nexport const filterBarRowVariants = cva([\"ms-auto flex flex-wrap items-center gap-2\"]);\n\nexport const filterBarChipVariants = cva([\n \"inline-flex items-center gap-1 rounded-full\",\n \"border-(length:--border-width) border-border bg-muted\",\n \"ps-3 pe-1 text-xs leading-(--leading-snug) text-foreground\",\n \"box-border font-sans\",\n]);\n\n/** One option of a dropdown filter. */\nexport interface FilterBarOption {\n /** The option's value, handed to `onChange`. */\n id: Key;\n /** The option's text: in the list, on the trigger and on the chip. */\n label: string;\n}\n\n/** A dropdown filter: a row in the Add filter menu that opens its options. */\nexport interface FilterBarSelectField {\n /** Stable identity, used as the React key. */\n id: string;\n /** The field's name: its row in the menu and the start of its chip. */\n label: string;\n /** An icon before the name in the menu row. Mark it `aria-hidden`. */\n icon?: ReactNode;\n /** The current value. `null` reads as not set. */\n value: Key | null;\n /** The value that means \"not filtered\" (the \"All\" option's id). */\n emptyValue: Key;\n /** The options, the \"All\" option included. */\n options: readonly FilterBarOption[];\n /** Told the chosen option's id, or `emptyValue` when the chip is removed. */\n onChange: (value: Key) => void;\n}\n\n/**\n * A filter that is not a plain dropdown (a date range, a searchable picker):\n * the caller renders the control and says what its chip reads.\n */\nexport interface FilterBarCustomField {\n /** Stable identity, used as the React key. */\n id: string;\n /** The start of its chip, and its row in the menu. The control rendered by `render` names itself. */\n label: string;\n /** An icon before the name in the menu row. Mark it `aria-hidden`. */\n icon?: ReactNode;\n /** The control, rendered in the menu's step for this filter. */\n render: () => ReactNode;\n /** The chip's value text while the filter is set, or `null` while it is not. */\n activeLabel: string | null;\n /** Unsets the filter: the chip's remove button and Clear all call it. */\n onClear: () => void;\n}\n\nexport type FilterBarField = FilterBarSelectField | FilterBarCustomField;\n\nexport interface FilterBarProps {\n /** Always in the pill, at its start: status tabs and the search box. */\n inline?: ReactNode;\n /** Always in the pill, at its end: a count, Export, a view toggle. */\n end?: ReactNode;\n /** The filters in the Add filter menu. With none, no button renders. */\n filters: readonly FilterBarField[];\n /** Resets every filter in one step. Without it, each set filter is handed its `emptyValue`. */\n onClearAll?: () => void;\n /**\n * The button's text, and the menu's name.\n *\n * @default \"Add filter\"\n */\n buttonLabel?: string;\n /**\n * The text of Clear all.\n *\n * @default \"Clear all\"\n */\n clearAllLabel?: string;\n /**\n * Ignored: the menu has no Done button, because choosing an option closes it.\n *\n * @deprecated Kept so existing callers still compile.\n */\n doneLabel?: string;\n /**\n * The note shown while no filter is set.\n *\n * @default \"No filters applied\"\n */\n emptyLabel?: string;\n /**\n * The accessible name of the list of chips.\n *\n * @default \"Filters set\"\n */\n chipsLabel?: string;\n /**\n * The accessible name of a chip's remove button, given the chip's text.\n *\n * @default (text) => `Remove filter ${text}`\n */\n removeLabel?: (chipText: string) => string;\n /**\n * The accessible name of the menu's back button.\n *\n * @default \"Back to filters\"\n */\n backLabel?: string;\n /** Extra classes on the root. Merged last, so they win conflicts with ours. */\n className?: string;\n}\n\nconst isCustom = (f: FilterBarField): f is FilterBarCustomField => \"render\" in f;\n\ninterface Chip {\n id: string;\n text: string;\n clear: () => void;\n}\n\n/** What a filter holds now, for its menu row and its chip: `null` while it is not set. */\nfunction valueOf(f: FilterBarField): string | null {\n if (isCustom(f)) return f.activeLabel;\n if (f.value == null || f.value === f.emptyValue) return null;\n const option = f.options.find((o) => o.id === f.value);\n return option ? option.label : String(f.value);\n}\n\n/** The set filters, each with its chip text and how to unset it. */\nfunction chipsFor(filters: readonly FilterBarField[]): Chip[] {\n const chips: Chip[] = [];\n for (const f of filters) {\n const value = valueOf(f);\n if (value == null) continue;\n chips.push({\n id: f.id,\n text: `${f.label}: ${value}`,\n clear: isCustom(f) ? f.onClear : () => f.onChange(f.emptyValue),\n });\n }\n return chips;\n}\n\nfunction Glyph({ path }: { path: string }) {\n return (\n <svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" aria-hidden=\"true\">\n <path d={path} strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n </svg>\n );\n}\n\nconst PlusGlyph = () => <Glyph path=\"M8 3v10M3 8h10\" />;\nconst CrossGlyph = () => <Glyph path=\"M4 4l8 8M12 4l-8 8\" />;\nconst ChevronRightGlyph = () => <Glyph path=\"M6 3l5 5-5 5\" />;\nconst ChevronLeftGlyph = () => <Glyph path=\"M10 3 5 8l5 5\" />;\nconst CheckGlyph = () => <Glyph path=\"M3 8.5l3.5 3.5L13 4.5\" />;\n\n/**\n * A list toolbar with its filters behind one Add filter button.\n *\n * @example\n * <FilterBar\n * end={<Button>Export</Button>}\n * filters={[{ id: \"status\", label: \"Status\", icon: <StatusIcon aria-hidden />, value, emptyValue: \"all\",\n * onChange: setValue, options: [{ id: \"all\", label: \"Any status\" }, { id: \"open\", label: \"Open\" }] }]}\n * />\n */\n/**\n * One place in the chip row, for a chip or for Clear all. A slot added after the\n * first render opens from nothing while its content fades up and scales in, so the\n * chips beside it glide over; one that is removed stays, shrinking and collapsing,\n * until its exit ends. Nothing animates at first render.\n */\nfunction Slot({\n leaving,\n ready,\n onDone,\n id,\n children,\n}: {\n leaving: boolean;\n ready: boolean;\n onDone: (id: string) => void;\n id: string;\n children: ReactNode;\n}) {\n // Decided once, at this slot's own mount.\n const [fresh] = useState(ready);\n const remove = useCallback(() => onDone(id), [onDone, id]);\n useExitBackstop(leaving, remove, 900);\n // Reduced motion removes it at once: an animation that is switched off never ends.\n useIsomorphicLayoutEffect(() => {\n if (leaving && prefersReducedMotion()) remove();\n }, [leaving, remove]);\n return (\n <li\n data-slot={leaving ? \"filter-bar-leaving\" : undefined}\n aria-hidden={leaving || undefined}\n ref={(node) => {\n if (!node) return;\n if (leaving) node.setAttribute(\"inert\", \"\");\n else node.removeAttribute(\"inert\");\n }}\n onAnimationEnd={(event) => {\n if (leaving && event.target === event.currentTarget && event.animationName === \"chip-slot-out\")\n remove();\n }}\n className={cnAll(\n \"min-w-0 flex-none [interpolate-size:allow-keywords]\",\n leaving\n ? \"pointer-events-none motion-safe:animate-chip-out\"\n : fresh && \"motion-safe:animate-chip-in motion-reduce:animate-fade-in\",\n )}\n >\n {children}\n </li>\n );\n}\n\n/**\n * A chip's remove button. It registers itself with the bar under its chip's id\n * through a ref callback that stays the same across renders, so React does not\n * detach and re-attach it on every commit.\n */\nfunction ChipRemove({\n id,\n label,\n register,\n onPress,\n}: {\n id: string;\n label: string;\n register: (id: string, node: HTMLButtonElement | null) => void;\n onPress: () => void;\n}) {\n const ref = useCallback((node: HTMLButtonElement | null) => register(id, node), [register, id]);\n return (\n <Button\n ref={ref}\n variant=\"ghost\"\n size=\"sm\"\n aria-label={label}\n onPress={onPress}\n // The ring is drawn inside the button, which fits inside the chip: the\n // default ring sits outside, stood taller than the chip and covered the end\n // of its label.\n className=\"h-6 rounded-full px-1 data-[focus-visible]:-outline-offset-(--focus-ring-width)\"\n >\n <CrossGlyph />\n </Button>\n );\n}\n\n/** A chip's text: a changed value swaps (the new text rises in) and the chip's width springs to it. */\nfunction ChipText({ text }: { text: string }) {\n const content = useRef<HTMLSpanElement>(null);\n useMorphWidth(content, text);\n return (\n <span className=\"button-slot\">\n <span ref={content} className=\"inline-flex flex-none items-center\">\n <SwapText swapKey={text} className=\"inline-block\">\n {text}\n </SwapText>\n </span>\n </span>\n );\n}\n\n/** A filter's icon, drawn at the control icon size in the secondary ink. */\nfunction RowIcon({ icon }: { icon: ReactNode }) {\n return icon ? (\n <span aria-hidden=\"true\" className=\"inline-flex text-text-secondary\">\n {icon}\n </span>\n ) : null;\n}\n\n/** The note for no filters. Arriving after the first render (the last chip has gone) it fades in; at first render it does not. */\nfunction EmptyNote({ ready, children }: { ready: boolean; children: ReactNode }) {\n // Decided once, at this note's own mount: adding the class later would start the animation on a note already on screen.\n const [fresh] = useState(ready);\n return (\n <span\n data-slot=\"filter-bar-empty\"\n className={cn(\"text-sm text-text-secondary\" + (fresh ? \" motion-safe:animate-fade-in\" : \"\"))}\n >\n {children}\n </span>\n );\n}\n\nconst STEP = \"motion-safe:animate-filter-step-in motion-reduce:animate-fade-in\";\n\n/**\n * The Add filter menu's panel, in two steps: the list of filters, then the\n * chosen filter's options (or its own control). A step arrives from the side it\n * was reached from; the first one, on open, does not animate by itself (the\n * popover grows from the button instead).\n */\nfunction FilterPanel({\n filters,\n label,\n backLabel,\n onClose,\n}: {\n filters: readonly FilterBarField[];\n label: string;\n backLabel: string;\n onClose: () => void;\n}) {\n const [fieldId, setFieldId] = useState<string | null>(null);\n // null until the user has navigated: the first step does not animate by itself.\n const [dir, setDir] = useState<1 | -1 | null>(null);\n // The filter whose step we came back from: focus returns to its row.\n const [returnTo, setReturnTo] = useState<string | null>(null);\n const listRef = useRef<HTMLDivElement>(null);\n const bindFields = useMenuHighlight();\n const bindOptions = useMenuHighlight();\n const field = filters.find((f) => f.id === fieldId) ?? null;\n\n // Coming back, focus lands on the row that was opened rather than on the first one.\n // The rows of a React Aria menu land a frame after it mounts, so this waits for the row.\n useEffect(() => {\n if (field || returnTo === null) return;\n const id = returnTo;\n let frame = 0;\n let tries = 0;\n const focusRow = () => {\n const row = Array.from(\n listRef.current?.querySelectorAll<HTMLElement>(\"[data-slot='menu-item']\") ?? [],\n ).find((node) => node.dataset.key === id);\n if (row) row.focus();\n else if (tries++ < 20) frame = requestAnimationFrame(focusRow);\n };\n focusRow();\n return () => cancelAnimationFrame(frame);\n }, [field, returnTo]);\n\n const open = (id: Key) => {\n setDir(1);\n setFieldId(String(id));\n };\n const back = () => {\n if (!field) return;\n setReturnTo(field.id);\n setDir(-1);\n setFieldId(null);\n };\n const stepClass = dir === null ? \"\" : cn(STEP + (dir === 1 ? \" [--step-dir:1]\" : \" [--step-dir:-1]\"));\n\n return (\n <div data-slot=\"filter-bar-panel\" className=\"overflow-x-clip\">\n {field ? (\n <div\n key={field.id}\n className={stepClass}\n onKeyDown={(event) => {\n // Arrow left steps back, from the options list; a custom control keeps its own keys.\n if (event.key === \"ArrowLeft\" && !isCustom(field)) {\n event.preventDefault();\n back();\n }\n }}\n >\n <div className=\"flex items-center gap-1 px-1 pb-1\">\n <Button\n variant=\"ghost\"\n size=\"icon\"\n aria-label={backLabel}\n onPress={back}\n className=\"size-8 flex-none rounded-full\"\n >\n <ChevronLeftGlyph />\n </Button>\n <span className=\"min-w-0 truncate text-sm font-medium text-foreground\">{field.label}</span>\n </div>\n {isCustom(field) ? (\n <div className=\"p-1.5\">{field.render()}</div>\n ) : (\n <AriaMenu\n ref={bindOptions}\n aria-label={field.label}\n data-slot=\"filter-bar-options\"\n autoFocus=\"first\"\n selectionMode=\"single\"\n disallowEmptySelection\n selectedKeys={field.value == null ? [] : [field.value]}\n onAction={(key) => {\n field.onChange(key);\n onClose();\n }}\n className={menuVariants()}\n >\n {field.options.map((o) => (\n <MenuItem key={o.id} id={o.id} textValue={o.label}>\n <span className=\"min-w-0 flex-1 truncate\">{o.label}</span>\n <span\n aria-hidden=\"true\"\n className={\n o.id === field.value\n ? \"inline-flex text-foreground\"\n : \"inline-flex text-foreground opacity-0\"\n }\n >\n <CheckGlyph />\n </span>\n </MenuItem>\n ))}\n </AriaMenu>\n )}\n </div>\n ) : (\n <div key=\"fields\" ref={listRef} className={stepClass}>\n <AriaMenu\n ref={bindFields}\n aria-label={label}\n data-slot=\"filter-bar-fields\"\n autoFocus={returnTo === null ? \"first\" : false}\n onAction={open}\n // Choosing a filter opens its options; it must not close the panel.\n onClose={() => {}}\n className={menuVariants()}\n >\n {filters.map((f) => {\n const value = valueOf(f);\n return (\n <MenuItem key={f.id} id={f.id} textValue={f.label}>\n <RowIcon icon={f.icon} />\n <span className=\"min-w-0 flex-1 truncate\">{f.label}</span>\n {value ? (\n <span className=\"max-w-24 truncate text-xs text-text-secondary\">{value}</span>\n ) : null}\n <span aria-hidden=\"true\" className=\"inline-flex text-text-secondary\">\n <ChevronRightGlyph />\n </span>\n </MenuItem>\n );\n })}\n </AriaMenu>\n </div>\n )}\n </div>\n );\n}\n\nexport const FilterBar = forwardRef<HTMLDivElement, FilterBarProps>(function FilterBar(\n {\n inline,\n end,\n filters,\n onClearAll,\n buttonLabel = \"Add filter\",\n clearAllLabel = \"Clear all\",\n emptyLabel = \"No filters applied\",\n chipsLabel = \"Filters set\",\n removeLabel = (text) => `Remove filter ${text}`,\n backLabel = \"Back to filters\",\n className,\n },\n ref,\n) {\n const [open, setOpen] = useState(false);\n const chips = chipsFor(filters);\n const [ready, setReady] = useState(false);\n useEffect(() => {\n // A frame after mount: chips added from then on arrive animated, the first ones do not.\n const frame = requestAnimationFrame(() => setReady(true));\n return () => cancelAnimationFrame(frame);\n }, []);\n // The chips, with Clear all as the row's last place, each kept on screen while it leaves.\n const places = useLeavingList([\n ...chips.map((chip) => ({ id: chip.id, chip })),\n ...(chips.length > 0 ? [{ id: \"\\u0000clear\", chip: null }] : []),\n ]);\n const frame = useRef<HTMLDivElement>(null);\n const row = useRef<HTMLUListElement>(null);\n // The row follows its height when a chip makes a line wrap or unwrap.\n const chipKey = chips.map((chip) => chip.id).join();\n useHeightFollow(frame, row, chipKey);\n // A removed chip's slot turns inert as it leaves, which would drop focus to the\n // page: where focus goes instead is noted on the press (the removed chip's index,\n // or \"add\" for Clear all) and moved once the caller's change has removed it.\n const removeButtons = useRef(new Map<string, HTMLButtonElement>());\n const registerRemove = useCallback((id: string, node: HTMLButtonElement | null) => {\n if (node) removeButtons.current.set(id, node);\n else removeButtons.current.delete(id);\n }, []);\n const addButton = useRef<HTMLButtonElement>(null);\n const focusAfter = useRef<number | \"add\" | null>(null);\n const noteFocusAfter = (after: number | \"add\") => {\n focusAfter.current = after;\n // A caller that ignores the press changes no chip, so the note is never\n // applied; drop it a frame later, or it would move focus on some later,\n // unrelated change to the chips. A caller that does change them commits\n // inside the press, before this frame.\n requestAnimationFrame(() => {\n if (focusAfter.current === after) focusAfter.current = null;\n });\n };\n useIsomorphicLayoutEffect(() => {\n const after = focusAfter.current;\n if (after === null) return;\n focusAfter.current = null;\n const next =\n after === \"add\" || chips.length === 0 ? null : chips[Math.min(after, chips.length - 1)]!;\n (next ? removeButtons.current.get(next.id) : addButton.current)?.focus();\n }, [chipKey]);\n const clearAll = () => {\n noteFocusAfter(\"add\");\n if (onClearAll) onClearAll();\n else for (const c of chips) c.clear();\n };\n\n return (\n <div ref={ref} data-slot=\"filter-bar\" className={cn(filterBarVariants(), className)}>\n {inline ? <div className=\"flex flex-wrap items-center gap-2\">{inline}</div> : null}\n <div\n data-slot=\"filter-bar-chips-area\"\n className=\"flex min-h-(--density-control-h-sm) min-w-0 flex-[1_1_8rem] items-center\"\n >\n {places.list.length > 0 ? (\n <div ref={frame} data-slot=\"filter-bar-chips-frame\" className=\"height-follow min-w-0\">\n <ul\n ref={row}\n aria-label={chipsLabel}\n data-slot=\"filter-bar-chips\"\n className=\"m-0 flex list-none flex-wrap items-center gap-2 p-0\"\n >\n {places.list.map(({ item, leaving }) =>\n item.chip ? (\n <Slot key={item.id} id={item.id} leaving={leaving} ready={ready} onDone={places.done}>\n <span data-slot=\"filter-bar-chip\" className={filterBarChipVariants()}>\n <ChipText text={item.chip.text} />\n <ChipRemove\n id={item.id}\n label={removeLabel(item.chip.text)}\n register={registerRemove}\n onPress={() => {\n noteFocusAfter(chips.findIndex((chip) => chip.id === item.id));\n item.chip.clear();\n }}\n />\n </span>\n </Slot>\n ) : (\n <Slot key={item.id} id={item.id} leaving={leaving} ready={ready} onDone={places.done}>\n <Button variant=\"link\" size=\"sm\" onPress={clearAll}>\n {clearAllLabel}\n </Button>\n </Slot>\n ),\n )}\n </ul>\n </div>\n ) : (\n <EmptyNote ready={ready}>{emptyLabel}</EmptyNote>\n )}\n </div>\n {filters.length > 0 || end ? (\n <div data-slot=\"filter-bar-actions\" className={filterBarRowVariants()}>\n {filters.length > 0 ? (\n <PopoverTrigger isOpen={open} onOpenChange={setOpen}>\n <Button\n ref={addButton}\n variant=\"outlineNeutral\"\n size=\"sm\"\n startIcon={<PlusGlyph />}\n data-slot=\"filter-bar-button\"\n >\n {buttonLabel}\n </Button>\n <Popover\n placement=\"bottom end\"\n offset={8}\n className=\"w-64 max-w-[calc(100vw-2rem)] p-1.5\"\n >\n <FilterPanel\n filters={filters}\n label={buttonLabel}\n backLabel={backLabel}\n onClose={() => setOpen(false)}\n />\n </Popover>\n </PopoverTrigger>\n ) : null}\n {end}\n </div>\n ) : null}\n </div>\n );\n});\n"],"mappings":"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"}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { ComponentPropsWithoutRef } from "react";
|
|
2
|
+
//#region src/gauge/gauge.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* Gauge: one value against a range, as a ring open at the bottom with the
|
|
5
|
+
* number at its centre. Use it for a score or a fill level; use Progress for a
|
|
6
|
+
* task that finishes.
|
|
7
|
+
*
|
|
8
|
+
* The ring fills the first time it is seen, slowly and without overshoot, like
|
|
9
|
+
* a ring closing. After that it springs to each new value with a little life
|
|
10
|
+
* while the count follows without overshooting. A band that starts at
|
|
11
|
+
* `from` sets the colour and names the state ("Healthy", "At risk"): the
|
|
12
|
+
* highest band the value reaches wins, and it changes exactly as the count
|
|
13
|
+
* crosses the threshold, so state never rests on colour alone. The label,
|
|
14
|
+
* status and detail rise in when they change, from the side the value moved
|
|
15
|
+
* toward.
|
|
16
|
+
*
|
|
17
|
+
* Plain SVG and CSS. One small spring tween writes the ring and the count
|
|
18
|
+
* straight to the DOM. The ring is a `meter` for assistive technology.
|
|
19
|
+
*/
|
|
20
|
+
export type GaugeTone = "primary" | "success" | "warning" | "danger";
|
|
21
|
+
export interface GaugeThreshold {
|
|
22
|
+
/** The band starts at this value (in the same unit as `value`). */
|
|
23
|
+
from: number;
|
|
24
|
+
tone: GaugeTone;
|
|
25
|
+
/** Names the state. Shown under the number and announced with the value. */
|
|
26
|
+
label: string;
|
|
27
|
+
}
|
|
28
|
+
export interface GaugeProps extends Omit<ComponentPropsWithoutRef<"figure">, "children"> {
|
|
29
|
+
value: number;
|
|
30
|
+
/** @default 0 */
|
|
31
|
+
min?: number;
|
|
32
|
+
/** @default 100 */
|
|
33
|
+
max?: number;
|
|
34
|
+
/** What is measured. Names the meter. */
|
|
35
|
+
label: string;
|
|
36
|
+
/** A line under the label, such as "Up 4 from last week". */
|
|
37
|
+
detail?: string;
|
|
38
|
+
/** The colour when no band applies. @default "primary" */
|
|
39
|
+
tone?: GaugeTone;
|
|
40
|
+
/** Bands that set the tone and name the state. */
|
|
41
|
+
thresholds?: GaugeThreshold[];
|
|
42
|
+
}
|
|
43
|
+
export declare const Gauge: import("react").ForwardRefExoticComponent<GaugeProps & import("react").RefAttributes<HTMLElement>>;
|
|
44
|
+
//#endregion
|
|
45
|
+
//# sourceMappingURL=gauge.d.ts.map
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { cn } from "../lib/cn.js";
|
|
3
|
+
import { mergeRefs } from "../lib/merge-refs.js";
|
|
4
|
+
import { prefersReducedMotion, useIsomorphicLayoutEffect } from "../lib/motion.js";
|
|
5
|
+
import { SwapText } from "../lib/swap-text.js";
|
|
6
|
+
import { MORPH, REVEAL, SMOOTH, live, retarget } from "../lib/spring-tween.js";
|
|
7
|
+
import { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
8
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
9
|
+
//#region src/gauge/gauge.tsx
|
|
10
|
+
/** A 270 degree ring open at the bottom, drawn clockwise from its lower left end. Redrawn from the sweep every frame so the round cap stays exact. */
|
|
11
|
+
const RADIUS = 42;
|
|
12
|
+
const START = -135;
|
|
13
|
+
const SPAN = 270;
|
|
14
|
+
const clamp = (value, low, high) => Math.min(high, Math.max(low, value));
|
|
15
|
+
function ringFor(sweep) {
|
|
16
|
+
const end = START + SPAN * clamp(sweep, 0, 1);
|
|
17
|
+
const at = (angle) => `${(50 + RADIUS * Math.sin(angle * Math.PI / 180)).toFixed(3)} ${(50 - RADIUS * Math.cos(angle * Math.PI / 180)).toFixed(3)}`;
|
|
18
|
+
return `M ${at(START)} A ${RADIUS} ${RADIUS} 0 ${end - START > 180 ? 1 : 0} 1 ${at(end)}`;
|
|
19
|
+
}
|
|
20
|
+
const TRACK = ringFor(1);
|
|
21
|
+
const Gauge = forwardRef(function Gauge({ value, min = 0, max = 100, label, detail, tone = "primary", thresholds, className, ...props }, ref) {
|
|
22
|
+
const safeMax = max > min ? max : min + 1;
|
|
23
|
+
const percentage = clamp((value - min) / (safeMax - min), 0, 1);
|
|
24
|
+
const displayValue = Math.round(percentage * 100);
|
|
25
|
+
const bands = [...thresholds ?? []].sort((a, b) => a.from - b.from);
|
|
26
|
+
const bandAt = (at) => bands.filter((band) => at >= band.from - 1e-6).pop();
|
|
27
|
+
const band = bandAt(value);
|
|
28
|
+
const figure = useRef(null);
|
|
29
|
+
const refs = useMemo(() => mergeRefs(ref, figure), [ref]);
|
|
30
|
+
const ring = useRef(null);
|
|
31
|
+
const digits = useRef(null);
|
|
32
|
+
const sizer = useRef(null);
|
|
33
|
+
const sweep = useRef(live(0));
|
|
34
|
+
const count = useRef(live(0));
|
|
35
|
+
const [seen, setSeen] = useState(false);
|
|
36
|
+
const [filled, setFilled] = useState(false);
|
|
37
|
+
const [shownBand, setShownBand] = useState(band);
|
|
38
|
+
const [moved, setMoved] = useState({
|
|
39
|
+
value: displayValue,
|
|
40
|
+
direction: 1
|
|
41
|
+
});
|
|
42
|
+
if (moved.value !== displayValue) setMoved({
|
|
43
|
+
value: displayValue,
|
|
44
|
+
direction: displayValue < moved.value ? -1 : 1
|
|
45
|
+
});
|
|
46
|
+
const latest = useRef({
|
|
47
|
+
bandAt,
|
|
48
|
+
min,
|
|
49
|
+
span: safeMax - min,
|
|
50
|
+
filled
|
|
51
|
+
});
|
|
52
|
+
useIsomorphicLayoutEffect(() => {
|
|
53
|
+
latest.current = {
|
|
54
|
+
bandAt,
|
|
55
|
+
min,
|
|
56
|
+
span: safeMax - min,
|
|
57
|
+
filled
|
|
58
|
+
};
|
|
59
|
+
});
|
|
60
|
+
const shownRef = useRef(shownBand);
|
|
61
|
+
useIsomorphicLayoutEffect(() => {
|
|
62
|
+
shownRef.current = shownBand;
|
|
63
|
+
});
|
|
64
|
+
/** One writer: the ring, its fade-in over the first sliver, and the count. */
|
|
65
|
+
const paint = useCallback(() => {
|
|
66
|
+
const to = clamp(sweep.current.value, 0, 1);
|
|
67
|
+
ring.current?.setAttribute("d", ringFor(to));
|
|
68
|
+
if (ring.current) ring.current.style.opacity = String(clamp(to / .02, 0, 1));
|
|
69
|
+
const shown = clamp(count.current.value, 0, 100);
|
|
70
|
+
if (digits.current) digits.current.textContent = String(Math.round(shown));
|
|
71
|
+
const { bandAt: pick, min: lo, span, filled: done } = latest.current;
|
|
72
|
+
if (done) {
|
|
73
|
+
const next = pick(lo + shown / 100 * span);
|
|
74
|
+
const current = shownRef.current;
|
|
75
|
+
if (next?.from !== current?.from || next?.label !== current?.label) {
|
|
76
|
+
shownRef.current = next;
|
|
77
|
+
setShownBand(next);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}, []);
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
const node = figure.current;
|
|
83
|
+
if (!node || prefersReducedMotion() || typeof IntersectionObserver === "undefined") {
|
|
84
|
+
setSeen(true);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const observer = new IntersectionObserver(([entry]) => {
|
|
88
|
+
if (!entry?.isIntersecting) return;
|
|
89
|
+
observer.disconnect();
|
|
90
|
+
setSeen(true);
|
|
91
|
+
}, { threshold: .5 });
|
|
92
|
+
observer.observe(node);
|
|
93
|
+
return () => observer.disconnect();
|
|
94
|
+
}, []);
|
|
95
|
+
const bandRef = useRef(band);
|
|
96
|
+
useIsomorphicLayoutEffect(() => {
|
|
97
|
+
bandRef.current = band;
|
|
98
|
+
});
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
if (!seen) return;
|
|
101
|
+
const first = !latest.current.filled;
|
|
102
|
+
retarget(sweep.current, percentage, first ? REVEAL : MORPH, paint);
|
|
103
|
+
retarget(count.current, percentage * 100, first ? REVEAL : SMOOTH, paint, 0, first ? () => {
|
|
104
|
+
latest.current.filled = true;
|
|
105
|
+
shownRef.current = bandRef.current;
|
|
106
|
+
setShownBand(bandRef.current);
|
|
107
|
+
setFilled(true);
|
|
108
|
+
} : void 0);
|
|
109
|
+
}, [
|
|
110
|
+
percentage,
|
|
111
|
+
seen,
|
|
112
|
+
paint
|
|
113
|
+
]);
|
|
114
|
+
useEffect(() => {
|
|
115
|
+
const sweeping = sweep.current;
|
|
116
|
+
const number = count.current;
|
|
117
|
+
return () => {
|
|
118
|
+
sweeping.stop();
|
|
119
|
+
number.stop();
|
|
120
|
+
};
|
|
121
|
+
}, []);
|
|
122
|
+
useIsomorphicLayoutEffect(paint, [paint]);
|
|
123
|
+
useEffect(() => {
|
|
124
|
+
const node = sizer.current;
|
|
125
|
+
const box = digits.current;
|
|
126
|
+
if (!node || !box || typeof ResizeObserver === "undefined") return;
|
|
127
|
+
const observer = new ResizeObserver(([entry]) => {
|
|
128
|
+
if (!entry) return;
|
|
129
|
+
box.style.width = `${entry.borderBoxSize?.[0]?.inlineSize ?? node.offsetWidth}px`;
|
|
130
|
+
});
|
|
131
|
+
observer.observe(node);
|
|
132
|
+
return () => observer.disconnect();
|
|
133
|
+
}, []);
|
|
134
|
+
const shown = filled ? shownBand : band;
|
|
135
|
+
const activeTone = (filled ? shownBand : band)?.tone ?? tone;
|
|
136
|
+
const clampedValue = clamp(value, min, safeMax);
|
|
137
|
+
return /* @__PURE__ */ jsxs("figure", {
|
|
138
|
+
ref: refs,
|
|
139
|
+
...props,
|
|
140
|
+
"data-slot": "gauge",
|
|
141
|
+
"data-tone": activeTone,
|
|
142
|
+
className: cn("gauge", className),
|
|
143
|
+
children: [/* @__PURE__ */ jsxs("div", {
|
|
144
|
+
"data-slot": "gauge-visual",
|
|
145
|
+
role: "meter",
|
|
146
|
+
"aria-label": label,
|
|
147
|
+
"aria-valuemin": min,
|
|
148
|
+
"aria-valuemax": safeMax,
|
|
149
|
+
"aria-valuenow": clampedValue,
|
|
150
|
+
"aria-valuetext": `${displayValue}%${band ? `, ${band.label}` : ""}`,
|
|
151
|
+
className: "gauge-visual",
|
|
152
|
+
children: [/* @__PURE__ */ jsxs("svg", {
|
|
153
|
+
viewBox: "0 0 100 90",
|
|
154
|
+
"aria-hidden": "true",
|
|
155
|
+
focusable: "false",
|
|
156
|
+
className: "block size-full overflow-visible",
|
|
157
|
+
children: [/* @__PURE__ */ jsx("path", {
|
|
158
|
+
d: TRACK,
|
|
159
|
+
className: "gauge-track"
|
|
160
|
+
}), /* @__PURE__ */ jsx("path", {
|
|
161
|
+
ref: ring,
|
|
162
|
+
className: "gauge-fill",
|
|
163
|
+
style: { opacity: 0 }
|
|
164
|
+
})]
|
|
165
|
+
}), /* @__PURE__ */ jsxs("div", {
|
|
166
|
+
"aria-hidden": "true",
|
|
167
|
+
className: "gauge-readout",
|
|
168
|
+
children: [/* @__PURE__ */ jsxs("span", {
|
|
169
|
+
className: "gauge-number",
|
|
170
|
+
children: [
|
|
171
|
+
/* @__PURE__ */ jsx("span", {
|
|
172
|
+
ref: digits,
|
|
173
|
+
className: "gauge-digits"
|
|
174
|
+
}),
|
|
175
|
+
/* @__PURE__ */ jsx("span", {
|
|
176
|
+
className: "gauge-unit",
|
|
177
|
+
children: "%"
|
|
178
|
+
}),
|
|
179
|
+
/* @__PURE__ */ jsx("span", {
|
|
180
|
+
ref: sizer,
|
|
181
|
+
className: "gauge-sizer",
|
|
182
|
+
children: displayValue
|
|
183
|
+
})
|
|
184
|
+
]
|
|
185
|
+
}), thresholds ? /* @__PURE__ */ jsx("span", {
|
|
186
|
+
className: "gauge-status",
|
|
187
|
+
children: /* @__PURE__ */ jsx(SwapText, {
|
|
188
|
+
swapKey: filled ? shown?.label ?? "" : "",
|
|
189
|
+
dir: moved.direction,
|
|
190
|
+
className: "block",
|
|
191
|
+
children: shown?.label
|
|
192
|
+
})
|
|
193
|
+
}) : null]
|
|
194
|
+
})]
|
|
195
|
+
}), /* @__PURE__ */ jsxs("figcaption", {
|
|
196
|
+
className: "grid min-w-0 gap-0.5",
|
|
197
|
+
children: [/* @__PURE__ */ jsx("strong", {
|
|
198
|
+
className: "text-sm font-medium text-foreground",
|
|
199
|
+
children: /* @__PURE__ */ jsx(SwapText, {
|
|
200
|
+
swapKey: label,
|
|
201
|
+
className: "block",
|
|
202
|
+
children: label
|
|
203
|
+
})
|
|
204
|
+
}), detail ? /* @__PURE__ */ jsx("span", {
|
|
205
|
+
className: "text-xs tabular-nums text-text-secondary",
|
|
206
|
+
children: /* @__PURE__ */ jsx(SwapText, {
|
|
207
|
+
swapKey: detail,
|
|
208
|
+
dir: moved.direction,
|
|
209
|
+
className: "block",
|
|
210
|
+
children: detail
|
|
211
|
+
})
|
|
212
|
+
}) : null]
|
|
213
|
+
})]
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
//#endregion
|
|
217
|
+
export { Gauge };
|
|
218
|
+
|
|
219
|
+
//# sourceMappingURL=gauge.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"gauge.js","names":[],"sources":["../../src/gauge/gauge.tsx"],"sourcesContent":["\"use client\";\n\nimport {\n forwardRef,\n useCallback,\n useEffect,\n useMemo,\n useRef,\n useState,\n type ComponentPropsWithoutRef,\n} from \"react\";\n\nimport { cn } from \"../lib/cn.js\";\nimport { mergeRefs } from \"../lib/merge-refs.js\";\nimport { prefersReducedMotion, useIsomorphicLayoutEffect } from \"../lib/motion.js\";\nimport { live, MORPH, retarget, REVEAL, SMOOTH } from \"../lib/spring-tween.js\";\nimport { SwapText } from \"../lib/swap-text.js\";\n\n/**\n * Gauge: one value against a range, as a ring open at the bottom with the\n * number at its centre. Use it for a score or a fill level; use Progress for a\n * task that finishes.\n *\n * The ring fills the first time it is seen, slowly and without overshoot, like\n * a ring closing. After that it springs to each new value with a little life\n * while the count follows without overshooting. A band that starts at\n * `from` sets the colour and names the state (\"Healthy\", \"At risk\"): the\n * highest band the value reaches wins, and it changes exactly as the count\n * crosses the threshold, so state never rests on colour alone. The label,\n * status and detail rise in when they change, from the side the value moved\n * toward.\n *\n * Plain SVG and CSS. One small spring tween writes the ring and the count\n * straight to the DOM. The ring is a `meter` for assistive technology.\n */\nexport type GaugeTone = \"primary\" | \"success\" | \"warning\" | \"danger\";\n\nexport interface GaugeThreshold {\n /** The band starts at this value (in the same unit as `value`). */\n from: number;\n tone: GaugeTone;\n /** Names the state. Shown under the number and announced with the value. */\n label: string;\n}\n\nexport interface GaugeProps extends Omit<ComponentPropsWithoutRef<\"figure\">, \"children\"> {\n value: number;\n /** @default 0 */\n min?: number;\n /** @default 100 */\n max?: number;\n /** What is measured. Names the meter. */\n label: string;\n /** A line under the label, such as \"Up 4 from last week\". */\n detail?: string;\n /** The colour when no band applies. @default \"primary\" */\n tone?: GaugeTone;\n /** Bands that set the tone and name the state. */\n thresholds?: GaugeThreshold[];\n}\n\n/** A 270 degree ring open at the bottom, drawn clockwise from its lower left end. Redrawn from the sweep every frame so the round cap stays exact. */\nconst RADIUS = 42;\nconst START = -135;\nconst SPAN = 270;\nconst clamp = (value: number, low: number, high: number) => Math.min(high, Math.max(low, value));\n\nfunction ringFor(sweep: number) {\n const end = START + SPAN * clamp(sweep, 0, 1);\n const at = (angle: number) =>\n `${(50 + RADIUS * Math.sin((angle * Math.PI) / 180)).toFixed(3)} ${(50 - RADIUS * Math.cos((angle * Math.PI) / 180)).toFixed(3)}`;\n return `M ${at(START)} A ${RADIUS} ${RADIUS} 0 ${end - START > 180 ? 1 : 0} 1 ${at(end)}`;\n}\nconst TRACK = ringFor(1);\n\nexport const Gauge = forwardRef<HTMLElement, GaugeProps>(function Gauge(\n { value, min = 0, max = 100, label, detail, tone = \"primary\", thresholds, className, ...props },\n ref,\n) {\n const safeMax = max > min ? max : min + 1;\n const percentage = clamp((value - min) / (safeMax - min), 0, 1);\n const displayValue = Math.round(percentage * 100);\n const bands = [...(thresholds ?? [])].sort((a, b) => a.from - b.from);\n const bandAt = (at: number) => bands.filter((band) => at >= band.from - 1e-6).pop();\n const band = bandAt(value);\n\n const figure = useRef<HTMLElement | null>(null);\n const refs = useMemo(() => mergeRefs(ref, figure), [ref]);\n const ring = useRef<SVGPathElement>(null);\n const digits = useRef<HTMLSpanElement>(null);\n const sizer = useRef<HTMLSpanElement>(null);\n const sweep = useRef(live(0));\n const count = useRef(live(0));\n\n const [seen, setSeen] = useState(false);\n const [filled, setFilled] = useState(false);\n // The band on screen: after the first fill it follows the number as it counts.\n const [shownBand, setShownBand] = useState(band);\n const [moved, setMoved] = useState({ value: displayValue, direction: 1 });\n if (moved.value !== displayValue) {\n setMoved({ value: displayValue, direction: displayValue < moved.value ? -1 : 1 });\n }\n\n const latest = useRef({ bandAt, min, span: safeMax - min, filled });\n useIsomorphicLayoutEffect(() => {\n latest.current = { bandAt, min, span: safeMax - min, filled };\n });\n const shownRef = useRef(shownBand);\n useIsomorphicLayoutEffect(() => {\n shownRef.current = shownBand;\n });\n\n /** One writer: the ring, its fade-in over the first sliver, and the count. */\n const paint = useCallback(() => {\n const to = clamp(sweep.current.value, 0, 1);\n ring.current?.setAttribute(\"d\", ringFor(to));\n if (ring.current) ring.current.style.opacity = String(clamp(to / 0.02, 0, 1));\n const shown = clamp(count.current.value, 0, 100);\n if (digits.current) digits.current.textContent = String(Math.round(shown));\n // After the first fill the state follows the number on screen, so the colour and label change as the count crosses a threshold.\n const { bandAt: pick, min: lo, span, filled: done } = latest.current;\n if (done) {\n const next = pick(lo + (shown / 100) * span);\n const current = shownRef.current;\n if (next?.from !== current?.from || next?.label !== current?.label) {\n shownRef.current = next;\n setShownBand(next);\n }\n }\n }, []);\n\n // The ring fills once half of it is in view.\n useEffect(() => {\n const node = figure.current;\n if (!node || prefersReducedMotion() || typeof IntersectionObserver === \"undefined\") {\n setSeen(true);\n return;\n }\n const observer = new IntersectionObserver(\n ([entry]) => {\n if (!entry?.isIntersecting) return;\n observer.disconnect();\n setSeen(true);\n },\n { threshold: 0.5 },\n );\n observer.observe(node);\n return () => observer.disconnect();\n }, []);\n\n // The first fill is slower and never overshoots; later values spring to place, the count a beat calmer.\n const bandRef = useRef(band);\n useIsomorphicLayoutEffect(() => {\n bandRef.current = band;\n });\n useEffect(() => {\n if (!seen) return;\n const first = !latest.current.filled;\n retarget(sweep.current, percentage, first ? REVEAL : MORPH, paint);\n retarget(\n count.current,\n percentage * 100,\n first ? REVEAL : SMOOTH,\n paint,\n 0,\n first\n ? () => {\n latest.current.filled = true;\n shownRef.current = bandRef.current;\n setShownBand(bandRef.current);\n setFilled(true);\n }\n : undefined,\n );\n }, [percentage, seen, paint]);\n useEffect(() => {\n const sweeping = sweep.current;\n const number = count.current;\n return () => {\n sweeping.stop();\n number.stop();\n };\n }, []);\n useIsomorphicLayoutEffect(paint, [paint]);\n\n // The digits sit right aligned in a box sized to the target number, so the ones column never jumps while counting and the group recentres when the number gains or loses a digit.\n useEffect(() => {\n const node = sizer.current;\n const box = digits.current;\n if (!node || !box || typeof ResizeObserver === \"undefined\") return;\n const observer = new ResizeObserver(([entry]) => {\n if (!entry) return;\n box.style.width = `${entry.borderBoxSize?.[0]?.inlineSize ?? node.offsetWidth}px`;\n });\n observer.observe(node);\n return () => observer.disconnect();\n }, []);\n\n const shown = filled ? shownBand : band;\n const activeTone = (filled ? shownBand : band)?.tone ?? tone;\n const clampedValue = clamp(value, min, safeMax);\n\n return (\n <figure\n ref={refs}\n {...props}\n data-slot=\"gauge\"\n data-tone={activeTone}\n className={cn(\"gauge\", className)}\n >\n <div\n data-slot=\"gauge-visual\"\n role=\"meter\"\n aria-label={label}\n aria-valuemin={min}\n aria-valuemax={safeMax}\n aria-valuenow={clampedValue}\n aria-valuetext={`${displayValue}%${band ? `, ${band.label}` : \"\"}`}\n className=\"gauge-visual\"\n >\n <svg\n viewBox=\"0 0 100 90\"\n aria-hidden=\"true\"\n focusable=\"false\"\n className=\"block size-full overflow-visible\"\n >\n <path d={TRACK} className=\"gauge-track\" />\n <path ref={ring} className=\"gauge-fill\" style={{ opacity: 0 }} />\n </svg>\n <div aria-hidden=\"true\" className=\"gauge-readout\">\n <span className=\"gauge-number\">\n <span ref={digits} className=\"gauge-digits\" />\n <span className=\"gauge-unit\">%</span>\n <span ref={sizer} className=\"gauge-sizer\">\n {displayValue}\n </span>\n </span>\n {thresholds ? (\n <span className=\"gauge-status\">\n <SwapText\n swapKey={filled ? (shown?.label ?? \"\") : \"\"}\n dir={moved.direction}\n className=\"block\"\n >\n {shown?.label}\n </SwapText>\n </span>\n ) : null}\n </div>\n </div>\n <figcaption className=\"grid min-w-0 gap-0.5\">\n <strong className=\"text-sm font-medium text-foreground\">\n <SwapText swapKey={label} className=\"block\">\n {label}\n </SwapText>\n </strong>\n {detail ? (\n <span className=\"text-xs tabular-nums text-text-secondary\">\n <SwapText swapKey={detail} dir={moved.direction} className=\"block\">\n {detail}\n </SwapText>\n </span>\n ) : null}\n </figcaption>\n </figure>\n );\n});\n"],"mappings":"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"}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
//#region src/in-view-title/in-view-title.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* How the title reveals. `blur` sharpens word by word and suits most headings.
|
|
4
|
+
* `word` and `line` rise out of a clip, for editorial pages. `tracking` opens
|
|
5
|
+
* tight letter spacing. `wipe` uncovers the title from left to right through a
|
|
6
|
+
* soft edge.
|
|
7
|
+
*/
|
|
8
|
+
export type InViewTitleVariant = "word" | "line" | "blur" | "tracking" | "wipe";
|
|
9
|
+
/**
|
|
10
|
+
* InViewTitle: a section title that reveals itself when it scrolls into view,
|
|
11
|
+
* once. A title already above the viewport shows at once, and a title rendered
|
|
12
|
+
* on a server appears on its own if scripts are slow, so text is never left
|
|
13
|
+
* hidden. The title is spoken as one heading (the pieces are a picture); reduced
|
|
14
|
+
* motion shows it at once.
|
|
15
|
+
*/
|
|
16
|
+
export interface InViewTitleProps {
|
|
17
|
+
/** The title. */
|
|
18
|
+
text: string;
|
|
19
|
+
/** How it reveals. @default "blur" */
|
|
20
|
+
variant?: InViewTitleVariant;
|
|
21
|
+
/** The element to render. @default "h2" */
|
|
22
|
+
as?: "h1" | "h2" | "h3";
|
|
23
|
+
/** The breaks for the `line` variant. Without them the whole title is one line. */
|
|
24
|
+
lines?: string[];
|
|
25
|
+
/** Reveal only the first time it enters. With false it hides again when it leaves below and replays. @default true */
|
|
26
|
+
once?: boolean;
|
|
27
|
+
id?: string;
|
|
28
|
+
/** Extra classes for the wrapper. Merged last. */
|
|
29
|
+
className?: string;
|
|
30
|
+
}
|
|
31
|
+
export declare const InViewTitle: import("react").ForwardRefExoticComponent<InViewTitleProps & import("react").RefAttributes<HTMLDivElement>>;
|
|
32
|
+
//#endregion
|
|
33
|
+
//# sourceMappingURL=in-view-title.d.ts.map
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { cn } from "../lib/cn.js";
|
|
3
|
+
import { mergeRefs } from "../lib/merge-refs.js";
|
|
4
|
+
import { Fragment, forwardRef, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
|
5
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
6
|
+
//#region src/in-view-title/in-view-title.tsx
|
|
7
|
+
const TYPE = {
|
|
8
|
+
h1: "font-display text-h1 leading-(--leading-snug) m-0 font-semibold",
|
|
9
|
+
h2: "font-display text-h2 leading-(--leading-snug) m-0 font-semibold",
|
|
10
|
+
h3: "font-display text-h3 leading-(--leading-snug) m-0 font-semibold"
|
|
11
|
+
};
|
|
12
|
+
/** A title is revealed when this much of it is on screen. */
|
|
13
|
+
const THRESHOLD = .45;
|
|
14
|
+
const lateBoot = typeof window !== "undefined" && performance.now() > 2400;
|
|
15
|
+
/** Stagger between words (ms): 60 at most, and 400 in all. Lines stagger 80. Letters of the tracking variant move with their word. */
|
|
16
|
+
const WORD_STEP_MS = 60;
|
|
17
|
+
const WORD_MAX_MS = 400;
|
|
18
|
+
const LINE_STEP_MS = 80;
|
|
19
|
+
const TRACKING_STEP_MS = 32;
|
|
20
|
+
/** How far each letter travels per step from its word's centre in the tracking variant. */
|
|
21
|
+
const TRACK_EM = .06;
|
|
22
|
+
const subscribeNothing = () => () => {};
|
|
23
|
+
const delayOf = (ms) => ({ "--part-delay": `${ms}ms` });
|
|
24
|
+
const InViewTitle = forwardRef(function InViewTitle({ text, variant = "blur", as = "h2", lines, once = true, id, className }, ref) {
|
|
25
|
+
const root = useRef(null);
|
|
26
|
+
const refs = useMemo(() => mergeRefs(ref, root), [ref]);
|
|
27
|
+
const hydrated = useSyncExternalStore(subscribeNothing, () => true, () => false);
|
|
28
|
+
const [serverRendered] = useState(!hydrated);
|
|
29
|
+
const late = serverRendered && lateBoot;
|
|
30
|
+
const [seen, setSeen] = useState(false);
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
const node = root.current;
|
|
33
|
+
if (!node) return;
|
|
34
|
+
if (typeof IntersectionObserver === "undefined") {
|
|
35
|
+
setSeen(true);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const observer = new IntersectionObserver(([entry]) => {
|
|
39
|
+
if (!entry) return;
|
|
40
|
+
const passed = !entry.isIntersecting && entry.boundingClientRect.top < 0;
|
|
41
|
+
if (entry.isIntersecting || passed) {
|
|
42
|
+
setSeen(true);
|
|
43
|
+
if (once) observer.disconnect();
|
|
44
|
+
} else if (!once) setSeen(false);
|
|
45
|
+
}, { threshold: THRESHOLD });
|
|
46
|
+
observer.observe(node);
|
|
47
|
+
return () => observer.disconnect();
|
|
48
|
+
}, [once]);
|
|
49
|
+
const Tag = as;
|
|
50
|
+
const words = text.split(" ").filter(Boolean);
|
|
51
|
+
const step = Math.min(WORD_STEP_MS, WORD_MAX_MS / Math.max(words.length, 1));
|
|
52
|
+
const space = (index, total) => index < total - 1 ? " " : null;
|
|
53
|
+
let body = null;
|
|
54
|
+
if (variant === "word") body = words.map((word, index) => /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("span", {
|
|
55
|
+
className: "in-view-title-clip",
|
|
56
|
+
"aria-hidden": "true",
|
|
57
|
+
children: /* @__PURE__ */ jsx("span", {
|
|
58
|
+
"data-part": "",
|
|
59
|
+
style: delayOf(index * step),
|
|
60
|
+
children: word
|
|
61
|
+
})
|
|
62
|
+
}), space(index, words.length)] }, `${word}-${index}`));
|
|
63
|
+
else if (variant === "line") body = (lines?.length ? lines : [text]).map((line, index) => /* @__PURE__ */ jsx("span", {
|
|
64
|
+
className: "in-view-title-line-clip",
|
|
65
|
+
"aria-hidden": "true",
|
|
66
|
+
children: /* @__PURE__ */ jsx("span", {
|
|
67
|
+
"data-part": "",
|
|
68
|
+
style: delayOf(index * LINE_STEP_MS),
|
|
69
|
+
children: line
|
|
70
|
+
})
|
|
71
|
+
}, `${line}-${index}`));
|
|
72
|
+
else if (variant === "blur") body = words.map((word, index) => /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("span", {
|
|
73
|
+
"data-part": "",
|
|
74
|
+
"aria-hidden": "true",
|
|
75
|
+
style: delayOf(index * step),
|
|
76
|
+
children: word
|
|
77
|
+
}), space(index, words.length)] }, `${word}-${index}`));
|
|
78
|
+
else if (variant === "tracking") body = words.map((word, index) => {
|
|
79
|
+
const letters = Array.from(word);
|
|
80
|
+
const centre = (letters.length - 1) / 2;
|
|
81
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("span", {
|
|
82
|
+
className: "whitespace-nowrap",
|
|
83
|
+
"data-part": "",
|
|
84
|
+
"aria-hidden": "true",
|
|
85
|
+
style: delayOf(index * TRACKING_STEP_MS),
|
|
86
|
+
children: letters.map((letter, letterIndex) => /* @__PURE__ */ jsx("span", {
|
|
87
|
+
"data-letter": "",
|
|
88
|
+
style: { "--track-x": `${(centre - letterIndex) * TRACK_EM}em` },
|
|
89
|
+
children: letter
|
|
90
|
+
}, letterIndex))
|
|
91
|
+
}), space(index, words.length)] }, `${word}-${index}`);
|
|
92
|
+
});
|
|
93
|
+
return /* @__PURE__ */ jsx("div", {
|
|
94
|
+
ref: refs,
|
|
95
|
+
"data-slot": "in-view-title",
|
|
96
|
+
"data-variant": variant,
|
|
97
|
+
"data-visible": seen || late ? "" : void 0,
|
|
98
|
+
"data-instant": late ? "" : void 0,
|
|
99
|
+
"data-pending": hydrated ? void 0 : "",
|
|
100
|
+
className: cn("in-view-title", className),
|
|
101
|
+
children: variant === "wipe" ? /* @__PURE__ */ jsx("div", {
|
|
102
|
+
"data-part": "",
|
|
103
|
+
children: /* @__PURE__ */ jsx(Tag, {
|
|
104
|
+
id,
|
|
105
|
+
className: TYPE[as],
|
|
106
|
+
children: text
|
|
107
|
+
})
|
|
108
|
+
}) : /* @__PURE__ */ jsx(Tag, {
|
|
109
|
+
id,
|
|
110
|
+
"aria-label": text,
|
|
111
|
+
className: TYPE[as],
|
|
112
|
+
children: body
|
|
113
|
+
})
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
//#endregion
|
|
117
|
+
export { InViewTitle };
|
|
118
|
+
|
|
119
|
+
//# sourceMappingURL=in-view-title.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"in-view-title.js","names":[],"sources":["../../src/in-view-title/in-view-title.tsx"],"sourcesContent":["\"use client\";\n\nimport {\n Fragment,\n forwardRef,\n useEffect,\n useMemo,\n useRef,\n useState,\n useSyncExternalStore,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\n\nimport { cn } from \"../lib/cn.js\";\nimport { mergeRefs } from \"../lib/merge-refs.js\";\n\n/**\n * How the title reveals. `blur` sharpens word by word and suits most headings.\n * `word` and `line` rise out of a clip, for editorial pages. `tracking` opens\n * tight letter spacing. `wipe` uncovers the title from left to right through a\n * soft edge.\n */\nexport type InViewTitleVariant = \"word\" | \"line\" | \"blur\" | \"tracking\" | \"wipe\";\n\n/**\n * InViewTitle: a section title that reveals itself when it scrolls into view,\n * once. A title already above the viewport shows at once, and a title rendered\n * on a server appears on its own if scripts are slow, so text is never left\n * hidden. The title is spoken as one heading (the pieces are a picture); reduced\n * motion shows it at once.\n */\nexport interface InViewTitleProps {\n /** The title. */\n text: string;\n /** How it reveals. @default \"blur\" */\n variant?: InViewTitleVariant;\n /** The element to render. @default \"h2\" */\n as?: \"h1\" | \"h2\" | \"h3\";\n /** The breaks for the `line` variant. Without them the whole title is one line. */\n lines?: string[];\n /** Reveal only the first time it enters. With false it hides again when it leaves below and replays. @default true */\n once?: boolean;\n id?: string;\n /** Extra classes for the wrapper. Merged last. */\n className?: string;\n}\n\n// Static strings: Tailwind scans source text, so a class built at runtime would never be emitted.\nconst TYPE = {\n h1: \"font-display text-h1 leading-(--leading-snug) m-0 font-semibold\",\n h2: \"font-display text-h2 leading-(--leading-snug) m-0 font-semibold\",\n h3: \"font-display text-h3 leading-(--leading-snug) m-0 font-semibold\",\n} as const;\n\n/** A title is revealed when this much of it is on screen. */\nconst THRESHOLD = 0.45;\n/** Matches the stylesheet fallback: a server-rendered title that scripts reach later than this is already showing, so it stays put. */\nconst FALLBACK_MS = 2400;\nconst lateBoot = typeof window !== \"undefined\" && performance.now() > FALLBACK_MS;\n/** Stagger between words (ms): 60 at most, and 400 in all. Lines stagger 80. Letters of the tracking variant move with their word. */\nconst WORD_STEP_MS = 60;\nconst WORD_MAX_MS = 400;\nconst LINE_STEP_MS = 80;\nconst TRACKING_STEP_MS = 32;\n/** How far each letter travels per step from its word's centre in the tracking variant. */\nconst TRACK_EM = 0.06;\n\nconst subscribeNothing = () => () => {};\nconst delayOf = (ms: number) => ({ \"--part-delay\": `${ms}ms` }) as CSSProperties;\n\nexport const InViewTitle = forwardRef<HTMLDivElement, InViewTitleProps>(function InViewTitle(\n { text, variant = \"blur\", as = \"h2\", lines, once = true, id, className },\n ref,\n) {\n const root = useRef<HTMLDivElement | null>(null);\n const refs = useMemo(() => mergeRefs(ref, root), [ref]);\n const hydrated = useSyncExternalStore(\n subscribeNothing,\n () => true,\n () => false,\n );\n // Rendered on a server, and scripts arrived after the stylesheet fallback had already shown it: nothing to reveal.\n const [serverRendered] = useState(!hydrated);\n const late = serverRendered && lateBoot;\n const [seen, setSeen] = useState(false);\n\n useEffect(() => {\n const node = root.current;\n if (!node) return;\n if (typeof IntersectionObserver === \"undefined\") {\n setSeen(true);\n return;\n }\n const observer = new IntersectionObserver(\n ([entry]) => {\n if (!entry) return;\n // Above the viewport (scrolled past, or restored mid-page) counts as seen: there is nothing to scroll to.\n const passed = !entry.isIntersecting && entry.boundingClientRect.top < 0;\n if (entry.isIntersecting || passed) {\n setSeen(true);\n if (once) observer.disconnect();\n } else if (!once) setSeen(false);\n },\n { threshold: THRESHOLD },\n );\n observer.observe(node);\n return () => observer.disconnect();\n }, [once]);\n\n const Tag = as;\n const words = text.split(\" \").filter(Boolean);\n const step = Math.min(WORD_STEP_MS, WORD_MAX_MS / Math.max(words.length, 1));\n const space = (index: number, total: number) => (index < total - 1 ? \" \" : null);\n\n let body: ReactNode = null;\n if (variant === \"word\") {\n body = words.map((word, index) => (\n <Fragment key={`${word}-${index}`}>\n <span className=\"in-view-title-clip\" aria-hidden=\"true\">\n <span data-part=\"\" style={delayOf(index * step)}>\n {word}\n </span>\n </span>\n {space(index, words.length)}\n </Fragment>\n ));\n } else if (variant === \"line\") {\n body = (lines?.length ? lines : [text]).map((line, index) => (\n <span className=\"in-view-title-line-clip\" aria-hidden=\"true\" key={`${line}-${index}`}>\n <span data-part=\"\" style={delayOf(index * LINE_STEP_MS)}>\n {line}\n </span>\n </span>\n ));\n } else if (variant === \"blur\") {\n body = words.map((word, index) => (\n <Fragment key={`${word}-${index}`}>\n <span data-part=\"\" aria-hidden=\"true\" style={delayOf(index * step)}>\n {word}\n </span>\n {space(index, words.length)}\n </Fragment>\n ));\n } else if (variant === \"tracking\") {\n body = words.map((word, index) => {\n const letters = Array.from(word);\n const centre = (letters.length - 1) / 2;\n return (\n <Fragment key={`${word}-${index}`}>\n <span\n className=\"whitespace-nowrap\"\n data-part=\"\"\n aria-hidden=\"true\"\n style={delayOf(index * TRACKING_STEP_MS)}\n >\n {letters.map((letter, letterIndex) => (\n <span\n key={letterIndex}\n data-letter=\"\"\n style={{ \"--track-x\": `${(centre - letterIndex) * TRACK_EM}em` } as CSSProperties}\n >\n {letter}\n </span>\n ))}\n </span>\n {space(index, words.length)}\n </Fragment>\n );\n });\n }\n\n return (\n <div\n ref={refs}\n data-slot=\"in-view-title\"\n data-variant={variant}\n data-visible={seen || late ? \"\" : undefined}\n data-instant={late ? \"\" : undefined}\n data-pending={hydrated ? undefined : \"\"}\n className={cn(\"in-view-title\", className)}\n >\n {variant === \"wipe\" ? (\n <div data-part=\"\">\n <Tag id={id} className={TYPE[as]}>\n {text}\n </Tag>\n </div>\n ) : (\n <Tag id={id} aria-label={text} className={TYPE[as]}>\n {body}\n </Tag>\n )}\n </div>\n );\n});\n"],"mappings":"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"}
|