@heyramzi/ui 6.1.0 → 8.0.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 +32 -40
- package/README.md +10 -4
- package/package.json +31 -29
- package/scripts/sync-shadcn.mjs +29 -16
- package/scripts/sync-transitions.mjs +11 -11
- 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 -24
- 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 +17 -17
- 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/sliding-pill.ts +68 -0
- package/src/lib/theme-transition.ts +41 -13
- 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-actions.svelte +23 -0
- package/src/shadcn/code-block/code-block-content.svelte +55 -0
- package/src/shadcn/code-block/code-block-copy-button.svelte +10 -0
- package/src/shadcn/code-block/code-block-footer.svelte +20 -0
- package/src/shadcn/code-block/code-block-header.svelte +23 -0
- package/src/shadcn/code-block/code-block-line.svelte +71 -0
- package/src/shadcn/code-block/code-block-title.svelte +23 -0
- package/src/shadcn/code-block/code-block.svelte +47 -0
- package/src/shadcn/code-block/index.ts +42 -0
- 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/hooks/highlighter.ts +132 -0
- package/src/shadcn/hooks/use-highlighted-code.svelte.ts +98 -0
- package/src/shadcn/kbd/index.ts +10 -0
- package/src/shadcn/kbd/kbd-group.svelte +20 -0
- package/src/shadcn/kbd/kbd.svelte +23 -0
- package/src/shadcn/message-scroller/index.ts +28 -0
- package/src/shadcn/message-scroller/message-scroller-button.svelte +52 -0
- package/src/shadcn/message-scroller/message-scroller-content.svelte +17 -0
- package/src/shadcn/message-scroller/message-scroller-item.svelte +22 -0
- package/src/shadcn/message-scroller/message-scroller-provider.svelte +7 -0
- package/src/shadcn/message-scroller/message-scroller-viewport.svelte +20 -0
- package/src/shadcn/message-scroller/message-scroller.svelte +20 -0
- 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/tabs/tabs-list.svelte +13 -2
- package/src/shadcn/tabs/tabs-trigger.svelte +1 -1
- package/src/shadcn/toggle-group/toggle-group.svelte +10 -3
- package/src/shadcn/trace/index.ts +29 -0
- package/src/shadcn/trace/trace-collapsible-content.svelte +29 -0
- package/src/shadcn/trace/trace-collapsible-trigger.svelte +29 -0
- package/src/shadcn/trace/trace-collapsible.svelte +20 -0
- package/src/shadcn/trace/trace-description.svelte +20 -0
- package/src/shadcn/trace/trace-icon.svelte +24 -0
- package/src/shadcn/trace/trace-line.svelte +31 -0
- package/src/shadcn/trace/trace-title.svelte +20 -0
- package/src/shadcn/trace/trace.svelte +23 -0
- 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/ai/conversation/Conversation.svelte +0 -28
- package/src/ai/conversation/ConversationContent.svelte +0 -35
- package/src/ai/conversation/ConversationEmptyState.svelte +0 -46
- package/src/ai/conversation/ConversationScrollButton.svelte +0 -51
- package/src/ai/conversation/index.ts +0 -33
- package/src/ai/conversation/stick-to-bottom-context.svelte.ts +0 -131
- 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/charts/animated-value.css +0 -7
- package/src/charts/animated-value.svelte +0 -110
- package/src/charts/area-chart.css +0 -29
- package/src/charts/area-chart.svelte +0 -195
- package/src/charts/bar-chart.css +0 -119
- package/src/charts/bar-chart.svelte +0 -269
- package/src/charts/charts.css +0 -201
- package/src/charts/crosshair.svelte +0 -35
- package/src/charts/donut-chart.css +0 -56
- package/src/charts/donut-chart.svelte +0 -158
- package/src/charts/edge-fade.svelte +0 -43
- package/src/charts/edge-fade.ts +0 -16
- package/src/charts/figure.css +0 -100
- package/src/charts/figure.svelte +0 -122
- package/src/charts/gauge-chart.css +0 -94
- package/src/charts/gauge-chart.svelte +0 -303
- package/src/charts/hover-band.test.ts +0 -30
- package/src/charts/hover-band.ts +0 -21
- package/src/charts/hover-focus.svelte +0 -85
- package/src/charts/index.ts +0 -30
- package/src/charts/legend.css +0 -131
- package/src/charts/legend.svelte +0 -112
- package/src/charts/line-chart.css +0 -26
- package/src/charts/line-chart.svelte +0 -256
- package/src/charts/motion.ts +0 -19
- package/src/charts/number-chart.css +0 -168
- package/src/charts/number-chart.svelte +0 -177
- package/src/charts/primitives/chart-container.svelte +0 -57
- package/src/charts/primitives/chart-style.svelte +0 -36
- package/src/charts/primitives/chart-tooltip.svelte +0 -161
- package/src/charts/primitives/chart-utils.ts +0 -54
- package/src/charts/primitives/index.ts +0 -11
- package/src/charts/reveal.svelte +0 -61
- package/src/charts/ring-chart.css +0 -96
- package/src/charts/ring-chart.svelte +0 -158
- package/src/charts/sparkline.css +0 -45
- package/src/charts/sparkline.svelte +0 -140
- package/src/charts/stat-card.css +0 -103
- package/src/charts/stat-card.svelte +0 -267
- package/src/charts/time-series-chart.css +0 -28
- package/src/charts/time-series-chart.svelte +0 -179
- package/src/charts/trend-pill.css +0 -38
- package/src/charts/trend-pill.svelte +0 -48
- package/src/lib/hooks/is-mobile.svelte.ts +0 -11
- package/src/lib/utils.ts +0 -45
- package/src/showcase/AiSection.svelte +0 -157
- package/src/showcase/AnimationsSection.svelte +0 -1658
- package/src/showcase/ChartsSection.svelte +0 -458
- 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/conversation.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/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 -358
- 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.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/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 -17
- 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,84 @@
|
|
|
1
|
+
<script lang="ts" generics="V extends string">
|
|
2
|
+
// =========================
|
|
3
|
+
// IMPORTS
|
|
4
|
+
// =========================
|
|
5
|
+
import { CaretDown } from "phosphor-svelte";
|
|
6
|
+
import { Mark } from "../mark/index.js";
|
|
7
|
+
import { cn } from "@heyramzi/utils";
|
|
8
|
+
import Picker from "./picker.svelte";
|
|
9
|
+
import { pickerTriggerClass } from "./trigger.js";
|
|
10
|
+
import type { PickerOption } from "./types.js";
|
|
11
|
+
|
|
12
|
+
// =========================
|
|
13
|
+
// TYPES
|
|
14
|
+
// =========================
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The picker for named things carrying a mark: a kit, a channel, a client. It
|
|
18
|
+
* wraps `Picker` because the kit strip and the channel row wrote the same two
|
|
19
|
+
* snippets.
|
|
20
|
+
*/
|
|
21
|
+
type Props = {
|
|
22
|
+
options: PickerOption<V>[];
|
|
23
|
+
value: V | null;
|
|
24
|
+
onSelect: (value: V) => void;
|
|
25
|
+
ariaLabel: string;
|
|
26
|
+
/** Said by the trigger when nothing in the list is the current value. */
|
|
27
|
+
placeholder?: string;
|
|
28
|
+
/** The search field's own prompt, on a list long enough to show one. */
|
|
29
|
+
searchPlaceholder?: string;
|
|
30
|
+
/** `circle` for a face or an avatar, `square` for a logo. */
|
|
31
|
+
shape?: "square" | "circle";
|
|
32
|
+
width?: "sm" | "default" | "lg";
|
|
33
|
+
class?: string;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// =========================
|
|
37
|
+
// PROPS
|
|
38
|
+
// =========================
|
|
39
|
+
let {
|
|
40
|
+
options,
|
|
41
|
+
value,
|
|
42
|
+
onSelect,
|
|
43
|
+
ariaLabel,
|
|
44
|
+
placeholder = "Select",
|
|
45
|
+
searchPlaceholder = "Search",
|
|
46
|
+
shape = "square",
|
|
47
|
+
width = "lg",
|
|
48
|
+
class: className,
|
|
49
|
+
}: Props = $props();
|
|
50
|
+
|
|
51
|
+
// =========================
|
|
52
|
+
// DERIVED
|
|
53
|
+
// =========================
|
|
54
|
+
let selected = $derived(options.find((option) => option.value === value));
|
|
55
|
+
</script>
|
|
56
|
+
|
|
57
|
+
<!-- =========================== -->
|
|
58
|
+
<!-- MARKUP -->
|
|
59
|
+
<!-- =========================== -->
|
|
60
|
+
<Picker
|
|
61
|
+
{options}
|
|
62
|
+
{value}
|
|
63
|
+
{onSelect}
|
|
64
|
+
{width}
|
|
65
|
+
{searchPlaceholder}
|
|
66
|
+
triggerClass={cn(pickerTriggerClass(), className)}
|
|
67
|
+
triggerLabel={ariaLabel}
|
|
68
|
+
>
|
|
69
|
+
{#snippet trigger()}
|
|
70
|
+
{#if selected?.image}
|
|
71
|
+
<Mark src={selected.image} label={selected.label} {shape} />
|
|
72
|
+
{:else if selected?.icon}
|
|
73
|
+
{@const Icon = selected.icon}
|
|
74
|
+
<Icon class="text-muted-foreground size-4" />
|
|
75
|
+
{/if}
|
|
76
|
+
<span class="truncate">{selected?.label ?? placeholder}</span>
|
|
77
|
+
<CaretDown class="text-muted-foreground size-3" />
|
|
78
|
+
{/snippet}
|
|
79
|
+
<!-- A row with an icon of its own is drawn by `Picker`; this is every other
|
|
80
|
+
row, the ones that carry a mark. -->
|
|
81
|
+
{#snippet lead(option)}
|
|
82
|
+
<Mark src={option.image} label={option.label} {shape} />
|
|
83
|
+
{/snippet}
|
|
84
|
+
</Picker>
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
<script lang="ts" generics="V extends string">
|
|
2
|
+
import type { Snippet } from "svelte";
|
|
3
|
+
import * as Command from "../../shadcn/command/index.js";
|
|
4
|
+
import * as Popover from "../../shadcn/popover/index.js";
|
|
5
|
+
import { cn } from "@heyramzi/utils";
|
|
6
|
+
import type { PickerGroup, PickerOption } from "./types.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* One menu for every "pick one of these" in the app: priority, person, week,
|
|
10
|
+
* goal, status. It's the Command palette at popover scale, same keys.
|
|
11
|
+
*/
|
|
12
|
+
// It replaced four pickers in one sidebar, each built differently. Only one
|
|
13
|
+
// had search, only one had arrow keys, and the check mark sat in three places.
|
|
14
|
+
|
|
15
|
+
type Props = {
|
|
16
|
+
/** Flat list. Mutually exclusive with `groups`. */
|
|
17
|
+
options?: PickerOption<V>[];
|
|
18
|
+
/** Titled runs, when the menu has a shape worth naming. */
|
|
19
|
+
groups?: PickerGroup<V>[];
|
|
20
|
+
value: V | null;
|
|
21
|
+
onSelect: (value: V) => void;
|
|
22
|
+
/** The control that opens the menu. Rendered inside `Popover.Trigger`. */
|
|
23
|
+
trigger: Snippet;
|
|
24
|
+
/** Class for the trigger. Use `pickerTriggerClass` to build it. */
|
|
25
|
+
triggerClass?: string;
|
|
26
|
+
triggerLabel?: string;
|
|
27
|
+
/**
|
|
28
|
+
* A mark drawn at the head of a row when the option carries no icon: an
|
|
29
|
+
* avatar, a client logo, a colour swatch.
|
|
30
|
+
*/
|
|
31
|
+
lead?: Snippet<[PickerOption<V>]>;
|
|
32
|
+
/** Pinned under the list: a clear action, a hint, a secondary surface. */
|
|
33
|
+
footer?: Snippet<[{ close: () => void }]>;
|
|
34
|
+
/**
|
|
35
|
+
* Search field. `auto` shows it from seven rows, the point at which the
|
|
36
|
+
* eye stops finding a name faster than the fingers can type it.
|
|
37
|
+
*/
|
|
38
|
+
searchable?: boolean | "auto";
|
|
39
|
+
searchPlaceholder?: string;
|
|
40
|
+
emptyLabel?: string;
|
|
41
|
+
open?: boolean;
|
|
42
|
+
align?: "start" | "center" | "end";
|
|
43
|
+
/** `sm` fits a word, `default` a name, `lg` a name and a date. */
|
|
44
|
+
width?: "sm" | "default" | "lg";
|
|
45
|
+
disabled?: boolean;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
let {
|
|
49
|
+
options,
|
|
50
|
+
groups,
|
|
51
|
+
value,
|
|
52
|
+
onSelect,
|
|
53
|
+
trigger,
|
|
54
|
+
triggerClass,
|
|
55
|
+
triggerLabel,
|
|
56
|
+
lead,
|
|
57
|
+
footer,
|
|
58
|
+
searchable = "auto",
|
|
59
|
+
searchPlaceholder,
|
|
60
|
+
emptyLabel = "No matches",
|
|
61
|
+
open = $bindable(false),
|
|
62
|
+
align = "start",
|
|
63
|
+
width = "default",
|
|
64
|
+
disabled = false
|
|
65
|
+
}: Props = $props();
|
|
66
|
+
|
|
67
|
+
let resolved = $derived<PickerGroup<V>[]>(groups ?? [{ options: options ?? [] }]);
|
|
68
|
+
let count = $derived(resolved.reduce((n, g) => n + g.options.length, 0));
|
|
69
|
+
let showSearch = $derived(searchable === "auto" ? count >= 7 : searchable);
|
|
70
|
+
|
|
71
|
+
// 176 / 208 / 240px. The reference runs a 5-row priority menu at 206px, so
|
|
72
|
+
// `default` is the common case. `lg` fits a name and a date without cutting.
|
|
73
|
+
const WIDTH = { sm: "w-44", default: "w-52", lg: "w-60" } as const;
|
|
74
|
+
|
|
75
|
+
function pick(next: V) {
|
|
76
|
+
open = false;
|
|
77
|
+
onSelect(next);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* WHY a custom filter: the default one scores `value`, which is often an id.
|
|
82
|
+
* Typing "an" fuzzy-matched every uuid, so only the visible words are searched.
|
|
83
|
+
*/
|
|
84
|
+
function filter(_value: string, search: string, keywords?: string[]) {
|
|
85
|
+
return keywords ? Command.computeCommandScore(keywords.join(" "), search) : 0;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function keywordsFor(option: PickerOption<V>): string[] {
|
|
89
|
+
return [option.label, option.hint, option.keywords].filter(Boolean) as string[];
|
|
90
|
+
}
|
|
91
|
+
</script>
|
|
92
|
+
|
|
93
|
+
<Popover.Root bind:open>
|
|
94
|
+
<Popover.Trigger class={triggerClass} aria-label={triggerLabel} {disabled}>
|
|
95
|
+
{@render trigger()}
|
|
96
|
+
</Popover.Trigger>
|
|
97
|
+
<!-- A picker opens below its trigger. `side` was a prop taking four edges and
|
|
98
|
+
no caller in any repo had ever passed one. Deleted 4 Sep 2026. -->
|
|
99
|
+
<Popover.Content {align} side="bottom" class={cn("p-0", WIDTH[width])}>
|
|
100
|
+
<Command.Root {filter} loop>
|
|
101
|
+
{#if showSearch}
|
|
102
|
+
<Command.Input placeholder={searchPlaceholder} />
|
|
103
|
+
{/if}
|
|
104
|
+
<Command.List class={cn(!showSearch && "py-1.5")}>
|
|
105
|
+
{#if showSearch}
|
|
106
|
+
<Command.Empty>{emptyLabel}</Command.Empty>
|
|
107
|
+
{/if}
|
|
108
|
+
{#each resolved as group, i (group.heading ?? i)}
|
|
109
|
+
<Command.Group heading={group.heading}>
|
|
110
|
+
{#each group.options as option (option.value)}
|
|
111
|
+
{@const active = option.value === value}
|
|
112
|
+
<Command.Item
|
|
113
|
+
value={option.value}
|
|
114
|
+
keywords={keywordsFor(option)}
|
|
115
|
+
disabled={option.disabled}
|
|
116
|
+
data-checked={active ? "true" : undefined}
|
|
117
|
+
onSelect={() => pick(option.value)}
|
|
118
|
+
>
|
|
119
|
+
{#if option.icon}
|
|
120
|
+
{@const Icon = option.icon}
|
|
121
|
+
<Icon class={cn("size-4", option.tone)} weight="bold" />
|
|
122
|
+
{:else if lead}
|
|
123
|
+
{@render lead(option)}
|
|
124
|
+
{/if}
|
|
125
|
+
<span class={cn("min-w-0 flex-1 truncate text-left", active && option.tone)}>
|
|
126
|
+
{option.label}
|
|
127
|
+
</span>
|
|
128
|
+
{#if option.hint}
|
|
129
|
+
<span class="text-muted-foreground shrink-0 text-xs tabular-nums">{option.hint}</span>
|
|
130
|
+
{/if}
|
|
131
|
+
</Command.Item>
|
|
132
|
+
{/each}
|
|
133
|
+
</Command.Group>
|
|
134
|
+
{/each}
|
|
135
|
+
</Command.List>
|
|
136
|
+
{#if footer}
|
|
137
|
+
<div class="border-border/70 border-t p-1.5">
|
|
138
|
+
{@render footer({ close: () => (open = false) })}
|
|
139
|
+
</div>
|
|
140
|
+
{/if}
|
|
141
|
+
</Command.Root>
|
|
142
|
+
</Popover.Content>
|
|
143
|
+
</Popover.Root>
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { tv } from "tailwind-variants";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The one look for a control that opens a picker, and for the plain values
|
|
5
|
+
* that sit in the same column beside it.
|
|
6
|
+
*/
|
|
7
|
+
// WHY a recipe: a property row can be a menu trigger, a link or a plain
|
|
8
|
+
// string. All three draw the same box, and only one is a button.
|
|
9
|
+
|
|
10
|
+
// It replaced eight geometries in one sidebar: three heights, three radii, two
|
|
11
|
+
// paddings, and a border on three rows out of eight.
|
|
12
|
+
|
|
13
|
+
// Borderless at rest on purpose. A value that's set is a fact to read, so the
|
|
14
|
+
// box shows only under the pointer and on focus.
|
|
15
|
+
export const pickerTriggerClass = tv({
|
|
16
|
+
base: [
|
|
17
|
+
// 13px at weight 500 is Linear's own property row, measured 31 Aug 2026 on a
|
|
18
|
+
// 16px root. 14px reads looser than the reference, and it shows in the sidebar.
|
|
19
|
+
"t-press inline-flex min-w-0 items-center gap-1.5 rounded-md text-[13px] font-medium whitespace-nowrap select-none",
|
|
20
|
+
"hover:bg-muted data-[state=open]:bg-muted",
|
|
21
|
+
"disabled:pointer-events-none disabled:opacity-50",
|
|
22
|
+
"[&_svg]:pointer-events-none [&_svg]:shrink-0",
|
|
23
|
+
],
|
|
24
|
+
variants: {
|
|
25
|
+
// Heights match `segmentedTrackClass`, so a strip and a picker line up.
|
|
26
|
+
// Padding is 6px leading, 10px trailing: an even 8px shunted icon rows right.
|
|
27
|
+
size: {
|
|
28
|
+
sm: "h-6 ps-1.5 pe-2 any-pointer-coarse:h-9 any-pointer-coarse:pe-2.5",
|
|
29
|
+
default: "h-7 ps-1.5 pe-2.5 any-pointer-coarse:h-10",
|
|
30
|
+
},
|
|
31
|
+
/**
|
|
32
|
+
* Fill the row. Off by default: Linear's property rows sit at natural width
|
|
33
|
+
* (80px for "Assign"), so a set of rows reads as a set of chips.
|
|
34
|
+
*/
|
|
35
|
+
block: { true: "flex w-full", false: "w-fit" },
|
|
36
|
+
/** No value yet: the label is a prompt, so it recedes until hovered. */
|
|
37
|
+
empty: { true: "text-muted-foreground/70 hover:text-muted-foreground", false: "" },
|
|
38
|
+
/** A row that carries no action still needs the column's box. */
|
|
39
|
+
inert: { true: "hover:bg-transparent cursor-default", false: "" },
|
|
40
|
+
},
|
|
41
|
+
defaultVariants: { size: "default", block: false, empty: false, inert: false },
|
|
42
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { PhosphorIcon } from "../../lib/icon-types.js";
|
|
2
|
+
|
|
3
|
+
/** One row in a picker menu. */
|
|
4
|
+
export type PickerOption<V extends string = string> = {
|
|
5
|
+
/** The value written when the row is chosen. */
|
|
6
|
+
value: V;
|
|
7
|
+
label: string;
|
|
8
|
+
/** Right-aligned muted text: a date, a count, a unit. Never a second label. */
|
|
9
|
+
hint?: string;
|
|
10
|
+
/** Phosphor component drawn at the head of the row. */
|
|
11
|
+
icon?: PhosphorIcon;
|
|
12
|
+
/** A mark drawn at the head of a row that has no icon: a logo, an avatar.
|
|
13
|
+
* Drawn by whatever fills `lead`, which is what `MarkPicker` is for. */
|
|
14
|
+
image?: string;
|
|
15
|
+
/** Tone for the icon and, when the row is the current value, for the label. */
|
|
16
|
+
tone?: string;
|
|
17
|
+
/** Extra words the search matches, so "team@" finds a person by email. */
|
|
18
|
+
keywords?: string;
|
|
19
|
+
disabled?: boolean;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** A titled run of options. An untitled group is seamless; a titled one rules off. */
|
|
23
|
+
export type PickerGroup<V extends string = string> = {
|
|
24
|
+
heading?: string;
|
|
25
|
+
options: PickerOption<V>[];
|
|
26
|
+
};
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
import type { SegmentContent } from "./segment.svelte";
|
|
3
|
+
|
|
4
|
+
/** One tab. See `SegmentContent` for everything a segment can say. */
|
|
5
|
+
export type TabDef<T extends string = string> = SegmentContent & { value: T };
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Where the tab row sits, the only thing that legitimately differs between
|
|
9
|
+
* two tab sets. A closed union and not a class string: 23 files each decided
|
|
10
|
+
* their own.
|
|
11
|
+
*/
|
|
12
|
+
export type TabsPlacement = "toolbar" | "section" | "inline";
|
|
13
|
+
|
|
14
|
+
const PLACEMENT_WRAPPER: Record<TabsPlacement, string | undefined> = {
|
|
15
|
+
toolbar: undefined,
|
|
16
|
+
section: "shrink-0 border-b border-border py-2",
|
|
17
|
+
inline: undefined,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const PLACEMENT_ROOT: Record<TabsPlacement, string | undefined> = {
|
|
21
|
+
toolbar: undefined,
|
|
22
|
+
section: "gap-0",
|
|
23
|
+
inline: "shrink-0",
|
|
24
|
+
};
|
|
25
|
+
</script>
|
|
26
|
+
|
|
27
|
+
<script lang="ts" generics="T extends string">
|
|
28
|
+
import { Tabs as TabsPrimitive } from "bits-ui";
|
|
29
|
+
import { cn } from "@heyramzi/utils";
|
|
30
|
+
import Segment from "./segment.svelte";
|
|
31
|
+
import SegmentedTrack from "./segmented-track.svelte";
|
|
32
|
+
import SegmentedOverflow from "./segmented-overflow.svelte";
|
|
33
|
+
import { SEGMENT_MAX, splitSegments } from "./collapse.js";
|
|
34
|
+
import {
|
|
35
|
+
segmentedActiveClass,
|
|
36
|
+
segmentedTriggerClass,
|
|
37
|
+
type SegmentedVariant,
|
|
38
|
+
} from "./segmented.js";
|
|
39
|
+
|
|
40
|
+
// WHY re-declare `value` and `onValueChange`: the primitive types them as a
|
|
41
|
+
// bare `string`, so fourteen pages cast back to their union. Generic now, the
|
|
42
|
+
// one narrowing happens here.
|
|
43
|
+
type Props = Omit<TabsPrimitive.RootProps, "value" | "onValueChange"> & {
|
|
44
|
+
tabs: readonly TabDef<T>[];
|
|
45
|
+
value?: T;
|
|
46
|
+
onValueChange?: (value: T) => void;
|
|
47
|
+
// Where the row sits. See `TabsPlacement`.
|
|
48
|
+
placement?: TabsPlacement;
|
|
49
|
+
// WHY: when true the track stretches to its container and triggers share width equally.
|
|
50
|
+
// Use for narrow side panels where pickers should fill the row.
|
|
51
|
+
block?: boolean;
|
|
52
|
+
// `underline`: bare labels over a hairline, a travelling rule marks the active one.
|
|
53
|
+
// Same height and overflow menu. Colour the rule with `--segmented-rule` on `class`.
|
|
54
|
+
variant?: SegmentedVariant;
|
|
55
|
+
ariaLabel?: string;
|
|
56
|
+
toolbar?: import("svelte").Snippet;
|
|
57
|
+
// WHY: content ABOVE the tab row but inside the root. A detail pane names the
|
|
58
|
+
// record first, and the row can't leave the root without losing its tabpanel wiring.
|
|
59
|
+
lead?: import("svelte").Snippet;
|
|
60
|
+
// The page's own horizontal gutter, merged over the placement chrome.
|
|
61
|
+
listWrapperClass?: string;
|
|
62
|
+
// WHY: when only one tab is left the pill row is just decoration. Opt in to
|
|
63
|
+
// drop the row entirely (the lone tab's content still renders).
|
|
64
|
+
hideSingle?: boolean;
|
|
65
|
+
// Objects the row may draw before its tail becomes a menu, the menu
|
|
66
|
+
// counted as one of them. See `collapse.ts`.
|
|
67
|
+
max?: number;
|
|
68
|
+
// What the menu segment says. Translate at the call site.
|
|
69
|
+
moreLabel?: string;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
let {
|
|
73
|
+
ref = $bindable(null),
|
|
74
|
+
value = $bindable(),
|
|
75
|
+
onValueChange,
|
|
76
|
+
tabs,
|
|
77
|
+
placement = "toolbar",
|
|
78
|
+
block = false,
|
|
79
|
+
variant = "pill",
|
|
80
|
+
ariaLabel,
|
|
81
|
+
toolbar,
|
|
82
|
+
lead,
|
|
83
|
+
class: className,
|
|
84
|
+
listWrapperClass,
|
|
85
|
+
hideSingle = false,
|
|
86
|
+
max = SEGMENT_MAX,
|
|
87
|
+
moreLabel,
|
|
88
|
+
children,
|
|
89
|
+
...restProps
|
|
90
|
+
}: Props = $props();
|
|
91
|
+
|
|
92
|
+
let split = $derived(
|
|
93
|
+
splitSegments(
|
|
94
|
+
tabs,
|
|
95
|
+
max,
|
|
96
|
+
tabs.findIndex((tab) => tab.value === value),
|
|
97
|
+
),
|
|
98
|
+
);
|
|
99
|
+
</script>
|
|
100
|
+
|
|
101
|
+
<TabsPrimitive.Root
|
|
102
|
+
bind:ref
|
|
103
|
+
value={value ?? ""}
|
|
104
|
+
onValueChange={(next) => {
|
|
105
|
+
// The single narrowing: bits-ui hands back the string it was given, which
|
|
106
|
+
// is one of `tabs[].value`, so it is a `T`.
|
|
107
|
+
const picked = next as T;
|
|
108
|
+
value = picked;
|
|
109
|
+
onValueChange?.(picked);
|
|
110
|
+
}}
|
|
111
|
+
data-slot="tabs"
|
|
112
|
+
class={cn(
|
|
113
|
+
"group/tabs flex max-w-full min-w-0 flex-col gap-2",
|
|
114
|
+
PLACEMENT_ROOT[placement],
|
|
115
|
+
className,
|
|
116
|
+
)}
|
|
117
|
+
{...restProps}
|
|
118
|
+
>
|
|
119
|
+
{@render lead?.()}
|
|
120
|
+
|
|
121
|
+
{#if !(hideSingle && tabs.length <= 1)}
|
|
122
|
+
<!-- WHY scroll, not wrap: it's one object. Seven categories ran 828px,
|
|
123
|
+
past an iPad's edge. Past `max` the tail collapses first. -->
|
|
124
|
+
<div
|
|
125
|
+
class={cn(
|
|
126
|
+
"no-scrollbar flex max-w-full min-w-0 items-center gap-3 overflow-x-auto",
|
|
127
|
+
PLACEMENT_WRAPPER[placement],
|
|
128
|
+
listWrapperClass,
|
|
129
|
+
)}
|
|
130
|
+
>
|
|
131
|
+
<SegmentedTrack fill={block} {variant}>
|
|
132
|
+
<!-- WHY an inner `display: contents` tablist: a tablist owns only tabs, and the
|
|
133
|
+
overflow trigger isn't one. `contents` keeps segments as direct flex children. -->
|
|
134
|
+
<div class="contents" data-slot="tabs-list" role="tablist" aria-label={ariaLabel}>
|
|
135
|
+
{#each split.visible as tab (tab.value)}
|
|
136
|
+
<TabsPrimitive.Trigger
|
|
137
|
+
value={tab.value}
|
|
138
|
+
class={cn(segmentedTriggerClass({ variant, fill: block }), segmentedActiveClass)}
|
|
139
|
+
>
|
|
140
|
+
<Segment
|
|
141
|
+
label={tab.label}
|
|
142
|
+
count={tab.count}
|
|
143
|
+
icon={tab.icon}
|
|
144
|
+
image={tab.image}
|
|
145
|
+
done={tab.done}
|
|
146
|
+
badge={tab.badge}
|
|
147
|
+
active="auto"
|
|
148
|
+
/>
|
|
149
|
+
</TabsPrimitive.Trigger>
|
|
150
|
+
{/each}
|
|
151
|
+
</div>
|
|
152
|
+
{#snippet after()}
|
|
153
|
+
{#if split.hidden.length > 0}
|
|
154
|
+
<SegmentedOverflow
|
|
155
|
+
items={split.hidden}
|
|
156
|
+
onSelect={(next) => {
|
|
157
|
+
value = next;
|
|
158
|
+
onValueChange?.(next);
|
|
159
|
+
}}
|
|
160
|
+
label={moreLabel}
|
|
161
|
+
fill={block}
|
|
162
|
+
{variant}
|
|
163
|
+
/>
|
|
164
|
+
{/if}
|
|
165
|
+
{/snippet}
|
|
166
|
+
</SegmentedTrack>
|
|
167
|
+
|
|
168
|
+
{#if toolbar}
|
|
169
|
+
{@render toolbar()}
|
|
170
|
+
{/if}
|
|
171
|
+
</div>
|
|
172
|
+
{/if}
|
|
173
|
+
|
|
174
|
+
{@render children?.()}
|
|
175
|
+
</TabsPrimitive.Root>
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, expect, it } from "vite-plus/test";
|
|
2
|
+
import { splitSegments } from "./collapse.js";
|
|
3
|
+
|
|
4
|
+
const items = ["a", "b", "c", "d", "e", "f", "g", "h"] as const;
|
|
5
|
+
|
|
6
|
+
describe("splitSegments", () => {
|
|
7
|
+
it("keeps everything when the row can hold it", () => {
|
|
8
|
+
expect(splitSegments(items.slice(0, 6), 6, 0)).toEqual({
|
|
9
|
+
visible: ["a", "b", "c", "d", "e", "f"],
|
|
10
|
+
hidden: [],
|
|
11
|
+
});
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("leaves one slot for the menu once the row overflows", () => {
|
|
15
|
+
const { visible, hidden } = splitSegments(items, 6, 0);
|
|
16
|
+
expect(visible).toEqual(["a", "b", "c", "d", "e"]);
|
|
17
|
+
expect(hidden).toEqual(["f", "g", "h"]);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("pulls the current item into the last visible slot", () => {
|
|
21
|
+
const { visible, hidden } = splitSegments(items, 6, 6);
|
|
22
|
+
expect(visible).toEqual(["a", "b", "c", "d", "g"]);
|
|
23
|
+
expect(hidden).toEqual(["e", "f", "h"]);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("loses nothing whichever item is current", () => {
|
|
27
|
+
for (let i = 0; i < items.length; i++) {
|
|
28
|
+
const { visible, hidden } = splitSegments(items, 6, i);
|
|
29
|
+
expect([...visible, ...hidden].sort()).toEqual([...items].sort());
|
|
30
|
+
expect(visible).toContain(items[i]);
|
|
31
|
+
expect(visible).toHaveLength(5);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("draws the whole strip when nothing is current and it fits", () => {
|
|
36
|
+
expect(splitSegments(items.slice(0, 3), 6, -1).hidden).toEqual([]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("collapses the tail when nothing is current", () => {
|
|
40
|
+
expect(splitSegments(items, 6, -1)).toEqual({
|
|
41
|
+
visible: ["a", "b", "c", "d", "e"],
|
|
42
|
+
hidden: ["f", "g", "h"],
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("refuses to split into a menu plus one segment", () => {
|
|
47
|
+
expect(splitSegments(items, 2, 5).hidden).toEqual([]);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** How a segmented strip that outgrew its row decides what to keep. */
|
|
2
|
+
|
|
3
|
+
// WHY not a scrollbar: a sideways scroll hid four of five tabs
|
|
4
|
+
// behind a gesture nobody makes with a mouse. The tail becomes a menu instead.
|
|
5
|
+
|
|
6
|
+
// WHY a count, not a measurement: the strip sits in a toolbar sized by its own
|
|
7
|
+
// content, so measuring reads its own width back and never collapses.
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The most objects a strip draws before its tail becomes a menu, the menu
|
|
11
|
+
* counted as one of them. Two to six values is where a segmented control reads best.
|
|
12
|
+
*/
|
|
13
|
+
export const SEGMENT_MAX = 6;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Split items into the drawn ones and the menu's. The current item always stays visible.
|
|
17
|
+
* @param max objects the row may draw, the menu counted as one
|
|
18
|
+
*/
|
|
19
|
+
export function splitSegments<S>(
|
|
20
|
+
items: readonly S[],
|
|
21
|
+
max: number,
|
|
22
|
+
activeIndex: number,
|
|
23
|
+
): { visible: S[]; hidden: S[] } {
|
|
24
|
+
// Under three the row is the menu and a single segment beside it, which says
|
|
25
|
+
// less than the menu alone. Below that, and whenever everything fits, no split.
|
|
26
|
+
if (max < 3 || items.length <= max) return { visible: [...items], hidden: [] };
|
|
27
|
+
|
|
28
|
+
const slots = max - 1;
|
|
29
|
+
if (activeIndex < slots && activeIndex >= 0) {
|
|
30
|
+
return { visible: items.slice(0, slots), hidden: items.slice(slots) };
|
|
31
|
+
}
|
|
32
|
+
if (activeIndex < 0 || activeIndex >= items.length) {
|
|
33
|
+
return { visible: items.slice(0, slots), hidden: items.slice(slots) };
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
visible: [...items.slice(0, slots - 1), ...items.slice(activeIndex, activeIndex + 1)],
|
|
37
|
+
hidden: items.filter((_, i) => i >= slots - 1 && i !== activeIndex),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The segmented control. Three components draw it, one per ARIA shape, and they
|
|
3
|
+
* share the track (`segmented.ts`) and segment body (`segment.svelte`).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// WHY no low-level `Tabs.Root`/`List`/`Trigger` here: that's `shadcn/tabs`. This
|
|
7
|
+
// is the data-driven layer on top, and `AnimatedTabs` is the way in.
|
|
8
|
+
|
|
9
|
+
// Past `SEGMENT_MAX` objects the tail collapses into a menu: `collapse.ts`
|
|
10
|
+
// decides, `segmented-overflow.svelte` draws it. No call site opts in.
|
|
11
|
+
import AnimatedTabs, {
|
|
12
|
+
type TabDef,
|
|
13
|
+
type TabsPlacement,
|
|
14
|
+
} from "./animated-tabs.svelte";
|
|
15
|
+
import SegmentedFilter from "./segmented-filter.svelte";
|
|
16
|
+
import SegmentedNav, {
|
|
17
|
+
type SegmentedNavItem,
|
|
18
|
+
} from "./segmented-nav.svelte";
|
|
19
|
+
import SectionRuns, {
|
|
20
|
+
type SectionRun,
|
|
21
|
+
type SectionStrip,
|
|
22
|
+
} from "./section-runs.svelte";
|
|
23
|
+
import { type SegmentContent } from "./segment.svelte";
|
|
24
|
+
import { segmentedRowClass, type SegmentedVariant } from "./segmented.js";
|
|
25
|
+
import { SEGMENT_MAX } from "./collapse.js";
|
|
26
|
+
|
|
27
|
+
export {
|
|
28
|
+
AnimatedTabs,
|
|
29
|
+
SectionRuns,
|
|
30
|
+
SegmentedFilter,
|
|
31
|
+
SegmentedNav,
|
|
32
|
+
SEGMENT_MAX,
|
|
33
|
+
segmentedRowClass,
|
|
34
|
+
type SectionRun,
|
|
35
|
+
type SectionStrip,
|
|
36
|
+
type SegmentContent,
|
|
37
|
+
type SegmentedNavItem,
|
|
38
|
+
type SegmentedVariant,
|
|
39
|
+
type TabDef,
|
|
40
|
+
type TabsPlacement,
|
|
41
|
+
};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
import type { SegmentedNavItem } from "./segmented-nav.svelte";
|
|
3
|
+
|
|
4
|
+
/** One bordered track on the section row. A run with a `label` is a reference
|
|
5
|
+
* set, not a step, so `SectionRuns` titles it and sends it right. */
|
|
6
|
+
export interface SectionRun {
|
|
7
|
+
label?: string;
|
|
8
|
+
items: readonly SegmentedNavItem[];
|
|
9
|
+
/** Overrides the strip's `current` for this run, so two nested runs each light one tab. */
|
|
10
|
+
current?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// WHY runs: a section can hold a flow and the references it's read against.
|
|
14
|
+
// Each run gets its own track so they don't read as one sequence.
|
|
15
|
+
/** What an app shell publishes so a page's toolbar can draw the section strip. */
|
|
16
|
+
export interface SectionStrip {
|
|
17
|
+
runs: readonly SectionRun[];
|
|
18
|
+
current: string;
|
|
19
|
+
ariaLabel: string;
|
|
20
|
+
}
|
|
21
|
+
</script>
|
|
22
|
+
|
|
23
|
+
<script lang="ts">
|
|
24
|
+
// =========================
|
|
25
|
+
// IMPORTS
|
|
26
|
+
// =========================
|
|
27
|
+
import { cn } from "@heyramzi/utils";
|
|
28
|
+
import SegmentedNav from "./segmented-nav.svelte";
|
|
29
|
+
|
|
30
|
+
// =========================
|
|
31
|
+
// TYPES
|
|
32
|
+
// =========================
|
|
33
|
+
interface Props {
|
|
34
|
+
strip: SectionStrip;
|
|
35
|
+
/** The host row's own constraints: order, width, container queries. */
|
|
36
|
+
class?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// =========================
|
|
40
|
+
// PROPS
|
|
41
|
+
// =========================
|
|
42
|
+
let { strip, class: className }: Props = $props();
|
|
43
|
+
|
|
44
|
+
// =========================
|
|
45
|
+
// DERIVED
|
|
46
|
+
// =========================
|
|
47
|
+
|
|
48
|
+
// The titled run takes the right edge, so a reference set reads apart from the steps.
|
|
49
|
+
let firstTitled = $derived(strip.runs.findIndex((run) => run.label));
|
|
50
|
+
</script>
|
|
51
|
+
|
|
52
|
+
<!-- =========================== -->
|
|
53
|
+
<!-- MARKUP -->
|
|
54
|
+
<!-- =========================== -->
|
|
55
|
+
|
|
56
|
+
<!-- WHY it grows only with a titled run: `ml-auto` needs room to push into, and
|
|
57
|
+
a one-run strip keeps its width beside the search. -->
|
|
58
|
+
<div
|
|
59
|
+
class={cn(
|
|
60
|
+
"no-scrollbar flex min-w-0 items-center gap-x-3 overflow-x-auto",
|
|
61
|
+
firstTitled >= 0 && "flex-1",
|
|
62
|
+
className,
|
|
63
|
+
)}
|
|
64
|
+
>
|
|
65
|
+
{#each strip.runs as run, index (index)}
|
|
66
|
+
<div class={cn("flex shrink-0 items-center gap-2", index === firstTitled && "ml-auto")}>
|
|
67
|
+
{#if run.label}
|
|
68
|
+
<span class="text-[10px] font-medium tracking-wider text-muted-foreground uppercase">
|
|
69
|
+
{run.label}
|
|
70
|
+
</span>
|
|
71
|
+
{/if}
|
|
72
|
+
<SegmentedNav
|
|
73
|
+
items={run.items}
|
|
74
|
+
current={run.current ?? strip.current}
|
|
75
|
+
ariaLabel={run.label ?? strip.ariaLabel}
|
|
76
|
+
/>
|
|
77
|
+
</div>
|
|
78
|
+
{/each}
|
|
79
|
+
</div>
|