@heyramzi/ui 7.0.0 → 8.1.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/COMPONENTS.md +28 -17
- package/README.md +6 -2
- package/package.json +25 -15
- package/scripts/sync-shadcn.mjs +17 -13
- package/scripts/sync-transitions.mjs +11 -11
- package/src/agents/agent-avatar.svelte +67 -114
- package/src/agents/agent-cast.svelte +31 -7
- package/src/agents/demo.svelte +55 -8
- package/src/agents/helpers.ts +58 -0
- package/src/agents/page-state.ts +70 -0
- package/src/agents/types.ts +3 -1
- package/src/ai/ai-button/AiButton.svelte +86 -0
- package/src/ai/ai-button/index.ts +4 -0
- package/src/ai/confirmation/Confirmation.svelte +8 -8
- package/src/ai/confirmation/confirmation-context.svelte.ts +2 -2
- package/src/ai/generation-progress/GenerationProgress.svelte +354 -0
- package/src/ai/generation-progress/index.ts +7 -0
- package/src/ai/index.ts +16 -6
- package/src/ai/prompt-input/PromptInput.svelte +19 -1
- package/src/ai/prompt-input/PromptInputSubmit.svelte +9 -3
- package/src/ai/reasoning/Reasoning.svelte +11 -8
- package/src/ambient.d.ts +4 -0
- package/src/animations/README.md +14 -14
- package/src/animations/index.css +3 -14
- package/src/animations/upstream-coverage.test.ts +1 -1
- package/src/components/copy/clipboard.test.ts +206 -0
- package/src/components/copy/clipboard.ts +49 -0
- package/src/components/copy/copied.svelte.ts +39 -0
- package/src/components/copy/copy-button.svelte +175 -0
- package/src/components/copy/copy-button.test.ts +110 -0
- package/src/components/copy/index.ts +3 -0
- package/src/components/data-table/data-table.test.ts +134 -0
- package/src/components/data-table/data-table.ts +77 -0
- package/src/components/data-table/index.ts +20 -0
- package/src/components/data-table/infinite-scroll.svelte +69 -0
- package/src/components/data-table/pagination.svelte +62 -0
- package/src/components/data-table/row-arrow.svelte +28 -0
- package/src/components/data-table/row-context-menu.svelte +106 -0
- package/src/components/data-table/row-context-menu.test.ts +18 -0
- package/src/components/data-table/sortable-head.svelte +56 -0
- package/src/components/data-table/table-skeleton.svelte +83 -0
- package/src/components/hold-to-confirm/hold-to-confirm.svelte +232 -0
- package/src/components/hold-to-confirm/index.ts +1 -0
- package/src/components/icon/icon.svelte +23 -0
- package/src/components/icon/index.ts +1 -0
- package/src/components/language-flag/index.ts +1 -0
- package/src/components/language-flag/language-flag.svelte +60 -0
- package/src/components/list-page/detail-empty.svelte +50 -0
- package/src/components/list-page/detail-header.svelte +107 -0
- package/src/components/list-page/detail-panel.svelte +90 -0
- package/src/components/list-page/detail-row.svelte +36 -0
- package/src/components/list-page/detail-stack.svelte +33 -0
- package/src/components/list-page/empty-state.svelte +91 -0
- package/src/components/list-page/filter-item.svelte +49 -0
- package/src/components/list-page/filter-popover.svelte +88 -0
- package/src/components/list-page/filter-section.ts +12 -0
- package/src/components/list-page/grouped-list.svelte +116 -0
- package/src/components/list-page/index.ts +28 -0
- package/src/components/list-page/list-page-shell.svelte +195 -0
- package/src/components/list-page/list-table.ts +19 -0
- package/src/components/list-page/master-detail.svelte +206 -0
- package/src/components/list-page/master-detail.test.ts +37 -0
- package/src/components/list-page/notice-bar.svelte +68 -0
- package/src/components/list-page/prev-next.svelte +104 -0
- package/src/components/list-page/queue-rail.ts +25 -0
- package/src/components/list-page/rail-body.svelte +29 -0
- package/src/components/list-page/rail-empty.svelte +51 -0
- package/src/components/list-page/rail-head.svelte +49 -0
- package/src/components/list-page/rail-row.svelte +163 -0
- package/src/components/list-page/section-host.ts +24 -0
- package/src/components/list-page/section-label.svelte +39 -0
- package/src/components/list-page/step.test.ts +21 -0
- package/src/components/list-page/step.ts +10 -0
- package/src/components/list-page/toolbar-search.svelte +63 -0
- package/src/components/list-page/toolbar-trigger.ts +23 -0
- package/src/components/list-page/types.ts +1 -0
- package/src/components/locale-switch/index.ts +1 -0
- package/src/components/locale-switch/locale-switch.svelte +59 -0
- package/src/components/mark/index.ts +1 -0
- package/src/components/mark/mark.svelte +70 -0
- package/src/components/open-in/index.ts +6 -0
- package/src/components/open-in/open-in.svelte +106 -0
- package/src/components/picker/index.ts +4 -0
- package/src/components/picker/mark-picker.svelte +84 -0
- package/src/components/picker/picker.svelte +143 -0
- package/src/components/picker/trigger.ts +42 -0
- package/src/components/picker/types.ts +26 -0
- package/src/components/segmented/animated-tabs.svelte +175 -0
- package/src/components/segmented/collapse.test.ts +49 -0
- package/src/components/segmented/collapse.ts +39 -0
- package/src/components/segmented/index.ts +41 -0
- package/src/components/segmented/section-runs.svelte +79 -0
- package/src/components/segmented/segment.svelte +106 -0
- package/src/components/segmented/segmented-filter.svelte +125 -0
- package/src/components/segmented/segmented-nav.svelte +82 -0
- package/src/components/segmented/segmented-overflow.svelte +85 -0
- package/src/components/segmented/segmented-track.svelte +181 -0
- package/src/components/segmented/segmented.test.ts +20 -0
- package/src/components/segmented/segmented.ts +80 -0
- package/src/components/star-rating-input/index.ts +9 -0
- package/src/components/star-rating-input/star-rating-input.svelte +256 -0
- package/src/components/svg/index.ts +1 -0
- package/src/components/svg/svg.svelte +9 -0
- package/src/effects/border-beam/BorderBeam.svelte +1 -1
- package/src/effects/image-comparison/ImageComparison.css +42 -8
- package/src/effects/image-comparison/ImageComparison.svelte +138 -21
- package/src/effects/image-comparison/compare.test.ts +17 -0
- package/src/effects/image-comparison/compare.ts +5 -0
- package/src/effects/interactive-hover-button/InteractiveHoverButton.svelte +338 -0
- package/src/effects/magic-card/MagicCard.css +47 -0
- package/src/effects/magic-card/MagicCard.svelte +57 -0
- package/src/effects/marquee/Marquee.css +27 -1
- package/src/effects/marquee/Marquee.svelte +67 -3
- package/src/effects/reveal/index.ts +2 -1
- package/src/effects/reveal/observe.test.ts +60 -0
- package/src/effects/reveal/observe.ts +33 -1
- package/src/effects/reveal/types.ts +7 -0
- package/src/effects/theme-toggle/ThemeToggle.css +199 -6
- package/src/effects/theme-toggle/ThemeToggle.svelte +51 -26
- package/src/gallery.test.ts +52 -0
- package/src/index.ts +5 -1
- package/src/lib/panel.ts +7 -1
- package/src/lib/theme-transition.ts +37 -11
- package/src/lib/tone.ts +48 -0
- package/src/shadcn/accordion/accordion-trigger.svelte +15 -6
- package/src/shadcn/alert/alert.svelte +3 -1
- package/src/shadcn/badge/badge.svelte +8 -7
- package/src/shadcn/breadcrumb/breadcrumb-ellipsis.svelte +2 -2
- package/src/shadcn/breadcrumb/breadcrumb-separator.svelte +2 -2
- package/src/shadcn/calendar/calendar-month-select.svelte +2 -2
- package/src/shadcn/calendar/calendar-next-button.svelte +2 -2
- package/src/shadcn/calendar/calendar-prev-button.svelte +2 -2
- package/src/shadcn/calendar/calendar-year-select.svelte +2 -2
- package/src/shadcn/calendar/calendar.svelte +6 -0
- package/src/shadcn/code-block/code-block-copy-button.svelte +6 -55
- package/src/shadcn/command/index.ts +3 -0
- package/src/shadcn/field/index.ts +3 -0
- package/src/shadcn/field/simple-field.svelte +71 -0
- package/src/shadcn/field/simple-field.test.ts +47 -0
- package/src/shadcn/message-scroller/message-scroller-button.svelte +2 -2
- package/src/shadcn/radio-group/radio-group-item.svelte +3 -2
- package/src/shadcn/sheet/sheet-content.svelte +1 -0
- package/src/shadcn/sheet/sheet.css +48 -0
- package/src/shadcn/sonner/sonner.svelte +7 -10
- package/src/shadcn/toggle-group/toggle-group.svelte +10 -3
- package/src/shadcn/trace/trace-collapsible-trigger.svelte +2 -2
- package/src/ai/composer/Composer.css +0 -108
- package/src/ai/composer/Composer.svelte +0 -111
- package/src/ai/composer/index.ts +0 -10
- package/src/animations/accordion.css +0 -58
- package/src/animations/icon-swap.css +0 -43
- package/src/animations/notification-badge.css +0 -81
- package/src/animations/panel.css +0 -86
- package/src/animations/reasoning-stream.css +0 -49
- package/src/animations/skeleton-reveal.css +0 -97
- package/src/animations/streaming-text.css +0 -31
- package/src/animations/tabs-sliding.css +0 -83
- package/src/animations/texts-reveal.css +0 -70
- package/src/animations/tooltip.css +0 -72
- package/src/animations/view-transition-reveal.ts +0 -74
- package/src/lib/utils.ts +0 -45
- package/src/showcase/AiSection.svelte +0 -150
- package/src/showcase/AnimationsSection.svelte +0 -1658
- package/src/showcase/EffectsSection.svelte +0 -152
- package/src/showcase/ShadcnSection.svelte +0 -27
- package/src/showcase/ShowcaseImport.svelte +0 -126
- package/src/showcase/ShowcaseList.svelte +0 -132
- package/src/showcase/ShowcaseNav.svelte +0 -247
- package/src/showcase/ShowcaseSection.svelte +0 -112
- package/src/showcase/ShowcaseShell.svelte +0 -55
- package/src/showcase/ShowcaseStage.svelte +0 -342
- package/src/showcase/ShowcaseVariant.svelte +0 -109
- package/src/showcase/assets/image-reveal/art-1.jpg +0 -0
- package/src/showcase/assets/image-reveal/art-2.jpg +0 -0
- package/src/showcase/assets/image-reveal/art-3.jpg +0 -0
- package/src/showcase/check.svg +0 -1
- package/src/showcase/chevron-arms.svg +0 -2
- package/src/showcase/chevron-down.svg +0 -1
- package/src/showcase/copy.svg +0 -2
- package/src/showcase/demos/ai/confirmation.svelte +0 -59
- package/src/showcase/demos/ai/loading-state.svelte +0 -19
- package/src/showcase/demos/ai/prompt-input.svelte +0 -57
- package/src/showcase/demos/ai/reasoning.svelte +0 -19
- package/src/showcase/demos/ai/thinking-dots.svelte +0 -15
- package/src/showcase/demos/ai/voice-waveform.svelte +0 -24
- package/src/showcase/demos/effects/animated-beam.svelte +0 -50
- package/src/showcase/demos/effects/animated-group.svelte +0 -47
- package/src/showcase/demos/effects/border-beam-default.svelte +0 -20
- package/src/showcase/demos/effects/border-beam-line.svelte +0 -20
- package/src/showcase/demos/effects/border-beam-mono.svelte +0 -20
- package/src/showcase/demos/effects/border-beam-sunset.svelte +0 -20
- package/src/showcase/demos/effects/image-comparison.svelte +0 -25
- package/src/showcase/demos/effects/image-reveal.svelte +0 -49
- package/src/showcase/demos/effects/in-view.svelte +0 -41
- package/src/showcase/demos/effects/integrations-hub.svelte +0 -82
- package/src/showcase/demos/effects/magnetic.svelte +0 -31
- package/src/showcase/demos/effects/marquee.svelte +0 -33
- package/src/showcase/demos/effects/progressive-blur.svelte +0 -47
- package/src/showcase/demos/effects/spotlight.svelte +0 -40
- package/src/showcase/demos/effects/text-effect.svelte +0 -55
- package/src/showcase/demos/shadcn/accordion.svelte +0 -45
- package/src/showcase/demos/shadcn/alert-dialog.svelte +0 -23
- package/src/showcase/demos/shadcn/alert.svelte +0 -30
- package/src/showcase/demos/shadcn/attachment.svelte +0 -71
- package/src/showcase/demos/shadcn/avatar.svelte +0 -30
- package/src/showcase/demos/shadcn/badge.svelte +0 -26
- package/src/showcase/demos/shadcn/breadcrumb.svelte +0 -34
- package/src/showcase/demos/shadcn/bubble.svelte +0 -36
- package/src/showcase/demos/shadcn/button.svelte +0 -11
- package/src/showcase/demos/shadcn/calendar.svelte +0 -8
- package/src/showcase/demos/shadcn/card.svelte +0 -39
- package/src/showcase/demos/shadcn/checkbox.svelte +0 -39
- package/src/showcase/demos/shadcn/code-block.svelte +0 -80
- package/src/showcase/demos/shadcn/collapsible.svelte +0 -20
- package/src/showcase/demos/shadcn/command.svelte +0 -48
- package/src/showcase/demos/shadcn/context-menu.svelte +0 -54
- package/src/showcase/demos/shadcn/demo-harness.svelte +0 -12
- package/src/showcase/demos/shadcn/demos.test.ts +0 -20
- package/src/showcase/demos/shadcn/dialog.svelte +0 -38
- package/src/showcase/demos/shadcn/dropdown-menu.svelte +0 -59
- package/src/showcase/demos/shadcn/empty.svelte +0 -29
- package/src/showcase/demos/shadcn/field.svelte +0 -115
- package/src/showcase/demos/shadcn/hover-card.svelte +0 -32
- package/src/showcase/demos/shadcn/index.ts +0 -382
- package/src/showcase/demos/shadcn/input-otp.svelte +0 -19
- package/src/showcase/demos/shadcn/input.svelte +0 -5
- package/src/showcase/demos/shadcn/label.svelte +0 -11
- package/src/showcase/demos/shadcn/marker.svelte +0 -30
- package/src/showcase/demos/shadcn/message-scroller.svelte +0 -44
- package/src/showcase/demos/shadcn/message.svelte +0 -75
- package/src/showcase/demos/shadcn/popover.svelte +0 -36
- package/src/showcase/demos/shadcn/progress.svelte +0 -13
- package/src/showcase/demos/shadcn/radio-group.svelte +0 -19
- package/src/showcase/demos/shadcn/scroll-area.svelte +0 -18
- package/src/showcase/demos/shadcn/select.svelte +0 -29
- package/src/showcase/demos/shadcn/separator.svelte +0 -12
- package/src/showcase/demos/shadcn/sheet.svelte +0 -48
- package/src/showcase/demos/shadcn/skeleton.svelte +0 -11
- package/src/showcase/demos/shadcn/slider.svelte +0 -7
- package/src/showcase/demos/shadcn/sonner.svelte +0 -18
- package/src/showcase/demos/shadcn/spinner.svelte +0 -11
- package/src/showcase/demos/shadcn/switch.svelte +0 -9
- package/src/showcase/demos/shadcn/table.svelte +0 -76
- package/src/showcase/demos/shadcn/tabs.svelte +0 -62
- package/src/showcase/demos/shadcn/textarea.svelte +0 -5
- package/src/showcase/demos/shadcn/toggle-group.svelte +0 -18
- package/src/showcase/demos/shadcn/toggle.svelte +0 -14
- package/src/showcase/demos/shadcn/tooltip.svelte +0 -13
- package/src/showcase/demos/shadcn/trace.svelte +0 -51
- package/src/showcase/heart.svg +0 -1
- package/src/showcase/index.ts +0 -38
- package/src/showcase/kit.test.ts +0 -72
- package/src/showcase/kit.ts +0 -16
- package/src/showcase/plus.svg +0 -1
- package/src/showcase/showcase-shell.css +0 -91
- package/src/showcase/stacked-section.css +0 -15
- package/src/showcase/success-check.svg +0 -1
- package/src/showcase/types.ts +0 -29
- package/src/svg-raw.d.ts +0 -7
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
import type { PhosphorIcon } from "../../lib/icon-types.js";
|
|
3
|
+
|
|
4
|
+
/** Everything a segment may say, for every strip drawn from this folder. */
|
|
5
|
+
|
|
6
|
+
// WHY one shape: the tab, nav and filter strips each grew an item type and
|
|
7
|
+
// drifted, down to drawing a logo fallback two ways. Now it's one type, one renderer.
|
|
8
|
+
export type SegmentContent = {
|
|
9
|
+
label: string;
|
|
10
|
+
/** A number after the label. `0` still renders; omit for "no number". */
|
|
11
|
+
count?: number;
|
|
12
|
+
icon?: PhosphorIcon;
|
|
13
|
+
/** A square mark drawn in place of an icon, for a brand that has one. */
|
|
14
|
+
image?: string;
|
|
15
|
+
/**
|
|
16
|
+
* This segment's work is finished. Drawn as a dot after the label, and only
|
|
17
|
+
* where there is no count, because a count already says the same thing.
|
|
18
|
+
*/
|
|
19
|
+
done?: boolean;
|
|
20
|
+
/**
|
|
21
|
+
* A short note after the label, such as "Save 20%". Text where `count` is a
|
|
22
|
+
* number, in the accent colour. Drawn before a count or a done dot.
|
|
23
|
+
*/
|
|
24
|
+
badge?: string;
|
|
25
|
+
};
|
|
26
|
+
</script>
|
|
27
|
+
|
|
28
|
+
<script lang="ts">
|
|
29
|
+
// =========================
|
|
30
|
+
// IMPORTS
|
|
31
|
+
// =========================
|
|
32
|
+
import { cn } from "@heyramzi/utils";
|
|
33
|
+
import { Icon } from "../icon/index.js";
|
|
34
|
+
import { Mark } from "../mark/index.js";
|
|
35
|
+
|
|
36
|
+
// =========================
|
|
37
|
+
// TYPES
|
|
38
|
+
// =========================
|
|
39
|
+
type Props = SegmentContent & {
|
|
40
|
+
/**
|
|
41
|
+
* `true`/`false` when the caller owns the state. `"auto"` when bits-ui owns it
|
|
42
|
+
* in a `data-state` on the trigger, and the stylesheet answers.
|
|
43
|
+
*/
|
|
44
|
+
active?: boolean | "auto";
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// =========================
|
|
48
|
+
// PROPS
|
|
49
|
+
// =========================
|
|
50
|
+
let { label, count, icon, image, done = false, badge, active = false }: Props = $props();
|
|
51
|
+
|
|
52
|
+
// =========================
|
|
53
|
+
// DERIVED
|
|
54
|
+
// =========================
|
|
55
|
+
let auto = $derived(active === "auto");
|
|
56
|
+
let on = $derived(active === true);
|
|
57
|
+
</script>
|
|
58
|
+
|
|
59
|
+
<!-- =========================== -->
|
|
60
|
+
<!-- MARKUP -->
|
|
61
|
+
<!-- =========================== -->
|
|
62
|
+
{#if image}
|
|
63
|
+
<Mark
|
|
64
|
+
src={image}
|
|
65
|
+
{label}
|
|
66
|
+
class={cn(
|
|
67
|
+
"opacity-75 transition-opacity duration-[var(--duration-fast)] ease-[var(--ease-smooth-out)] group-hover/segment:opacity-100",
|
|
68
|
+
on && "opacity-100",
|
|
69
|
+
auto && "group-data-[state=active]/segment:opacity-100",
|
|
70
|
+
)}
|
|
71
|
+
/>
|
|
72
|
+
{:else if icon}
|
|
73
|
+
<Icon {icon} size={16} class="shrink-0" />
|
|
74
|
+
{/if}
|
|
75
|
+
|
|
76
|
+
{label}
|
|
77
|
+
|
|
78
|
+
{#if badge}
|
|
79
|
+
<span class="text-primary text-[10px] font-semibold tracking-wide whitespace-nowrap uppercase">{badge}</span>
|
|
80
|
+
{/if}
|
|
81
|
+
|
|
82
|
+
{#if count != null}
|
|
83
|
+
<span
|
|
84
|
+
class={cn(
|
|
85
|
+
// WHY 16px, not 18: inside a 22px segment, 18 read as a chip nested in the pill.
|
|
86
|
+
|
|
87
|
+
// WHY min-width equals height with 4px padding: `min-w-5 px-1.5` made a digit
|
|
88
|
+
// 20 by 18, so `rounded-full` drew an ellipse.
|
|
89
|
+
"inline-flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-[10px] leading-none font-medium tabular-nums",
|
|
90
|
+
"bg-muted-foreground/15 text-muted-foreground",
|
|
91
|
+
on && "bg-brand-indigo/14 text-brand-indigo",
|
|
92
|
+
auto && "group-data-[state=active]/segment:bg-brand-indigo/14 group-data-[state=active]/segment:text-brand-indigo",
|
|
93
|
+
)}
|
|
94
|
+
>
|
|
95
|
+
{count}
|
|
96
|
+
</span>
|
|
97
|
+
{:else if done}
|
|
98
|
+
<span
|
|
99
|
+
class={cn(
|
|
100
|
+
"size-1.5 shrink-0 rounded-full bg-muted-foreground/50",
|
|
101
|
+
on && "bg-brand-indigo",
|
|
102
|
+
auto && "group-data-[state=active]/segment:bg-brand-indigo",
|
|
103
|
+
)}
|
|
104
|
+
aria-hidden="true"
|
|
105
|
+
></span>
|
|
106
|
+
{/if}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
<script lang="ts" generics="T extends string">
|
|
2
|
+
// =========================
|
|
3
|
+
// IMPORTS
|
|
4
|
+
// =========================
|
|
5
|
+
import { cn } from "@heyramzi/utils";
|
|
6
|
+
import Segment from "./segment.svelte";
|
|
7
|
+
import type { TabDef } from "./animated-tabs.svelte";
|
|
8
|
+
import SegmentedTrack from "./segmented-track.svelte";
|
|
9
|
+
import SegmentedOverflow from "./segmented-overflow.svelte";
|
|
10
|
+
import { SEGMENT_MAX, splitSegments } from "./collapse.js";
|
|
11
|
+
import { segmentedTriggerClass } from "./segmented.js";
|
|
12
|
+
|
|
13
|
+
// =========================
|
|
14
|
+
// TYPES
|
|
15
|
+
// =========================
|
|
16
|
+
type Props = {
|
|
17
|
+
// WHY: picks which rows are listed, with no panel of its own. That's a
|
|
18
|
+
// segmented control, not a tab set.
|
|
19
|
+
|
|
20
|
+
// WHY not `AnimatedTabs`: a strip with no tabpanel is a `group` of `aria-pressed`
|
|
21
|
+
// toggles. "Tab 1 of 4" sends a screen reader looking for a panel.
|
|
22
|
+
|
|
23
|
+
// WHY `TabDef`: an option and a tab were the same five fields written twice.
|
|
24
|
+
options: readonly TabDef<T>[];
|
|
25
|
+
value: T;
|
|
26
|
+
// For a caller whose value is derived and can't be bound. Same contract as
|
|
27
|
+
// `AnimatedTabs`, so the two are picked by ARIA alone.
|
|
28
|
+
onValueChange?: (value: T) => void;
|
|
29
|
+
// Count per option, keyed by value. Merged over `options[].count`, because
|
|
30
|
+
// the counts usually arrive from the data while the labels are a constant.
|
|
31
|
+
counts?: Partial<Record<T, number>>;
|
|
32
|
+
ariaLabel: string;
|
|
33
|
+
/** Fill the row rather than sit at its natural width. On for the list-page
|
|
34
|
+
* filter strip, off for a segment sitting in a toolbar beside others. */
|
|
35
|
+
block?: boolean;
|
|
36
|
+
/** Objects the row may draw before its tail becomes a menu. See `collapse.ts`. */
|
|
37
|
+
max?: number;
|
|
38
|
+
/** What the menu segment says. Translate at the call site. */
|
|
39
|
+
moreLabel?: string;
|
|
40
|
+
class?: string;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// =========================
|
|
44
|
+
// PROPS
|
|
45
|
+
// =========================
|
|
46
|
+
let {
|
|
47
|
+
options,
|
|
48
|
+
value = $bindable(),
|
|
49
|
+
onValueChange,
|
|
50
|
+
counts,
|
|
51
|
+
ariaLabel,
|
|
52
|
+
block = true,
|
|
53
|
+
max = SEGMENT_MAX,
|
|
54
|
+
moreLabel,
|
|
55
|
+
class: className,
|
|
56
|
+
}: Props = $props();
|
|
57
|
+
|
|
58
|
+
// =========================
|
|
59
|
+
// DERIVED
|
|
60
|
+
// =========================
|
|
61
|
+
|
|
62
|
+
// Counts arrive from data, labels are constant. Merged once here, so the pill
|
|
63
|
+
// and the overflow hint say the same thing.
|
|
64
|
+
let merged = $derived(
|
|
65
|
+
options.map((option) => {
|
|
66
|
+
const count = counts?.[option.value] ?? option.count;
|
|
67
|
+
return count === option.count ? option : { ...option, count };
|
|
68
|
+
}),
|
|
69
|
+
);
|
|
70
|
+
let split = $derived(
|
|
71
|
+
splitSegments(
|
|
72
|
+
merged,
|
|
73
|
+
max,
|
|
74
|
+
merged.findIndex((option) => option.value === value),
|
|
75
|
+
),
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
function pick(next: T) {
|
|
79
|
+
value = next;
|
|
80
|
+
onValueChange?.(next);
|
|
81
|
+
}
|
|
82
|
+
</script>
|
|
83
|
+
|
|
84
|
+
<!-- =========================== -->
|
|
85
|
+
<!-- MARKUP -->
|
|
86
|
+
<!-- =========================== -->
|
|
87
|
+
<!-- WHY scroll, not wrap: it's one object. Past `max` the tail collapses into
|
|
88
|
+
a menu first, so scrolling is the last resort. -->
|
|
89
|
+
<div
|
|
90
|
+
class={cn("no-scrollbar flex max-w-full min-w-0 overflow-x-auto", className)}
|
|
91
|
+
role="group"
|
|
92
|
+
aria-label={ariaLabel}
|
|
93
|
+
>
|
|
94
|
+
<SegmentedTrack fill={block}>
|
|
95
|
+
{#each split.visible as option (option.value)}
|
|
96
|
+
{@const active = value === option.value}
|
|
97
|
+
<button
|
|
98
|
+
type="button"
|
|
99
|
+
class={segmentedTriggerClass({ fill: block, active })}
|
|
100
|
+
onclick={() => pick(option.value)}
|
|
101
|
+
aria-pressed={active}
|
|
102
|
+
>
|
|
103
|
+
<Segment
|
|
104
|
+
label={option.label}
|
|
105
|
+
count={option.count}
|
|
106
|
+
icon={option.icon}
|
|
107
|
+
image={option.image}
|
|
108
|
+
done={option.done}
|
|
109
|
+
badge={option.badge}
|
|
110
|
+
{active}
|
|
111
|
+
/>
|
|
112
|
+
</button>
|
|
113
|
+
{/each}
|
|
114
|
+
{#snippet after()}
|
|
115
|
+
{#if split.hidden.length > 0}
|
|
116
|
+
<SegmentedOverflow
|
|
117
|
+
items={split.hidden}
|
|
118
|
+
onSelect={pick}
|
|
119
|
+
label={moreLabel}
|
|
120
|
+
fill={block}
|
|
121
|
+
/>
|
|
122
|
+
{/if}
|
|
123
|
+
{/snippet}
|
|
124
|
+
</SegmentedTrack>
|
|
125
|
+
</div>
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
import type { SegmentContent } from "./segment.svelte";
|
|
3
|
+
|
|
4
|
+
/** One destination. See `SegmentContent` for everything a segment can say. */
|
|
5
|
+
export type SegmentedNavItem = SegmentContent & { href: string };
|
|
6
|
+
</script>
|
|
7
|
+
|
|
8
|
+
<script lang="ts">
|
|
9
|
+
// =========================
|
|
10
|
+
// IMPORTS
|
|
11
|
+
// =========================
|
|
12
|
+
import { cn } from "@heyramzi/utils";
|
|
13
|
+
import Segment from "./segment.svelte";
|
|
14
|
+
import SegmentedTrack from "./segmented-track.svelte";
|
|
15
|
+
import { segmentedTriggerClass } from "./segmented.js";
|
|
16
|
+
|
|
17
|
+
// =========================
|
|
18
|
+
// TYPES
|
|
19
|
+
// =========================
|
|
20
|
+
type Props = {
|
|
21
|
+
// WHY a third component: tabs are a `tablist`, filters a `group` of toggles,
|
|
22
|
+
// and this goes to sibling pages, so it's a `nav` of links with `aria-current`.
|
|
23
|
+
|
|
24
|
+
// Links give middle-click, bookmarks and the back button. The look comes from
|
|
25
|
+
// `segmented.ts` and `segment.svelte`, so nothing here can drift.
|
|
26
|
+
items: readonly SegmentedNavItem[];
|
|
27
|
+
/** The href that is current. Compared after trailing slashes are dropped. */
|
|
28
|
+
current: string;
|
|
29
|
+
ariaLabel: string;
|
|
30
|
+
/** Fill the row rather than sit at its natural width. */
|
|
31
|
+
block?: boolean;
|
|
32
|
+
class?: string;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// =========================
|
|
36
|
+
// PROPS
|
|
37
|
+
// =========================
|
|
38
|
+
let {
|
|
39
|
+
items,
|
|
40
|
+
current,
|
|
41
|
+
ariaLabel,
|
|
42
|
+
block = false,
|
|
43
|
+
class: className,
|
|
44
|
+
}: Props = $props();
|
|
45
|
+
|
|
46
|
+
// =========================
|
|
47
|
+
// DERIVED
|
|
48
|
+
// =========================
|
|
49
|
+
let currentHref = $derived(current.replace(/\/$/, ""));
|
|
50
|
+
</script>
|
|
51
|
+
|
|
52
|
+
<!-- =========================== -->
|
|
53
|
+
<!-- MARKUP -->
|
|
54
|
+
<!-- =========================== -->
|
|
55
|
+
<!-- WHY scroll, not wrap: seven brands outrun a laptop pane. A clipped segment
|
|
56
|
+
must read as "more to the right", never as a broken row. -->
|
|
57
|
+
<nav
|
|
58
|
+
class={cn("no-scrollbar flex max-w-full min-w-0 overflow-x-auto", className)}
|
|
59
|
+
aria-label={ariaLabel}
|
|
60
|
+
>
|
|
61
|
+
<SegmentedTrack fill={block}>
|
|
62
|
+
{#each items as item (item.href)}
|
|
63
|
+
{@const active = item.href.replace(/\/$/, "") === currentHref}
|
|
64
|
+
<a
|
|
65
|
+
href={item.href}
|
|
66
|
+
aria-current={active ? "page" : undefined}
|
|
67
|
+
title={item.label}
|
|
68
|
+
class={segmentedTriggerClass({ fill: block, active })}
|
|
69
|
+
>
|
|
70
|
+
<Segment
|
|
71
|
+
label={item.label}
|
|
72
|
+
count={item.count}
|
|
73
|
+
icon={item.icon}
|
|
74
|
+
image={item.image}
|
|
75
|
+
done={item.done}
|
|
76
|
+
badge={item.badge}
|
|
77
|
+
{active}
|
|
78
|
+
/>
|
|
79
|
+
</a>
|
|
80
|
+
{/each}
|
|
81
|
+
</SegmentedTrack>
|
|
82
|
+
</nav>
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
<script lang="ts" generics="V extends string">
|
|
2
|
+
// =========================
|
|
3
|
+
// IMPORTS
|
|
4
|
+
// =========================
|
|
5
|
+
import { CaretDown } from "phosphor-svelte";
|
|
6
|
+
import { Picker } from "../picker/index.js";
|
|
7
|
+
import { cn } from "@heyramzi/utils";
|
|
8
|
+
import { segmentedTriggerClass, type SegmentedVariant } from "./segmented.js";
|
|
9
|
+
import type { SegmentContent } from "./segment.svelte";
|
|
10
|
+
|
|
11
|
+
// =========================
|
|
12
|
+
// TYPES
|
|
13
|
+
// =========================
|
|
14
|
+
/** The last object in an overfull strip: one segment that opens the rest. */
|
|
15
|
+
|
|
16
|
+
// WHY the `Picker`: every "pick one of these" goes there, so keys and
|
|
17
|
+
// filtering match the command palette.
|
|
18
|
+
|
|
19
|
+
// WHY never active: `splitSegments` pulls the current item into a visible slot,
|
|
20
|
+
// so this never fights bits-ui's `data-state="open"`.
|
|
21
|
+
type Props = {
|
|
22
|
+
items: readonly (SegmentContent & { value: V })[];
|
|
23
|
+
onSelect: (value: V) => void;
|
|
24
|
+
/** What the segment says. Translate at the call site. */
|
|
25
|
+
label?: string;
|
|
26
|
+
fill?: boolean;
|
|
27
|
+
variant?: SegmentedVariant;
|
|
28
|
+
searchPlaceholder?: string;
|
|
29
|
+
emptyLabel?: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// =========================
|
|
33
|
+
// PROPS
|
|
34
|
+
// =========================
|
|
35
|
+
let {
|
|
36
|
+
items,
|
|
37
|
+
onSelect,
|
|
38
|
+
label = "More",
|
|
39
|
+
fill = false,
|
|
40
|
+
variant = "pill",
|
|
41
|
+
searchPlaceholder,
|
|
42
|
+
emptyLabel,
|
|
43
|
+
}: Props = $props();
|
|
44
|
+
|
|
45
|
+
// =========================
|
|
46
|
+
// DERIVED
|
|
47
|
+
// =========================
|
|
48
|
+
|
|
49
|
+
// A count rides as the row's hint, where the strip draws it as a pill.
|
|
50
|
+
let options = $derived(
|
|
51
|
+
items.map((item) => ({
|
|
52
|
+
value: item.value,
|
|
53
|
+
label: item.label,
|
|
54
|
+
hint: item.count != null ? String(item.count) : undefined,
|
|
55
|
+
icon: item.icon,
|
|
56
|
+
})),
|
|
57
|
+
);
|
|
58
|
+
</script>
|
|
59
|
+
|
|
60
|
+
<!-- =========================== -->
|
|
61
|
+
<!-- MARKUP -->
|
|
62
|
+
<!-- =========================== -->
|
|
63
|
+
<Picker
|
|
64
|
+
{options}
|
|
65
|
+
value={null}
|
|
66
|
+
onSelect={(value) => onSelect(value)}
|
|
67
|
+
triggerClass={cn(
|
|
68
|
+
segmentedTriggerClass({ variant, fill }),
|
|
69
|
+
"data-[state=open]:bg-foreground/5 data-[state=open]:text-foreground",
|
|
70
|
+
)}
|
|
71
|
+
triggerLabel={label}
|
|
72
|
+
align="end"
|
|
73
|
+
{searchPlaceholder}
|
|
74
|
+
{emptyLabel}
|
|
75
|
+
>
|
|
76
|
+
{#snippet trigger()}
|
|
77
|
+
{label}
|
|
78
|
+
<CaretDown
|
|
79
|
+
class={cn(
|
|
80
|
+
"shrink-0 transition-transform duration-150 group-data-[state=open]/segment:rotate-180",
|
|
81
|
+
"size-3.5",
|
|
82
|
+
)}
|
|
83
|
+
/>
|
|
84
|
+
{/snippet}
|
|
85
|
+
</Picker>
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// =========================
|
|
3
|
+
// IMPORTS
|
|
4
|
+
// =========================
|
|
5
|
+
import type { Snippet } from "svelte";
|
|
6
|
+
import { cn } from "@heyramzi/utils";
|
|
7
|
+
import { segmentedTrackClass, type SegmentedVariant } from "./segmented.js";
|
|
8
|
+
import { slidingPill } from "../../lib/sliding-pill.js";
|
|
9
|
+
|
|
10
|
+
// =========================
|
|
11
|
+
// TYPES
|
|
12
|
+
// =========================
|
|
13
|
+
type Props = {
|
|
14
|
+
/** Fill the row rather than sit at its natural width. */
|
|
15
|
+
fill?: boolean;
|
|
16
|
+
/** `pill`, a bordered well with a travelling highlight, or `underline`, a travelling rule. */
|
|
17
|
+
variant?: SegmentedVariant;
|
|
18
|
+
class?: string;
|
|
19
|
+
children: Snippet;
|
|
20
|
+
/**
|
|
21
|
+
* A last segment that isn't an item: today the overflow trigger. It's inside
|
|
22
|
+
* the track but outside the ARIA container, so a `tablist` owns only tabs.
|
|
23
|
+
*/
|
|
24
|
+
after?: Snippet;
|
|
25
|
+
// What the ARIA shapes differ by: `role`/`aria-label` for the filter, nothing
|
|
26
|
+
// for the nav (its <nav> has it), bits-ui's wiring for tabs.
|
|
27
|
+
[key: string]: unknown;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// =========================
|
|
31
|
+
// PROPS
|
|
32
|
+
// =========================
|
|
33
|
+
let {
|
|
34
|
+
fill = false,
|
|
35
|
+
variant = "pill",
|
|
36
|
+
class: className,
|
|
37
|
+
children,
|
|
38
|
+
after,
|
|
39
|
+
...rest
|
|
40
|
+
}: Props = $props();
|
|
41
|
+
</script>
|
|
42
|
+
|
|
43
|
+
<!-- =========================== -->
|
|
44
|
+
<!-- MARKUP -->
|
|
45
|
+
<!-- =========================== -->
|
|
46
|
+
<!-- WHY its own component: the active segment is a travelling pill, and the
|
|
47
|
+
pill needs one element inside the track that no caller can forget. -->
|
|
48
|
+
<div
|
|
49
|
+
{...rest}
|
|
50
|
+
data-variant={variant}
|
|
51
|
+
class={cn("track relative", segmentedTrackClass({ variant, fill }), className)}
|
|
52
|
+
>
|
|
53
|
+
<!-- Renders at zero width until the attachment measures, so a server-rendered
|
|
54
|
+
strip shows the segment's own background and never an empty pill. -->
|
|
55
|
+
<span class="pill" aria-hidden="true" {@attach slidingPill}></span>
|
|
56
|
+
{@render children()}
|
|
57
|
+
{@render after?.()}
|
|
58
|
+
</div>
|
|
59
|
+
|
|
60
|
+
<!-- =========================== -->
|
|
61
|
+
<!-- STYLES -->
|
|
62
|
+
<!-- =========================== -->
|
|
63
|
+
<style>
|
|
64
|
+
/* Inset separators keep the strip one bordered object. */
|
|
65
|
+
.track :global(.ui-segment)::before {
|
|
66
|
+
content: "";
|
|
67
|
+
position: absolute;
|
|
68
|
+
top: 22%;
|
|
69
|
+
bottom: 22%;
|
|
70
|
+
left: 0;
|
|
71
|
+
width: 1px;
|
|
72
|
+
background-color: var(--color-border);
|
|
73
|
+
transition: opacity var(--duration-fast) var(--ease-smooth-out);
|
|
74
|
+
pointer-events: none;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.track :global(.pill + .ui-segment)::before,
|
|
78
|
+
.track :global([data-slot="tabs-list"] > .ui-segment:first-child)::before {
|
|
79
|
+
display: none;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/* The highlight and hover wash hide the seams on either side. */
|
|
83
|
+
.track :global(.ui-segment[data-state="active"])::before,
|
|
84
|
+
.track :global(.ui-segment[aria-current="page"])::before,
|
|
85
|
+
.track :global(.ui-segment[aria-pressed="true"])::before,
|
|
86
|
+
.track :global(.ui-segment[data-state="active"] + .ui-segment)::before,
|
|
87
|
+
.track :global(.ui-segment[aria-current="page"] + .ui-segment)::before,
|
|
88
|
+
.track :global(.ui-segment[aria-pressed="true"] + .ui-segment)::before,
|
|
89
|
+
.track :global([data-slot="tabs-list"]:has(> .ui-segment:last-child[data-state="active"]) + .ui-segment)::before,
|
|
90
|
+
.track :global(.ui-segment:hover)::before,
|
|
91
|
+
.track :global(.ui-segment:hover + .ui-segment)::before {
|
|
92
|
+
opacity: 0;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/* The label's colour lands with the pill, on the same fast step. */
|
|
96
|
+
.track :global(.ui-segment) {
|
|
97
|
+
transition:
|
|
98
|
+
background-color var(--duration-quick) var(--ease-smooth-out),
|
|
99
|
+
color var(--duration-fast) var(--ease-smooth-out),
|
|
100
|
+
transform var(--duration-micro) var(--ease-smooth-out);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/* A faint wash in the well says which segment the pointer would pick. */
|
|
104
|
+
@media (hover: hover) {
|
|
105
|
+
.track
|
|
106
|
+
:global(
|
|
107
|
+
.ui-segment:hover:not([data-state="active"], [aria-current="page"], [aria-pressed="true"], [data-state="open"])
|
|
108
|
+
) {
|
|
109
|
+
background-color: color-mix(in oklch, var(--color-muted) 55%, transparent);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
.pill {
|
|
114
|
+
position: absolute;
|
|
115
|
+
top: 2px;
|
|
116
|
+
bottom: 2px;
|
|
117
|
+
left: 0;
|
|
118
|
+
z-index: 0;
|
|
119
|
+
width: 0;
|
|
120
|
+
opacity: 0;
|
|
121
|
+
/* Concentric with the border and the 2px inset. */
|
|
122
|
+
border-radius: calc(var(--radius-lg) - 3px);
|
|
123
|
+
background-color: var(--color-muted);
|
|
124
|
+
box-shadow: inset 0 0 0 1px color-mix(in oklch, var(--color-foreground) 6%, transparent);
|
|
125
|
+
transform: translateX(0);
|
|
126
|
+
/* transitions-dev `16-tabs-sliding`: the fast step, 250ms. */
|
|
127
|
+
transition:
|
|
128
|
+
transform var(--duration-fast) var(--ease-smooth-out),
|
|
129
|
+
width var(--duration-fast) var(--ease-smooth-out),
|
|
130
|
+
opacity var(--duration-quick) var(--ease-smooth-out);
|
|
131
|
+
will-change: transform, width;
|
|
132
|
+
pointer-events: none;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/* Before the attachment measures, the active segment draws the pill itself,
|
|
136
|
+
so a server-rendered strip is right before any JS arrives. */
|
|
137
|
+
.track:not([data-pill="on"]) :global(:is([data-state="active"], [aria-current="page"], [aria-pressed="true"])) {
|
|
138
|
+
background-color: var(--color-muted);
|
|
139
|
+
box-shadow: inset 0 0 0 1px color-mix(in oklch, var(--color-foreground) 6%, transparent);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/* The underline: no seams, no wash, and the pill becomes a 2px rule on the
|
|
143
|
+
hairline. `--segmented-rule` lets a brand colour it from a class. */
|
|
144
|
+
.track[data-variant="underline"] :global(.ui-segment)::before {
|
|
145
|
+
display: none;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
@media (hover: hover) {
|
|
149
|
+
.track[data-variant="underline"]
|
|
150
|
+
:global(
|
|
151
|
+
.ui-segment:hover:not([data-state="active"], [aria-current="page"], [aria-pressed="true"], [data-state="open"])
|
|
152
|
+
) {
|
|
153
|
+
background-color: transparent;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
.track[data-variant="underline"] .pill {
|
|
158
|
+
top: auto;
|
|
159
|
+
bottom: -1px;
|
|
160
|
+
height: 2px;
|
|
161
|
+
border-radius: 1px;
|
|
162
|
+
background-color: var(--segmented-rule, var(--color-foreground));
|
|
163
|
+
box-shadow: none;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
.track[data-variant="underline"]:not([data-pill="on"])
|
|
167
|
+
:global(:is([data-state="active"], [aria-current="page"], [aria-pressed="true"])) {
|
|
168
|
+
background-color: transparent;
|
|
169
|
+
box-shadow: inset 0 -2px 0 var(--segmented-rule, var(--color-foreground));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
@media (prefers-reduced-motion: reduce) {
|
|
173
|
+
.pill {
|
|
174
|
+
transition: opacity var(--duration-quick) var(--ease-smooth-out);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
.track :global(.ui-segment) {
|
|
178
|
+
transition: none;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
</style>
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { describe, expect, it } from "vite-plus/test";
|
|
2
|
+
import { segmentedTrackClass } from "./segmented.js";
|
|
3
|
+
|
|
4
|
+
describe("segmentedTrackClass", () => {
|
|
5
|
+
// Every segmented control shares one height, whatever its look.
|
|
6
|
+
it("holds one height across variants", () => {
|
|
7
|
+
for (const variant of ["pill", "underline"] as const) {
|
|
8
|
+
const classes = segmentedTrackClass({ variant }).split(" ");
|
|
9
|
+
expect(classes).toContain("h-7");
|
|
10
|
+
expect(classes).toContain("any-pointer-coarse:h-10");
|
|
11
|
+
}
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("draws the pill as a bordered well and the underline as a hairline", () => {
|
|
15
|
+
expect(segmentedTrackClass({ variant: "pill" })).toContain("rounded-lg");
|
|
16
|
+
const underline = segmentedTrackClass({ variant: "underline" });
|
|
17
|
+
expect(underline).toContain("border-b");
|
|
18
|
+
expect(underline).not.toContain("rounded-lg");
|
|
19
|
+
});
|
|
20
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { tv } from "tailwind-variants";
|
|
2
|
+
import { pageGutterClass } from "../../lib/layout.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The one look for a segmented control: the track and trigger here, what a
|
|
6
|
+
* segment says in `segment.svelte`.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// WHY a recipe, not one component: tabs are a `tablist`, filters a `group` of
|
|
10
|
+
// `aria-pressed` toggles, page links a `nav`. Only the ARIA shape may differ.
|
|
11
|
+
|
|
12
|
+
// The look used to drift three ways, one with an at-rest shadow. All three read from here now.
|
|
13
|
+
export const segmentedTrackClass = tv({
|
|
14
|
+
// WHY the height lives on the track: it carries the inset, so an `h-7` trigger
|
|
15
|
+
// drew 30px beside a 28px button. One toolbar row came out at five heights.
|
|
16
|
+
|
|
17
|
+
// WHY no size variant: `sm` drew 24px, and every tab control must share one height.
|
|
18
|
+
base: "h-7 any-pointer-coarse:h-10 shrink-0 items-stretch",
|
|
19
|
+
variants: {
|
|
20
|
+
// `pill`: a bordered well with a travelling highlight. `underline`: bare
|
|
21
|
+
// labels over one hairline, the active one marked by a rule that travels.
|
|
22
|
+
variant: {
|
|
23
|
+
pill: "overflow-hidden rounded-lg border border-border p-0.5",
|
|
24
|
+
underline: "gap-5 border-b border-border",
|
|
25
|
+
},
|
|
26
|
+
// Fill the row rather than sit at its natural width. For a narrow rail.
|
|
27
|
+
fill: { true: "flex w-full", false: "inline-flex w-fit" },
|
|
28
|
+
},
|
|
29
|
+
defaultVariants: { variant: "pill", fill: false },
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
export type SegmentedVariant = "pill" | "underline";
|
|
33
|
+
|
|
34
|
+
export const segmentedTriggerClass = tv({
|
|
35
|
+
base: [
|
|
36
|
+
// `t-press` is the kit's one press: colour, shadow and a pixel of travel.
|
|
37
|
+
|
|
38
|
+
// `relative z-[1]` keeps the label above the travelling pill, or the pill
|
|
39
|
+
// paints over the word it highlights.
|
|
40
|
+
|
|
41
|
+
// `ui-segment` isn't a style. `segmented-track.svelte` matches on it to time
|
|
42
|
+
// the label colour with the pill.
|
|
43
|
+
"ui-segment group/segment t-press relative z-[1] inline-flex items-center justify-center gap-1.5 whitespace-nowrap font-medium select-none",
|
|
44
|
+
"text-muted-foreground hover:text-foreground",
|
|
45
|
+
"disabled:pointer-events-none disabled:opacity-50",
|
|
46
|
+
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
47
|
+
// WHY an inset focus ring: the track clips to its rounded corners, so an
|
|
48
|
+
// outside ring was cut in half on the end segments.
|
|
49
|
+
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-inset focus-visible:outline-none",
|
|
50
|
+
// WHY no height on the trigger: an icon makes a segment taller than a word.
|
|
51
|
+
// `h-full` against the track's `items-stretch` keeps every segment the same box.
|
|
52
|
+
"h-full text-xs",
|
|
53
|
+
],
|
|
54
|
+
variants: {
|
|
55
|
+
// The pill is concentric with the track's border and its 2px inset. The
|
|
56
|
+
// underline has no well to be concentric with, so no padding either: the
|
|
57
|
+
// rule under a label is as wide as the label.
|
|
58
|
+
variant: {
|
|
59
|
+
pill: "rounded-[calc(var(--radius-lg)-3px)] px-2.5 any-pointer-coarse:px-3.5",
|
|
60
|
+
underline: "rounded-sm px-0",
|
|
61
|
+
},
|
|
62
|
+
// WHY `min-w-fit flex-auto`, not equal fractions: fractions cut "Approved" to
|
|
63
|
+
// "Appro…" in a 280px rail. Each segment claims its label and shares the slack.
|
|
64
|
+
fill: { true: "min-w-fit flex-auto", false: "" },
|
|
65
|
+
active: { true: "text-foreground", false: "" },
|
|
66
|
+
},
|
|
67
|
+
defaultVariants: { variant: "pill", fill: false, active: false },
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The row a segmented strip sits in, and the one recipe for a navigation row
|
|
72
|
+
* under the app header. Written per caller it produced two different left
|
|
73
|
+
* edges.
|
|
74
|
+
*/
|
|
75
|
+
// WHY no bottom hairline: with a section strip and a page toolbar stacked it
|
|
76
|
+
// drew two rules for one boundary. The gap is the boundary.
|
|
77
|
+
export const segmentedRowClass = `flex shrink-0 flex-wrap items-center gap-x-4 gap-y-2 py-2 ${pageGutterClass}`;
|
|
78
|
+
|
|
79
|
+
/** The active style, for a strip whose state lives in a `data-active` attribute. */
|
|
80
|
+
export const segmentedActiveClass = "data-active:text-foreground";
|