@heyramzi/ui 2.0.1 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +48 -24
- package/scripts/sync-shadcn.mjs +3 -4
- package/src/agents/agent-avatar.svelte +604 -575
- package/src/agents/agent-cast.svelte +179 -183
- package/src/agents/demo.svelte +125 -113
- package/src/agents/helpers.ts +23 -17
- package/src/agents/index.ts +4 -4
- package/src/agents/page-state.ts +6 -6
- package/src/agents/types.ts +6 -6
- package/src/ai/ai-button/AiButton.svelte +4 -12
- package/src/ai/generation-progress/GenerationProgress.svelte +10 -3
- package/src/ai/prompt-input/PromptInputTextarea.svelte +1 -2
- package/src/ai/response/Response.css +2 -3
- package/src/animations/avatar-group-hover.css +2 -16
- package/src/animations/card-tilt.css +3 -6
- package/src/animations/digit-pop-in.css +2 -15
- package/src/animations/dropdown.css +4 -17
- package/src/animations/input-clear-dissolve.css +5 -18
- package/src/animations/input-shake.css +9 -41
- package/src/animations/learn-more-hover.css +17 -9
- package/src/animations/like-button.css +2 -4
- package/src/animations/page-side-by-side.css +2 -10
- package/src/animations/plus-menu-morph.css +2 -5
- package/src/animations/shimmer-text.css +5 -18
- package/src/animations/spinning-counter.css +2 -6
- package/src/animations/success-check.css +2 -15
- package/src/animations/thinking-states.css +2 -7
- package/src/brand/brand.test.ts +21 -5
- package/src/brand/index.ts +4 -10
- package/src/canvas/README.md +9 -12
- package/src/canvas/card/card-frame.svelte +43 -39
- package/src/canvas/card/resize-handles.svelte +89 -80
- package/src/canvas/chrome/chrome-zoom.svelte +101 -100
- package/src/canvas/chrome/lock-badge.svelte +8 -8
- package/src/canvas/chrome/side-handles.svelte +24 -19
- package/src/canvas/chrome/size-chip.svelte +30 -30
- package/src/canvas/chrome/snap-guides.svelte +20 -20
- package/src/canvas/components/icons/ToolIcon.svelte +8 -8
- package/src/canvas/components/shared/svg/PreviewSvg.svelte +19 -19
- package/src/canvas/components/shared/svg/ShapeOutline.svelte +71 -48
- package/src/canvas/components/shared/svg/StrokeNode.svelte +21 -27
- package/src/canvas/components/shared/svg/TextRule.svelte +25 -17
- package/src/canvas/edges/bend-handle.svelte +46 -46
- package/src/canvas/edges/edge-gradient.svelte +10 -10
- package/src/canvas/edges/gradient-edge.svelte +45 -29
- package/src/canvas/editor/lock.test.ts +6 -1
- package/src/canvas/editor/style.ts +7 -1
- package/src/canvas/headless/svg-figure.test.ts +5 -1
- package/src/canvas/lod/lod-switch.svelte +111 -106
- package/src/canvas/pen/PenField.svelte +87 -81
- package/src/canvas/pen/PenOverlay.svelte +127 -127
- package/src/canvas/pen/ShapeNode.svelte +31 -32
- package/src/canvas/pen/TextNode.svelte +54 -53
- package/src/canvas/pen/host-field.test.svelte +7 -7
- package/src/canvas/pen/shapes.ts +7 -1
- package/src/canvas/screen/GlyphIcon.svelte +22 -17
- package/src/canvas/screen/PartView.svelte +138 -72
- package/src/canvas/screen/ScreenBody.svelte +81 -60
- package/src/canvas/screen/ScreenCard.svelte +20 -20
- package/src/canvas/ui/CanvasToolbar.svelte +173 -85
- package/src/canvas/ui/FrameLayer.svelte +59 -59
- package/src/canvas/ui/PenLayer.svelte +39 -30
- package/src/canvas/ui/StylePanel.svelte +119 -102
- package/src/canvas/ui/canvas-grid.svelte +137 -129
- package/src/charts/animated-value.svelte +1 -1
- package/src/charts/area-chart.css +2 -5
- package/src/charts/area-chart.svelte +1 -1
- package/src/charts/bar-chart.css +2 -3
- package/src/charts/bar-chart.svelte +1 -1
- package/src/charts/charts.css +5 -17
- package/src/charts/crosshair.svelte +1 -1
- package/src/charts/dashboard/dashboard-empty.svelte +25 -25
- package/src/charts/dashboard/dashboard-section.svelte +24 -24
- package/src/charts/dashboard/data-panel.svelte +50 -50
- package/src/charts/dashboard/data-sort-head.svelte +56 -56
- package/src/charts/dashboard/data-table.svelte +138 -138
- package/src/charts/dashboard/panel-grid.svelte +30 -30
- package/src/charts/dashboard/period-filter.svelte +25 -25
- package/src/charts/dashboard/rating-chip.svelte +19 -19
- package/src/charts/dashboard/stat-band.svelte +32 -32
- package/src/charts/dashboard/stat-tile.svelte +106 -99
- package/src/charts/donut-chart.svelte +1 -1
- package/src/charts/edge-fade.svelte +1 -1
- package/src/charts/figure.svelte +1 -1
- package/src/charts/gauge-chart.css +4 -7
- package/src/charts/gauge-chart.svelte +1 -1
- package/src/charts/hover-focus.svelte +1 -1
- package/src/charts/legend.css +1 -3
- package/src/charts/legend.svelte +1 -1
- package/src/charts/line-chart.svelte +1 -1
- package/src/charts/number-chart.css +6 -9
- package/src/charts/number-chart.svelte +1 -1
- package/src/charts/reference-line.svelte +19 -19
- package/src/charts/reveal.svelte +1 -1
- package/src/charts/ring-chart.css +2 -4
- package/src/charts/ring-chart.svelte +1 -1
- package/src/charts/sparkline.svelte +5 -2
- package/src/charts/stat-card.svelte +1 -1
- package/src/charts/time-series-chart.css +2 -5
- package/src/charts/trend-pill.css +2 -4
- package/src/charts/trend-pill.svelte +1 -1
- package/src/components/copy/copy-button.svelte +93 -98
- package/src/components/copy/copy-button.test.ts +10 -2
- package/src/components/data-table/infinite-scroll.svelte +42 -42
- package/src/components/data-table/pagination.svelte +34 -22
- package/src/components/data-table/row-arrow.svelte +19 -16
- package/src/components/data-table/row-context-menu.svelte +52 -52
- package/src/components/data-table/sortable-head.svelte +25 -25
- package/src/components/data-table/table-skeleton.svelte +41 -36
- package/src/components/docs/doc-page.svelte +18 -9
- package/src/components/docs/on-this-page.svelte +16 -3
- package/src/components/docs/scroll-spy.svelte.ts +2 -3
- package/src/components/field-row/field-row.svelte +10 -8
- package/src/components/hold-to-confirm/hold-to-confirm.svelte +5 -1
- package/src/components/icon/icon.svelte +14 -14
- package/src/components/language-flag/language-flag.svelte +3 -1
- package/src/components/list-page/detail-empty.svelte +34 -34
- package/src/components/list-page/detail-header.svelte +55 -47
- package/src/components/list-page/detail-panel.svelte +51 -59
- package/src/components/list-page/detail-row.svelte +19 -19
- package/src/components/list-page/detail-stack.svelte +21 -21
- package/src/components/list-page/empty-state.svelte +30 -30
- package/src/components/list-page/filter-item.svelte +20 -20
- package/src/components/list-page/filter-popover.svelte +60 -55
- package/src/components/list-page/grouped-list.svelte +4 -2
- package/src/components/list-page/index.ts +8 -4
- package/src/components/list-page/list-page-shell.svelte +2 -5
- package/src/components/list-page/master-detail.svelte +87 -88
- package/src/components/list-page/notice-bar.svelte +38 -38
- package/src/components/list-page/prev-next.svelte +63 -63
- package/src/components/list-page/rail-body.svelte +18 -18
- package/src/components/list-page/rail-empty.svelte +23 -30
- package/src/components/list-page/rail-head.svelte +24 -24
- package/src/components/list-page/rail-row.svelte +77 -73
- package/src/components/list-page/section-host.ts +2 -3
- package/src/components/locale-switch/locale-switch.svelte +41 -41
- package/src/components/mark/mark.svelte +34 -38
- package/src/components/open-in/index.ts +1 -5
- package/src/components/open-in/open-in.svelte +40 -40
- package/src/components/picker/mark-picker.svelte +49 -49
- package/src/components/picker/picker.svelte +5 -3
- package/src/components/section-header/index.ts +1 -0
- package/src/components/section-header/section-header.svelte +1 -1
- package/src/components/section-header/section-header.test.ts +7 -1
- package/src/components/section-header/section-label.svelte +39 -0
- package/src/components/segmented/animated-tabs.svelte +31 -2
- package/src/components/segmented/collapse.test.ts +2 -1
- package/src/components/segmented/index.ts +2 -0
- package/src/components/segmented/segment.svelte +52 -49
- package/src/components/segmented/segmented-filter.svelte +79 -80
- package/src/components/segmented/segmented-nav.svelte +34 -40
- package/src/components/segmented/segmented-overflow.svelte +53 -50
- package/src/components/segmented/segmented-track.svelte +62 -46
- package/src/components/segmented/segmented.ts +2 -3
- package/src/components/star-rating-input/star-rating-input.svelte +103 -103
- package/src/components/svg/svg.svelte +4 -4
- package/src/components/table-view/table-view.svelte +17 -2
- package/src/components/table-view/table-view.test.ts +7 -1
- package/src/components/table-view/types.ts +11 -11
- package/src/components/tags-input/index.ts +13 -0
- package/src/components/tags-input/tags-input.svelte +305 -0
- package/src/components/tags-input/tags.test.ts +64 -0
- package/src/components/tags-input/tags.ts +142 -0
- package/src/components/theme/index.ts +2 -4
- package/src/components/theme/theme-provider.svelte +1 -3
- package/src/components/toolbar/index.ts +2 -0
- package/src/components/{list-page → toolbar}/toolbar-search.svelte +25 -25
- package/src/components/toolbar/toolbar.svelte +13 -9
- package/src/components/toolbar/toolbar.test.ts +10 -1
- package/src/design/index.css +21 -78
- package/src/design/polish.css +26 -35
- package/src/editor/editor-toolbar.svelte +104 -99
- package/src/editor/editor.svelte +296 -266
- package/src/editor/image-toolbar.svelte +20 -22
- package/src/editor/index.ts +1 -4
- package/src/editor/inline-editor.svelte +280 -238
- package/src/editor/markdown.ts +15 -15
- package/src/editor/renderer.svelte +71 -71
- package/src/editor/slash-menu.svelte +24 -27
- package/src/effects/animated-beam/diagrams/BeamSvg.svelte +1 -1
- package/src/effects/image-comparison/ImageComparison.svelte +11 -1
- package/src/effects/interactive-hover-button/InteractiveHoverButton.svelte +59 -46
- package/src/effects/magic-card/MagicCard.css +14 -7
- package/src/effects/marquee/Marquee.svelte +3 -5
- package/src/effects/reveal/observe.ts +0 -3
- package/src/effects/spotlight/Spotlight.svelte +1 -1
- package/src/effects/theme-toggle/ThemeToggle.css +25 -22
- package/src/effects/theme-toggle/ThemeToggle.svelte +6 -7
- package/src/gallery.test.ts +14 -4
- package/src/inbox/README.md +27 -0
- package/src/inbox/inbox.test.ts +214 -0
- package/src/inbox/index.ts +31 -0
- package/src/inbox/logic.ts +169 -0
- package/src/inbox/thread-inbox.svelte +439 -0
- package/src/inbox/types.ts +161 -0
- package/src/lib/theme-transition.ts +2 -4
- package/src/lib/tone.ts +2 -4
- package/src/lib/utils.ts +2 -2
- package/src/modules.test.ts +105 -0
- package/src/onboarding/define-tours.test.ts +55 -40
- package/src/onboarding/define-tours.ts +23 -23
- package/src/onboarding/demo.svelte +24 -5
- package/src/onboarding/index.ts +1 -8
- package/src/onboarding/onboarding-provider.svelte +19 -7
- package/src/onboarding/onboarding.svelte.ts +16 -16
- package/src/onboarding/pick.test.ts +61 -52
- package/src/onboarding/pick.ts +16 -13
- package/src/onboarding/stores.test.ts +80 -80
- package/src/onboarding/stores.ts +41 -39
- package/src/onboarding/tour-card.svelte +30 -5
- package/src/onboarding/tour-runner.svelte +12 -3
- package/src/onboarding/types.ts +20 -12
- package/src/onboarding/wait.ts +22 -22
- package/src/panel/README.md +5 -0
- package/src/panel/index.ts +2 -0
- package/src/panel/side-panel.svelte +194 -0
- package/src/record/README.md +3 -0
- package/src/record/demo-data.ts +352 -0
- package/src/record/field-section.svelte +57 -0
- package/src/record/fields.ts +194 -0
- package/src/record/format.ts +94 -0
- package/src/{tasks/record → record}/index.ts +49 -35
- package/src/{tasks/record → record}/info-list.svelte +145 -114
- package/src/record/kinds.ts +42 -0
- package/src/record/logic.ts +326 -0
- package/src/record/next-up.svelte +81 -0
- package/src/{tasks/record → record}/project-list.svelte +56 -50
- package/src/{tasks/record → record}/record-demo.svelte +130 -110
- package/src/{tasks/record → record}/record-header.svelte +37 -28
- package/src/{tasks/record → record}/record-shell.svelte +54 -54
- package/src/record/record-stats.svelte +48 -0
- package/src/record/record-timeline.svelte +251 -0
- package/src/record/record.test.ts +471 -0
- package/src/{tasks/record → record}/steps-rail.svelte +225 -216
- package/src/record/types.ts +299 -0
- package/src/record/where-we-are.svelte +125 -0
- package/src/shadcn/button/button.svelte +1 -1
- package/src/shadcn/card/card-title.svelte +1 -5
- package/src/shadcn/hooks/use-highlighted-code.svelte.ts +2 -13
- package/src/shadcn/sheet/sheet-content.svelte +2 -4
- package/src/shadcn/table/table-cell.svelte +2 -3
- package/src/shadcn/utilities.css +3 -8
- package/src/slides/Controls.svelte +150 -124
- package/src/slides/Deck.svelte +243 -238
- package/src/slides/Fragment.svelte +64 -57
- package/src/slides/Overview.svelte +106 -101
- package/src/slides/Presenter.svelte +300 -266
- package/src/slides/Slide.svelte +185 -170
- package/src/slides/SlideList.svelte +16 -12
- package/src/slides/ambient.d.ts +1 -1
- package/src/slides/bridges.ts +42 -23
- package/src/slides/deck-controller.svelte.ts +47 -35
- package/src/slides/demo-slides.svelte +330 -306
- package/src/slides/demo.svelte +164 -160
- package/src/slides/export/capture.ts +20 -16
- package/src/slides/export/ffmpeg.ts +43 -38
- package/src/slides/export/index.ts +5 -5
- package/src/slides/export/launch.ts +3 -1
- package/src/slides/export/pdf.ts +30 -27
- package/src/slides/export/types.ts +10 -8
- package/src/slides/index.ts +11 -11
- package/src/slides/labels.ts +22 -19
- package/src/slides/layout.ts +9 -2
- package/src/slides/model.ts +4 -4
- package/src/slides/navigation.ts +43 -31
- package/src/slides/theme.css +5 -7
- package/src/slides/types.ts +9 -10
- package/src/status/README.md +18 -0
- package/src/status/define-statuses.ts +25 -0
- package/src/status/index.ts +11 -0
- package/src/{tasks → status}/status-badge.svelte +3 -1
- package/src/{tasks → status}/status-dot.svelte +1 -3
- package/src/{tasks → status}/status-icon.svelte +1 -1
- package/src/status/status-types.ts +40 -0
- package/src/tasks/README.md +1 -1
- package/src/tasks/board-order.ts +2 -4
- package/src/tasks/demo.svelte +192 -34
- package/src/tasks/detail/detail-demo.svelte +75 -15
- package/src/tasks/detail/detail-types.ts +45 -45
- package/src/tasks/detail/task-detail.svelte +40 -10
- package/src/tasks/detail/task-property.svelte +8 -2
- package/src/tasks/detail/task-thread.svelte +53 -12
- package/src/tasks/gantt/gantt-bar.svelte +17 -6
- package/src/tasks/gantt/gantt-grid.svelte +40 -9
- package/src/tasks/gantt/gantt-group-row.svelte +8 -2
- package/src/tasks/gantt/gantt-header.svelte +9 -3
- package/src/tasks/gantt/gantt-task-row.svelte +26 -6
- package/src/tasks/gantt/gantt.css +2 -1
- package/src/tasks/gantt/gantt.test.ts +4 -2
- package/src/tasks/gantt/logic.test.ts +110 -20
- package/src/tasks/gantt/logic.ts +42 -6
- package/src/tasks/inbox/inbox-card.svelte +15 -7
- package/src/tasks/inbox/inbox-draft.svelte +1 -1
- package/src/tasks/inbox/inbox-logic.ts +6 -40
- package/src/tasks/inbox/inbox-statuses.ts +24 -24
- package/src/tasks/inbox/inbox-trace.svelte +9 -3
- package/src/tasks/inbox/inbox-types.ts +54 -58
- package/src/tasks/inbox/index.ts +22 -21
- package/src/tasks/index.ts +52 -53
- package/src/tasks/kanban-board.svelte +16 -12
- package/src/tasks/kanban-types.ts +2 -3
- package/src/tasks/presets/sales.ts +7 -7
- package/src/tasks/task-gantt.svelte +28 -8
- package/src/tasks/task-list.svelte +41 -17
- package/src/tasks/time/duration.ts +40 -40
- package/src/tasks/time/time-chip.svelte +9 -2
- package/src/tasks/time/time-dot.svelte +1 -1
- package/src/tasks/time/time-ring.svelte +11 -3
- package/src/tasks/time/time-tracker.svelte +57 -15
- package/src/tasks/time/time-types.ts +57 -57
- package/src/tasks/use-kanban-dnd.svelte.ts +2 -3
- package/src/thread/README.md +31 -0
- package/src/thread/index.ts +12 -0
- package/src/thread/logic.ts +61 -0
- package/src/thread/thread.svelte +432 -0
- package/src/thread/thread.test.ts +73 -0
- package/src/thread/types.ts +93 -0
- package/src/tokens/craft.css +17 -53
- package/src/tokens/index.css +3 -13
- package/src/tokens/palette.css +13 -11
- package/src/tokens/primitives.css +10 -23
- package/src/components/list-page/section-label.svelte +0 -39
- package/src/tasks/define-statuses.ts +0 -32
- package/src/tasks/inbox/inbox.svelte +0 -203
- package/src/tasks/record/demo-data.ts +0 -286
- package/src/tasks/record/field-section.svelte +0 -53
- package/src/tasks/record/fields.ts +0 -142
- package/src/tasks/record/format.ts +0 -87
- package/src/tasks/record/kinds.ts +0 -42
- package/src/tasks/record/logic.ts +0 -324
- package/src/tasks/record/next-up.svelte +0 -70
- package/src/tasks/record/record-stats.svelte +0 -48
- package/src/tasks/record/record-timeline.svelte +0 -231
- package/src/tasks/record/record.test.ts +0 -350
- package/src/tasks/record/types.ts +0 -280
- package/src/tasks/record/where-we-are.svelte +0 -121
- package/src/tasks/side-panel.svelte +0 -193
- package/src/tasks/status-types.ts +0 -40
- /package/src/components/{list-page → toolbar}/toolbar-trigger.ts +0 -0
- /package/src/{tasks → panel}/side-panel-types.ts +0 -0
- /package/src/{tasks/record → record}/gutter.ts +0 -0
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// =========================
|
|
3
|
+
// IMPORTS
|
|
4
|
+
// =========================
|
|
5
|
+
import { Plus, X } from "phosphor-svelte";
|
|
6
|
+
import { cn } from "../../lib/utils.js";
|
|
7
|
+
import {
|
|
8
|
+
addTag,
|
|
9
|
+
canCreate,
|
|
10
|
+
findOption,
|
|
11
|
+
matchOptions,
|
|
12
|
+
removeTag,
|
|
13
|
+
splitTags,
|
|
14
|
+
} from "./tags.js";
|
|
15
|
+
|
|
16
|
+
// =========================
|
|
17
|
+
// TYPES
|
|
18
|
+
// =========================
|
|
19
|
+
interface Labels {
|
|
20
|
+
/** Shown in the empty read state and as the input's placeholder. */
|
|
21
|
+
add: string;
|
|
22
|
+
create: (query: string) => string;
|
|
23
|
+
remove: (tag: string) => string;
|
|
24
|
+
max: string;
|
|
25
|
+
/** Shown when what's typed matches nothing and creating isn't allowed. */
|
|
26
|
+
none: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface Props {
|
|
30
|
+
/** The tags. Order is kept. */
|
|
31
|
+
value: readonly string[];
|
|
32
|
+
/** Called with the whole next list. Throw to refuse: the list reverts. */
|
|
33
|
+
onchange: (next: string[]) => void | Promise<void>;
|
|
34
|
+
/** The labels people pick from. Picking one stores its exact spelling and case. */
|
|
35
|
+
options?: readonly string[];
|
|
36
|
+
/**
|
|
37
|
+
* Whether a label that isn't an option may be made, through the "Create" row only.
|
|
38
|
+
* Text close to an option never becomes a label unless that row is chosen.
|
|
39
|
+
*/
|
|
40
|
+
allowCreate?: boolean;
|
|
41
|
+
/** Most tags the list holds. Past it the input goes away. */
|
|
42
|
+
max?: number;
|
|
43
|
+
/** Accessible name of the field. */
|
|
44
|
+
label: string;
|
|
45
|
+
labels?: Partial<Labels>;
|
|
46
|
+
disabled?: boolean;
|
|
47
|
+
class?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// =========================
|
|
51
|
+
// PROPS
|
|
52
|
+
// =========================
|
|
53
|
+
let {
|
|
54
|
+
value,
|
|
55
|
+
onchange,
|
|
56
|
+
options = [],
|
|
57
|
+
allowCreate = true,
|
|
58
|
+
max,
|
|
59
|
+
label,
|
|
60
|
+
labels,
|
|
61
|
+
disabled = false,
|
|
62
|
+
class: className,
|
|
63
|
+
}: Props = $props();
|
|
64
|
+
|
|
65
|
+
// =========================
|
|
66
|
+
// STATE
|
|
67
|
+
// =========================
|
|
68
|
+
let editing = $state(false);
|
|
69
|
+
let query = $state("");
|
|
70
|
+
let active = $state(0);
|
|
71
|
+
// What the list shows while a save is in flight, so a tag doesn't blink out and back.
|
|
72
|
+
let pending = $state<string[] | null>(null);
|
|
73
|
+
let input = $state<HTMLInputElement | null>(null);
|
|
74
|
+
let root = $state<HTMLElement | null>(null);
|
|
75
|
+
const listId = $props.id();
|
|
76
|
+
|
|
77
|
+
// =========================
|
|
78
|
+
// DERIVED
|
|
79
|
+
// =========================
|
|
80
|
+
const text: Labels = $derived({
|
|
81
|
+
add: "Add",
|
|
82
|
+
create: (q) => `Create “${q}”`,
|
|
83
|
+
remove: (tag) => `Remove ${tag}`,
|
|
84
|
+
max: "Limit reached",
|
|
85
|
+
none: "No match",
|
|
86
|
+
...labels,
|
|
87
|
+
});
|
|
88
|
+
const tags = $derived(pending ?? [...value]);
|
|
89
|
+
const full = $derived(max !== undefined && tags.length >= max);
|
|
90
|
+
const matches = $derived(matchOptions(options, tags, query));
|
|
91
|
+
const creatable = $derived(canCreate(options, tags, query, allowCreate));
|
|
92
|
+
// Options closest first (a typo's fix comes before anything new), then the one row that makes what's typed.
|
|
93
|
+
const rows = $derived([
|
|
94
|
+
...matches.map((name) => ({ kind: "pick" as const, name })),
|
|
95
|
+
...(creatable ? [{ kind: "create" as const, name: query.trim() }] : []),
|
|
96
|
+
]);
|
|
97
|
+
const nothing = $derived(editing && !full && query.trim() !== "" && rows.length === 0);
|
|
98
|
+
const open = $derived(editing && !full && (rows.length > 0 || nothing));
|
|
99
|
+
|
|
100
|
+
// =========================
|
|
101
|
+
// FUNCTIONS
|
|
102
|
+
// =========================
|
|
103
|
+
async function commit(next: readonly string[]) {
|
|
104
|
+
if (next === tags) return;
|
|
105
|
+
pending = [...next];
|
|
106
|
+
try {
|
|
107
|
+
await onchange([...next]);
|
|
108
|
+
} finally {
|
|
109
|
+
pending = null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function start() {
|
|
114
|
+
if (disabled) return;
|
|
115
|
+
editing = true;
|
|
116
|
+
queueMicrotask(() => input?.focus());
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Leaving the field keeps what was typed only when it is an existing label said another way.
|
|
120
|
+
// Anything else is dropped: free text is never saved by walking away.
|
|
121
|
+
function stop() {
|
|
122
|
+
const typed = query;
|
|
123
|
+
query = "";
|
|
124
|
+
editing = false;
|
|
125
|
+
const match = findOption(options, typed);
|
|
126
|
+
if (match) void commit(addTag(tags, match, options, max));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function add(name: string) {
|
|
130
|
+
query = "";
|
|
131
|
+
active = 0;
|
|
132
|
+
void commit(addTag(tags, name, options, max));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function onKey(event: KeyboardEvent) {
|
|
136
|
+
const last = rows.length - 1;
|
|
137
|
+
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
138
|
+
if (rows.length === 0) return;
|
|
139
|
+
event.preventDefault();
|
|
140
|
+
const step = event.key === "ArrowDown" ? 1 : -1;
|
|
141
|
+
active = (active + step + rows.length) % rows.length;
|
|
142
|
+
} else if (event.key === "Enter") {
|
|
143
|
+
event.preventDefault();
|
|
144
|
+
// The highlighted row: a close option when there is one, else the visible Create row.
|
|
145
|
+
if (!query.trim()) stop();
|
|
146
|
+
else if (rows.length > 0) add(rows[Math.min(active, last)].name);
|
|
147
|
+
} else if (event.key === "," || (event.key === "Tab" && query.trim())) {
|
|
148
|
+
event.preventDefault();
|
|
149
|
+
if (query.trim() && rows.length > 0) add(rows[Math.min(active, last)].name);
|
|
150
|
+
} else if (event.key === "Backspace" && query === "" && tags.length > 0) {
|
|
151
|
+
void commit(removeTag(tags, tags[tags.length - 1]));
|
|
152
|
+
} else if (event.key === "Escape") {
|
|
153
|
+
event.preventDefault();
|
|
154
|
+
if (query) query = "";
|
|
155
|
+
else {
|
|
156
|
+
editing = false;
|
|
157
|
+
root?.querySelector("button")?.focus();
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Pasted words that are existing labels are added. The rest stay in the box to be created on purpose.
|
|
163
|
+
function onPaste(event: ClipboardEvent) {
|
|
164
|
+
const pasted = event.clipboardData?.getData("text") ?? "";
|
|
165
|
+
if (!/[,\n]/.test(pasted)) return;
|
|
166
|
+
event.preventDefault();
|
|
167
|
+
const parts = splitTags(pasted);
|
|
168
|
+
const known = parts.filter((part) => findOption(options, part));
|
|
169
|
+
const rest = parts.filter((part) => !findOption(options, part));
|
|
170
|
+
query = rest.join(", ");
|
|
171
|
+
void commit(
|
|
172
|
+
known.reduce((list, part) => addTag(list, part, options, max), tags as readonly string[]),
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function onFocusOut(event: FocusEvent) {
|
|
177
|
+
const next = event.relatedTarget as Node | null;
|
|
178
|
+
if (editing && !(next && root?.contains(next))) stop();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// A new query starts from its best match.
|
|
182
|
+
$effect(() => {
|
|
183
|
+
void query;
|
|
184
|
+
active = 0;
|
|
185
|
+
});
|
|
186
|
+
</script>
|
|
187
|
+
|
|
188
|
+
<!-- =========================== -->
|
|
189
|
+
<!-- MARKUP -->
|
|
190
|
+
<!-- WHY: the edit frame is --radius-lg with 4px of padding, so the chip inside is --radius-xs
|
|
191
|
+
(8 - 4), and a read-state chip on its own takes --radius-sm. -->
|
|
192
|
+
<!-- WHY text-base on the input: below 16px iOS zooms the page when it gets focus. -->
|
|
193
|
+
<!-- =========================== -->
|
|
194
|
+
{#snippet chip(tag: string, removable: boolean)}
|
|
195
|
+
<span
|
|
196
|
+
class={cn(
|
|
197
|
+
"inline-flex h-5 max-w-full items-center gap-0.5 bg-muted px-1.5 text-xs font-medium text-foreground",
|
|
198
|
+
removable ? "rounded-(--radius-xs) pr-0.5" : "rounded-(--radius-sm)",
|
|
199
|
+
)}
|
|
200
|
+
>
|
|
201
|
+
<span class="truncate">{tag}</span>
|
|
202
|
+
{#if removable}
|
|
203
|
+
<button
|
|
204
|
+
type="button"
|
|
205
|
+
tabindex="-1"
|
|
206
|
+
class="relative grid size-4 shrink-0 place-items-center rounded-(--radius-xs) text-muted-foreground transition-colors hover:bg-foreground/10 hover:text-foreground before:absolute before:-inset-1.5 before:content-['']"
|
|
207
|
+
aria-label={text.remove(tag)}
|
|
208
|
+
onmousedown={(e) => e.preventDefault()}
|
|
209
|
+
onclick={() => commit(removeTag(tags, tag))}
|
|
210
|
+
>
|
|
211
|
+
<X class="size-3" weight="bold" aria-hidden="true" />
|
|
212
|
+
</button>
|
|
213
|
+
{/if}
|
|
214
|
+
</span>
|
|
215
|
+
{/snippet}
|
|
216
|
+
|
|
217
|
+
<div
|
|
218
|
+
bind:this={root}
|
|
219
|
+
class={cn("relative min-w-0", className)}
|
|
220
|
+
onfocusout={onFocusOut}
|
|
221
|
+
role="presentation"
|
|
222
|
+
>
|
|
223
|
+
{#if editing}
|
|
224
|
+
<div
|
|
225
|
+
class="-ml-1.5 flex min-h-7 flex-wrap items-center gap-1 rounded-lg bg-background p-1 ring-1 ring-ring/60 focus-within:ring-2 focus-within:ring-ring"
|
|
226
|
+
>
|
|
227
|
+
{#each tags as tag (tag.toLowerCase())}
|
|
228
|
+
{@render chip(tag, true)}
|
|
229
|
+
{/each}
|
|
230
|
+
{#if full}
|
|
231
|
+
<span class="px-1 text-xs text-muted-foreground">{text.max}</span>
|
|
232
|
+
{:else}
|
|
233
|
+
<input
|
|
234
|
+
bind:this={input}
|
|
235
|
+
bind:value={query}
|
|
236
|
+
type="text"
|
|
237
|
+
role="combobox"
|
|
238
|
+
aria-label={label}
|
|
239
|
+
aria-expanded={open}
|
|
240
|
+
aria-controls={listId}
|
|
241
|
+
aria-autocomplete="list"
|
|
242
|
+
aria-activedescendant={open ? `${listId}-${active}` : undefined}
|
|
243
|
+
autocomplete="off"
|
|
244
|
+
autocapitalize="off"
|
|
245
|
+
spellcheck="false"
|
|
246
|
+
enterkeyhint="done"
|
|
247
|
+
placeholder={tags.length === 0 ? text.add : ""}
|
|
248
|
+
class="h-5 min-w-16 flex-1 bg-transparent px-1 text-base outline-none placeholder:text-muted-foreground sm:text-xs"
|
|
249
|
+
onkeydown={onKey}
|
|
250
|
+
onpaste={onPaste}
|
|
251
|
+
/>
|
|
252
|
+
{/if}
|
|
253
|
+
</div>
|
|
254
|
+
{#if open}
|
|
255
|
+
<ul
|
|
256
|
+
id={listId}
|
|
257
|
+
role="listbox"
|
|
258
|
+
aria-label={label}
|
|
259
|
+
class="absolute top-full left-0 z-50 mt-1 max-h-60 w-full min-w-48 overflow-y-auto rounded-xl border border-border bg-popover p-1 text-popover-foreground shadow-(--elev-overlay)"
|
|
260
|
+
>
|
|
261
|
+
{#each rows as row, index (row.kind + row.name)}
|
|
262
|
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
263
|
+
<li
|
|
264
|
+
id="{listId}-{index}"
|
|
265
|
+
role="option"
|
|
266
|
+
aria-selected={index === active}
|
|
267
|
+
class={cn(
|
|
268
|
+
"flex min-h-8 cursor-default items-center gap-2 rounded-lg px-2 text-sm any-pointer-coarse:min-h-11",
|
|
269
|
+
index === active && "bg-accent text-accent-foreground",
|
|
270
|
+
)}
|
|
271
|
+
onmousedown={(e) => e.preventDefault()}
|
|
272
|
+
onmousemove={() => (active = index)}
|
|
273
|
+
onclick={() => add(row.name)}
|
|
274
|
+
>
|
|
275
|
+
{#if row.kind === "create"}
|
|
276
|
+
<Plus class="size-3.5 shrink-0 text-muted-foreground" aria-hidden="true" />
|
|
277
|
+
<span class="truncate">{text.create(row.name)}</span>
|
|
278
|
+
{:else}
|
|
279
|
+
<span class="truncate">{row.name}</span>
|
|
280
|
+
{/if}
|
|
281
|
+
</li>
|
|
282
|
+
{/each}
|
|
283
|
+
{#if nothing}
|
|
284
|
+
<li class="flex min-h-8 items-center px-2 text-sm text-muted-foreground">{text.none}</li>
|
|
285
|
+
{/if}
|
|
286
|
+
</ul>
|
|
287
|
+
{/if}
|
|
288
|
+
{:else}
|
|
289
|
+
<button
|
|
290
|
+
type="button"
|
|
291
|
+
class={cn(
|
|
292
|
+
"-ml-1.5 flex min-h-7 w-full flex-wrap items-center gap-1 rounded-lg px-1.5 py-1 text-left transition-colors outline-none hover:bg-muted/60 focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none",
|
|
293
|
+
)}
|
|
294
|
+
aria-label={label}
|
|
295
|
+
{disabled}
|
|
296
|
+
onclick={start}
|
|
297
|
+
>
|
|
298
|
+
{#each tags as tag (tag.toLowerCase())}
|
|
299
|
+
{@render chip(tag, false)}
|
|
300
|
+
{:else}
|
|
301
|
+
<span class="text-sm text-muted-foreground">{text.add}</span>
|
|
302
|
+
{/each}
|
|
303
|
+
</button>
|
|
304
|
+
{/if}
|
|
305
|
+
</div>
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, expect, it } from "vite-plus/test";
|
|
2
|
+
import {
|
|
3
|
+
addTag,
|
|
4
|
+
canCreate,
|
|
5
|
+
cleanTags,
|
|
6
|
+
collectOptions,
|
|
7
|
+
editDistance,
|
|
8
|
+
matchOptions,
|
|
9
|
+
removeTag,
|
|
10
|
+
splitTags,
|
|
11
|
+
} from "./tags.js";
|
|
12
|
+
|
|
13
|
+
const options = ["Airtable", "Docusign", "QuickBooks", "SharePoint", "Supabase"];
|
|
14
|
+
|
|
15
|
+
describe("tags as labels", () => {
|
|
16
|
+
it("splits on commas and newlines", () => {
|
|
17
|
+
expect(splitTags(" a, b,,\nc ,")).toEqual(["a", "b", "c"]);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("counts a transposition as one edit", () => {
|
|
21
|
+
expect(editDistance("airtbale", "airtable")).toBe(1);
|
|
22
|
+
expect(editDistance("make", "airtable")).toBeGreaterThan(2);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("picks the option's spelling for a case or spacing difference", () => {
|
|
26
|
+
expect(addTag([], "airtable", options)).toEqual(["Airtable"]);
|
|
27
|
+
expect(addTag([], "share point", options)).toEqual(["SharePoint"]);
|
|
28
|
+
const held = ["Airtable"];
|
|
29
|
+
expect(addTag(held, "AIRTABLE", options)).toBe(held);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("puts a near miss first and never offers a held option", () => {
|
|
33
|
+
expect(matchOptions(options, [], "Airtabel")[0]).toBe("Airtable");
|
|
34
|
+
expect(matchOptions(options, ["Airtable"], "airtable")).toEqual([]);
|
|
35
|
+
expect(matchOptions(options, [], "su")).toEqual(["Supabase"]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("ranks the same word, a prefix, then a part", () => {
|
|
39
|
+
expect(matchOptions(["QuickBooks", "Books", "Booksy"], [], "books")).toEqual([
|
|
40
|
+
"Books",
|
|
41
|
+
"Booksy",
|
|
42
|
+
"QuickBooks",
|
|
43
|
+
]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("offers create only for a new word, and only when allowed", () => {
|
|
47
|
+
expect(canCreate(options, [], "airtable")).toBe(false);
|
|
48
|
+
expect(canCreate(options, [], "Airtabel")).toBe(true);
|
|
49
|
+
expect(canCreate(options, [], "Make", false)).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("stops at max and removes by any casing", () => {
|
|
53
|
+
expect(addTag(["a", "b"], "c", [], 2)).toEqual(["a", "b"]);
|
|
54
|
+
expect(removeTag(["Airtable"], "airtable")).toEqual([]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("collapses a stored list and collects the most used spelling", () => {
|
|
58
|
+
expect(cleanTags(["docusign", "Docusign"], options)).toEqual(["Docusign"]);
|
|
59
|
+
expect(collectOptions([["Quickbooks"], ["QuickBooks", "Docusign"], ["QuickBooks"]])).toEqual([
|
|
60
|
+
"Docusign",
|
|
61
|
+
"QuickBooks",
|
|
62
|
+
]);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// The tag list's rules, with no DOM. Options are the values already used, so a spelling gets picked,
|
|
2
|
+
// never retyped, and a new tag is a deliberate act.
|
|
3
|
+
|
|
4
|
+
/** Splits typed or pasted text on commas and newlines, trimmed, empties dropped. */
|
|
5
|
+
export function splitTags(text: string): string[] {
|
|
6
|
+
return text
|
|
7
|
+
.split(/[,\n]/)
|
|
8
|
+
.map((part) => part.trim())
|
|
9
|
+
.filter(Boolean);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** What two spellings are compared by: no case, no spaces, no marks ("Share Point" is "SharePoint"). */
|
|
13
|
+
export function fold(text: string): string {
|
|
14
|
+
return text
|
|
15
|
+
.normalize("NFD")
|
|
16
|
+
.replace(/\p{M}/gu, "")
|
|
17
|
+
.toLowerCase()
|
|
18
|
+
.replace(/[\s._-]+/g, "");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const same = (a: string, b: string) => fold(a) === fold(b);
|
|
22
|
+
|
|
23
|
+
/** Edits (insert, delete, replace, swap of neighbours) between two strings, stopping past `limit`. */
|
|
24
|
+
export function editDistance(a: string, b: string, limit = 2): number {
|
|
25
|
+
if (Math.abs(a.length - b.length) > limit) return limit + 1;
|
|
26
|
+
let prev2: number[] = [];
|
|
27
|
+
let prev = Array.from({ length: b.length + 1 }, (_, j) => j);
|
|
28
|
+
for (let i = 1; i <= a.length; i++) {
|
|
29
|
+
const row = [i];
|
|
30
|
+
for (let j = 1; j <= b.length; j++) {
|
|
31
|
+
let d = Math.min(prev[j] + 1, row[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
32
|
+
if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1])
|
|
33
|
+
d = Math.min(d, prev2[j - 2] + 1);
|
|
34
|
+
row.push(d);
|
|
35
|
+
}
|
|
36
|
+
prev2 = prev;
|
|
37
|
+
prev = row;
|
|
38
|
+
}
|
|
39
|
+
return prev[b.length];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** How close `query` is to `option`: 0 the same, 1 a prefix, 2 contained, 3 a typo away, else null. */
|
|
43
|
+
function rank(option: string, query: string): number | null {
|
|
44
|
+
const o = fold(option);
|
|
45
|
+
const q = fold(query);
|
|
46
|
+
if (o === q) return 0;
|
|
47
|
+
if (o.startsWith(q)) return 1;
|
|
48
|
+
if (o.includes(q)) return 2;
|
|
49
|
+
// One edit for a short word, two from 7 letters, and not at all for 3 or fewer.
|
|
50
|
+
const limit = q.length >= 7 ? 2 : q.length >= 4 ? 1 : 0;
|
|
51
|
+
return limit > 0 && editDistance(o, q, limit) <= limit ? 3 : null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The options to offer for what's typed, closest first, from exact match down to a typo away.
|
|
56
|
+
* Options already on the field are left out.
|
|
57
|
+
*/
|
|
58
|
+
export function matchOptions(
|
|
59
|
+
options: readonly string[],
|
|
60
|
+
tags: readonly string[],
|
|
61
|
+
query: string,
|
|
62
|
+
limit = 8,
|
|
63
|
+
): string[] {
|
|
64
|
+
const open = options.filter((option) => !tags.some((held) => same(held, option)));
|
|
65
|
+
if (!query.trim()) return open.slice(0, limit);
|
|
66
|
+
return open
|
|
67
|
+
.flatMap((option) => {
|
|
68
|
+
const r = rank(option, query);
|
|
69
|
+
return r === null ? [] : [{ option, r }];
|
|
70
|
+
})
|
|
71
|
+
.sort((a, b) => a.r - b.r)
|
|
72
|
+
.map(({ option }) => option)
|
|
73
|
+
.slice(0, limit);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The existing option that is `input` apart from case, spacing and marks, or undefined. */
|
|
77
|
+
export function findOption(options: readonly string[], input: string): string | undefined {
|
|
78
|
+
return options.find((option) => same(option, input));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Whether typing `query` may offer "Create": never for a word an option or tag already is. A
|
|
83
|
+
* typo's close option shows above it, never instead.
|
|
84
|
+
*/
|
|
85
|
+
export function canCreate(
|
|
86
|
+
options: readonly string[],
|
|
87
|
+
tags: readonly string[],
|
|
88
|
+
query: string,
|
|
89
|
+
allowCreate = true,
|
|
90
|
+
): boolean {
|
|
91
|
+
return (
|
|
92
|
+
allowCreate && query.trim() !== "" && ![...options, ...tags].some((held) => same(held, query))
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The list after adding the option `name`, in the option's spelling. A repeat returns the same
|
|
98
|
+
* array, so a caller can skip the save.
|
|
99
|
+
*/
|
|
100
|
+
export function addTag(
|
|
101
|
+
tags: readonly string[],
|
|
102
|
+
name: string,
|
|
103
|
+
options: readonly string[] = [],
|
|
104
|
+
max?: number,
|
|
105
|
+
): readonly string[] {
|
|
106
|
+
const tag = findOption(options, name) ?? name.trim();
|
|
107
|
+
if (!tag || tags.some((held) => same(held, tag))) return tags;
|
|
108
|
+
if (max !== undefined && tags.length >= max) return tags;
|
|
109
|
+
return [...tags, tag];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The list without `tag`, whatever its casing. */
|
|
113
|
+
export function removeTag(tags: readonly string[], tag: string): string[] {
|
|
114
|
+
return tags.filter((held) => !same(held, tag));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Collapses spellings of one word to the first, and takes an option's spelling when one matches.
|
|
119
|
+
* For a list that was stored before the options existed.
|
|
120
|
+
*/
|
|
121
|
+
export function cleanTags(tags: readonly string[], options: readonly string[] = []): string[] {
|
|
122
|
+
return tags.reduce<string[]>((out, tag) => [...addTag(out, tag, options)], []);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The distinct values across many lists, one spelling each: the most used wins, then the first
|
|
127
|
+
* seen. This is what an "existing values" field offers as its options.
|
|
128
|
+
*/
|
|
129
|
+
export function collectOptions(lists: Iterable<readonly string[]>): string[] {
|
|
130
|
+
const seen = new Map<string, Map<string, number>>();
|
|
131
|
+
for (const list of lists)
|
|
132
|
+
for (const raw of list) {
|
|
133
|
+
const tag = raw.trim();
|
|
134
|
+
if (!tag) continue;
|
|
135
|
+
const spellings = seen.get(fold(tag)) ?? new Map<string, number>();
|
|
136
|
+
spellings.set(tag, (spellings.get(tag) ?? 0) + 1);
|
|
137
|
+
seen.set(fold(tag), spellings);
|
|
138
|
+
}
|
|
139
|
+
return [...seen.values()]
|
|
140
|
+
.map((spellings) => [...spellings].sort((a, b) => b[1] - a[1])[0][0])
|
|
141
|
+
.sort((a, b) => a.localeCompare(b));
|
|
142
|
+
}
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The one light and dark system
|
|
3
|
-
*
|
|
4
|
-
* read it from `mode.current` or `userPrefersMode.current`. `ThemeToggle` and `revealTheme`
|
|
5
|
-
* draw the control and the wipe.
|
|
2
|
+
* The one light and dark system, wrapping mode-watcher. Mount `ThemeProvider` once in the root
|
|
3
|
+
* layout, or the no-flash script never runs.
|
|
6
4
|
*/
|
|
7
5
|
export { default as ThemeProvider } from "./theme-provider.svelte";
|
|
8
6
|
export { revealTheme } from "../../lib/theme-transition.js";
|
|
@@ -42,8 +42,6 @@
|
|
|
42
42
|
});
|
|
43
43
|
</script>
|
|
44
44
|
|
|
45
|
-
<!-- WHY
|
|
46
|
-
colour-scheme before the first paint, so there is no flash and no cookie to read.
|
|
47
|
-
WHY synchronous: `revealTheme` snapshots the page the moment its callback returns, so the
|
|
45
|
+
<!-- WHY synchronous: `revealTheme` snapshots the page the moment its callback returns, so the
|
|
48
46
|
class has to be on the root by then. -->
|
|
49
47
|
<ModeWatcher defaultMode={initial} {synchronousModeChanges} {...watcher} />
|
|
@@ -1,30 +1,30 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
// =========================
|
|
3
|
-
// IMPORTS
|
|
4
|
-
// =========================
|
|
5
|
-
import { Input } from "../../shadcn/input/index.js";
|
|
6
|
-
import { MagnifyingGlass } from "phosphor-svelte";
|
|
7
|
-
import { cn } from "../../lib/utils.js";
|
|
2
|
+
// =========================
|
|
3
|
+
// IMPORTS
|
|
4
|
+
// =========================
|
|
5
|
+
import { Input } from "../../shadcn/input/index.js";
|
|
6
|
+
import { MagnifyingGlass } from "phosphor-svelte";
|
|
7
|
+
import { cn } from "../../lib/utils.js";
|
|
8
8
|
|
|
9
|
-
// =========================
|
|
10
|
-
// TYPES
|
|
11
|
-
// =========================
|
|
12
|
-
interface Props {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
}
|
|
9
|
+
// =========================
|
|
10
|
+
// TYPES
|
|
11
|
+
// =========================
|
|
12
|
+
interface Props {
|
|
13
|
+
value: string;
|
|
14
|
+
placeholder?: string;
|
|
15
|
+
size?: "sm" | "default";
|
|
16
|
+
class?: string;
|
|
17
|
+
}
|
|
18
18
|
|
|
19
|
-
// =========================
|
|
20
|
-
// PROPS
|
|
21
|
-
// =========================
|
|
22
|
-
let {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
}: Props = $props();
|
|
19
|
+
// =========================
|
|
20
|
+
// PROPS
|
|
21
|
+
// =========================
|
|
22
|
+
let {
|
|
23
|
+
value = $bindable(""),
|
|
24
|
+
placeholder = "Search...",
|
|
25
|
+
size = "default",
|
|
26
|
+
class: extraClass,
|
|
27
|
+
}: Props = $props();
|
|
28
28
|
</script>
|
|
29
29
|
|
|
30
30
|
<!-- =========================== -->
|
|
@@ -39,7 +39,7 @@ let {
|
|
|
39
39
|
class={cn(
|
|
40
40
|
"relative",
|
|
41
41
|
extraClass,
|
|
42
|
-
"@max-3xl/toolbar:w-auto @max-3xl/toolbar:min-w-0 @max-3xl/toolbar:flex-1"
|
|
42
|
+
"@max-3xl/toolbar:w-auto @max-3xl/toolbar:min-w-0 @max-3xl/toolbar:flex-1",
|
|
43
43
|
)}
|
|
44
44
|
>
|
|
45
45
|
<MagnifyingGlass
|
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
import { CaretDown, MagnifyingGlass } from "phosphor-svelte";
|
|
7
7
|
import { SegmentedFilter } from "../segmented/index.js";
|
|
8
8
|
import type { TabDef } from "../segmented/animated-tabs.svelte";
|
|
9
|
-
import { toolbarTriggerClass } from "
|
|
10
|
-
import ToolbarSearch from "
|
|
9
|
+
import { toolbarTriggerClass } from "./toolbar-trigger.js";
|
|
10
|
+
import ToolbarSearch from "./toolbar-search.svelte";
|
|
11
11
|
import { Input } from "../../shadcn/input/index.js";
|
|
12
12
|
import * as DropdownMenu from "../../shadcn/dropdown-menu/index.js";
|
|
13
13
|
import * as Popover from "../../shadcn/popover/index.js";
|
|
@@ -66,11 +66,8 @@
|
|
|
66
66
|
<!-- =========================== -->
|
|
67
67
|
<!-- MARKUP -->
|
|
68
68
|
<!-- =========================== -->
|
|
69
|
-
<!--
|
|
70
|
-
|
|
71
|
-
page as a search is opened. Search, tabs and view are the three that fold, each into the one
|
|
72
|
-
menu that holds them; both forms are in the DOM and the query shows one, so there is no
|
|
73
|
-
measuring and no flash on first paint. -->
|
|
69
|
+
<!-- A container query, not a breakpoint, because the toolbar sits in a pane narrower than the
|
|
70
|
+
screen; both forms stay in the DOM, so nothing flashes. -->
|
|
74
71
|
<div class={cn("@container/toolbar min-w-0", className)}>
|
|
75
72
|
<div class="flex flex-nowrap items-center gap-2">
|
|
76
73
|
{#if search !== undefined}
|
|
@@ -85,7 +82,12 @@
|
|
|
85
82
|
<MagnifyingGlass class="size-3.5" aria-hidden="true" />
|
|
86
83
|
</Popover.Trigger>
|
|
87
84
|
<Popover.Content align="start" class="w-64 p-2">
|
|
88
|
-
<Input
|
|
85
|
+
<Input
|
|
86
|
+
bind:value={search}
|
|
87
|
+
placeholder={searchPlaceholder}
|
|
88
|
+
aria-label={searchLabel}
|
|
89
|
+
class="h-8"
|
|
90
|
+
/>
|
|
89
91
|
</Popover.Content>
|
|
90
92
|
</Popover.Root>
|
|
91
93
|
{/if}
|
|
@@ -112,7 +114,9 @@
|
|
|
112
114
|
<DropdownMenu.RadioItem value={item.value}>
|
|
113
115
|
{item.label}
|
|
114
116
|
{#if item.count != null}
|
|
115
|
-
<span class="ms-auto pe-2 text-xs text-muted-foreground tabular-nums"
|
|
117
|
+
<span class="ms-auto pe-2 text-xs text-muted-foreground tabular-nums"
|
|
118
|
+
>{item.count}</span
|
|
119
|
+
>
|
|
116
120
|
{/if}
|
|
117
121
|
</DropdownMenu.RadioItem>
|
|
118
122
|
{/each}
|
|
@@ -45,7 +45,16 @@ describe("Toolbar", () => {
|
|
|
45
45
|
});
|
|
46
46
|
|
|
47
47
|
it("names the narrow controls so a screen reader can find them", () => {
|
|
48
|
-
const root = render({
|
|
48
|
+
const root = render({
|
|
49
|
+
search: "",
|
|
50
|
+
tabs,
|
|
51
|
+
tab: "all",
|
|
52
|
+
views,
|
|
53
|
+
view: "list",
|
|
54
|
+
searchLabel: "Find",
|
|
55
|
+
tabsLabel: "Status",
|
|
56
|
+
viewLabel: "Layout",
|
|
57
|
+
});
|
|
49
58
|
for (const label of ["Find", "Status", "Layout"]) {
|
|
50
59
|
expect(root.querySelector(`button[aria-label="${label}"]`), label).not.toBeNull();
|
|
51
60
|
}
|