@heyramzi/ui 1.53.0 → 1.54.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 +6 -0
- package/package.json +2 -2
- package/scripts/lib/sync-core.mjs +1 -1
- package/scripts/sync-shadcn.mjs +11 -11
- package/scripts/sync-transitions.mjs +7 -7
- package/src/agents/agent-avatar.svelte +14 -15
- package/src/agents/agent-cast.svelte +2 -2
- package/src/agents/demo.svelte +1 -1
- package/src/agents/helpers.ts +10 -1
- package/src/agents/index.ts +2 -2
- package/src/agents/page-state.ts +1 -1
- package/src/agents/types.ts +3 -3
- package/src/components/copy/copy-button.test.ts +3 -1
- package/src/components/data-table/infinite-scroll.svelte +1 -1
- package/src/components/data-table/pagination.svelte +1 -1
- package/src/components/data-table/row-arrow.svelte +1 -1
- package/src/components/data-table/row-context-menu.svelte +1 -1
- package/src/components/data-table/sortable-head.svelte +2 -2
- package/src/components/data-table/table-skeleton.svelte +1 -1
- package/src/components/docs/doc-page.svelte +161 -0
- package/src/components/docs/index.ts +12 -0
- package/src/components/docs/on-this-page.svelte +217 -0
- package/src/components/docs/scroll-spy.svelte.ts +97 -0
- package/src/components/field-row/field-row.svelte +57 -0
- package/src/components/field-row/index.ts +1 -0
- package/src/components/hold-to-confirm/hold-to-confirm.svelte +2 -2
- package/src/components/icon/icon.svelte +1 -1
- package/src/components/list-page/detail-empty.svelte +1 -1
- package/src/components/list-page/detail-header.svelte +1 -1
- package/src/components/list-page/detail-panel.svelte +1 -1
- package/src/components/list-page/detail-row.svelte +1 -1
- package/src/components/list-page/detail-stack.svelte +1 -1
- package/src/components/list-page/filter-item.svelte +1 -1
- package/src/components/list-page/filter-popover.svelte +1 -1
- package/src/components/list-page/grouped-list.svelte +1 -1
- package/src/components/list-page/list-page-shell.svelte +1 -1
- package/src/components/list-page/master-detail.svelte +1 -1
- package/src/components/list-page/prev-next.svelte +1 -1
- package/src/components/list-page/rail-body.svelte +1 -1
- package/src/components/list-page/rail-head.svelte +1 -1
- package/src/components/list-page/rail-row.svelte +1 -1
- package/src/components/list-page/section-label.svelte +1 -1
- package/src/components/list-page/toolbar-search.svelte +2 -2
- package/src/components/list-page/toolbar-trigger.ts +1 -1
- package/src/components/locale-switch/locale-switch.svelte +1 -1
- package/src/components/mark/mark.svelte +1 -1
- package/src/components/picker/mark-picker.svelte +1 -1
- package/src/components/picker/picker.svelte +1 -1
- package/src/components/picker/trigger.ts +2 -2
- package/src/components/section-card/index.ts +1 -0
- package/src/components/section-card/section-card.svelte +43 -0
- package/src/components/section-header/index.ts +1 -0
- package/src/components/section-header/section-header.svelte +54 -0
- package/src/components/section-header/section-header.test.ts +25 -0
- package/src/components/segmented/animated-tabs.svelte +2 -2
- package/src/components/segmented/collapse.test.ts +10 -0
- package/src/components/segmented/collapse.ts +5 -0
- package/src/components/segmented/segment.svelte +2 -2
- package/src/components/segmented/segmented-filter.svelte +6 -5
- package/src/components/segmented/segmented-nav.svelte +1 -1
- package/src/components/segmented/segmented-overflow.svelte +1 -1
- package/src/components/segmented/segmented-track.svelte +29 -4
- package/src/components/segmented/segmented.test.ts +1 -1
- package/src/components/segmented/segmented.ts +2 -2
- package/src/components/star-rating-input/star-rating-input.svelte +11 -4
- package/src/components/table-view/index.ts +3 -0
- package/src/components/table-view/table-view.svelte +103 -0
- package/src/components/table-view/table-view.test.ts +53 -0
- package/src/components/table-view/types.ts +14 -0
- package/src/components/theme/index.ts +17 -0
- package/src/components/theme/theme-provider.svelte +47 -0
- package/src/components/theme/theme-provider.test.ts +61 -0
- package/src/effects/animated-beam/diagrams/BeamSvg.svelte +1 -1
- package/src/effects/animated-beam/path-calculator.svelte.ts +1 -1
- package/src/effects/border-beam/types.ts +1 -1
- package/src/effects/interactive-hover-button/InteractiveHoverButton.svelte +1 -1
- package/src/effects/shader-gradient/shaders.ts +1 -1
- package/src/effects/spotlight/Spotlight.svelte +1 -2
- package/src/icons/trades.ts +3 -3
- package/src/lib/sliding-pill.ts +3 -3
- package/src/lib/tone.test.ts +10 -0
- package/src/lib/tone.ts +1 -1
- package/src/shadcn/badge/badge.svelte +1 -1
- package/src/shadcn/button/button.svelte +10 -1
- package/src/shadcn/button/button.test.ts +19 -0
- package/src/shadcn/checkbox/checkbox.svelte +1 -1
- package/src/shadcn/context-menu/context-menu-separator.svelte +4 -1
- package/src/shadcn/dropdown-menu/dropdown-menu-separator.svelte +4 -1
- package/src/shadcn/input/input.svelte +2 -2
- package/src/shadcn/progress/progress.svelte +8 -2
- package/src/shadcn/progress/tone.test.ts +13 -0
- package/src/shadcn/progress/tone.ts +15 -0
- package/src/shadcn/sidebar/sidebar-group-label.svelte +1 -1
- package/src/shadcn/table/table-cell.svelte +21 -1
- package/src/shadcn/table/table-cell.test.ts +16 -0
- package/src/tokens/base.css +183 -0
- package/src/tokens/craft.css +179 -0
- package/src/tokens/index.css +19 -0
- package/src/tokens/palette.css +410 -0
- package/src/tokens/primitives.css +223 -0
- package/scripts/relative-imports.mjs +0 -39
- package/src/tokens.css +0 -993
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// =========================
|
|
3
|
+
// IMPORTS
|
|
4
|
+
// =========================
|
|
5
|
+
import type { Snippet } from "svelte";
|
|
6
|
+
import { cn } from "@heyramzi/utils";
|
|
7
|
+
import * as Card from "../../shadcn/card/index.js";
|
|
8
|
+
|
|
9
|
+
// =========================
|
|
10
|
+
// TYPES
|
|
11
|
+
// =========================
|
|
12
|
+
interface Props {
|
|
13
|
+
title: string;
|
|
14
|
+
/** Controls at the far end of the header. */
|
|
15
|
+
actions?: Snippet;
|
|
16
|
+
/** No padding in the body, so a table runs edge to edge under the header. */
|
|
17
|
+
flush?: boolean;
|
|
18
|
+
class?: string;
|
|
19
|
+
children: Snippet;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// =========================
|
|
23
|
+
// PROPS
|
|
24
|
+
// =========================
|
|
25
|
+
let { title, actions, flush = false, class: className, children }: Props = $props();
|
|
26
|
+
</script>
|
|
27
|
+
|
|
28
|
+
<!-- =========================== -->
|
|
29
|
+
<!-- MARKUP -->
|
|
30
|
+
<!-- WHY a fixed header height: a card with an action in its header and one
|
|
31
|
+
without sat 12px apart on the same page. -->
|
|
32
|
+
<!-- =========================== -->
|
|
33
|
+
<Card.Root class={cn("gap-0 py-0", className)}>
|
|
34
|
+
<header class="flex min-h-14 items-center justify-between gap-3 border-b px-4 py-3 sm:px-6">
|
|
35
|
+
<Card.Title as="h3" class="text-base font-medium">{title}</Card.Title>
|
|
36
|
+
{#if actions}
|
|
37
|
+
<div class="flex shrink-0 items-center gap-2">{@render actions()}</div>
|
|
38
|
+
{/if}
|
|
39
|
+
</header>
|
|
40
|
+
<div class={flush ? "" : "p-4 sm:p-6"}>
|
|
41
|
+
{@render children()}
|
|
42
|
+
</div>
|
|
43
|
+
</Card.Root>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default as SectionHeader } from "./section-header.svelte";
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// =========================
|
|
3
|
+
// IMPORTS
|
|
4
|
+
// =========================
|
|
5
|
+
import type { Snippet } from "svelte";
|
|
6
|
+
import SectionLabel from "../list-page/section-label.svelte";
|
|
7
|
+
|
|
8
|
+
// =========================
|
|
9
|
+
// TYPES
|
|
10
|
+
// =========================
|
|
11
|
+
interface Props {
|
|
12
|
+
title: string;
|
|
13
|
+
/** How many things the section holds. Drawn as a chip next to the title. */
|
|
14
|
+
count?: number;
|
|
15
|
+
/** What the count counts, for a screen reader: "lines". */
|
|
16
|
+
countLabel?: string;
|
|
17
|
+
/** One line under the title. */
|
|
18
|
+
description?: string;
|
|
19
|
+
/** A control at the far end of the row. */
|
|
20
|
+
actions?: Snippet;
|
|
21
|
+
/** The heading level. Sections under a page title are 2. */
|
|
22
|
+
level?: 2 | 3;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// =========================
|
|
26
|
+
// PROPS
|
|
27
|
+
// =========================
|
|
28
|
+
let { title, count, countLabel = "items", description, actions, level = 2 }: Props = $props();
|
|
29
|
+
</script>
|
|
30
|
+
|
|
31
|
+
<!-- =========================== -->
|
|
32
|
+
<!-- MARKUP -->
|
|
33
|
+
<!-- =========================== -->
|
|
34
|
+
<header class="flex items-end justify-between gap-3">
|
|
35
|
+
<div class="min-w-0 space-y-1">
|
|
36
|
+
<svelte:element this={`h${level}`} class="flex items-center gap-2">
|
|
37
|
+
<SectionLabel>{title}</SectionLabel>
|
|
38
|
+
{#if count !== undefined}
|
|
39
|
+
<span
|
|
40
|
+
class="rounded-md bg-muted px-1.5 py-0.5 text-xs leading-none font-medium tabular-nums text-muted-foreground"
|
|
41
|
+
aria-label={`${count} ${countLabel}`}
|
|
42
|
+
>
|
|
43
|
+
{count}
|
|
44
|
+
</span>
|
|
45
|
+
{/if}
|
|
46
|
+
</svelte:element>
|
|
47
|
+
{#if description}
|
|
48
|
+
<p class="text-sm text-muted-foreground">{description}</p>
|
|
49
|
+
{/if}
|
|
50
|
+
</div>
|
|
51
|
+
{#if actions}
|
|
52
|
+
<div class="flex shrink-0 items-center gap-2">{@render actions()}</div>
|
|
53
|
+
{/if}
|
|
54
|
+
</header>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { flushSync, mount, unmount } from "svelte";
|
|
2
|
+
import { describe, expect, it } from "vite-plus/test";
|
|
3
|
+
import SectionHeader from "./section-header.svelte";
|
|
4
|
+
|
|
5
|
+
function render(props: Record<string, unknown>) {
|
|
6
|
+
const target = document.createElement("div");
|
|
7
|
+
document.body.appendChild(target);
|
|
8
|
+
const app = mount(SectionHeader, { target, props: { title: "Lines", ...props } });
|
|
9
|
+
flushSync();
|
|
10
|
+
return { target, done: () => { void unmount(app); target.remove(); } };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe("SectionHeader", () => {
|
|
14
|
+
it("draws the count as a chip a screen reader can name", () => {
|
|
15
|
+
const { target, done } = render({ count: 3, countLabel: "lines" });
|
|
16
|
+
expect(target.querySelector("[aria-label='3 lines']")).not.toBeNull();
|
|
17
|
+
done();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("takes its heading level", () => {
|
|
21
|
+
const { target, done } = render({ level: 3 });
|
|
22
|
+
expect(target.querySelector("h3")).not.toBeNull();
|
|
23
|
+
done();
|
|
24
|
+
});
|
|
25
|
+
});
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
// WHY re-declare `value` and `onValueChange`: the primitive types them as a
|
|
41
41
|
// bare `string`, so fourteen pages cast back to their union. Generic now, the
|
|
42
42
|
// one narrowing happens here.
|
|
43
|
-
|
|
43
|
+
interface Props extends Omit<TabsPrimitive.RootProps, "value" | "onValueChange"> {
|
|
44
44
|
tabs: readonly TabDef<T>[];
|
|
45
45
|
value?: T;
|
|
46
46
|
onValueChange?: (value: T) => void;
|
|
@@ -67,7 +67,7 @@
|
|
|
67
67
|
max?: number;
|
|
68
68
|
// What the menu segment says. Translate at the call site.
|
|
69
69
|
moreLabel?: string;
|
|
70
|
-
}
|
|
70
|
+
}
|
|
71
71
|
|
|
72
72
|
let {
|
|
73
73
|
ref = $bindable(null),
|
|
@@ -47,3 +47,13 @@ describe("splitSegments", () => {
|
|
|
47
47
|
expect(splitSegments(items, 2, 5).hidden).toEqual([]);
|
|
48
48
|
});
|
|
49
49
|
});
|
|
50
|
+
|
|
51
|
+
describe("resolveSegmentMax", () => {
|
|
52
|
+
it("turns none into a limit nothing reaches, so no tail folds", async () => {
|
|
53
|
+
const { resolveSegmentMax, splitSegments } = await import("./collapse.js");
|
|
54
|
+
const items = Array.from({ length: 20 }, (_, i) => i);
|
|
55
|
+
expect(resolveSegmentMax("none")).toBe(Number.POSITIVE_INFINITY);
|
|
56
|
+
expect(resolveSegmentMax(4)).toBe(4);
|
|
57
|
+
expect(splitSegments(items, resolveSegmentMax("none"), 15).hidden).toEqual([]);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -37,3 +37,8 @@ export function splitSegments<S>(
|
|
|
37
37
|
hidden: items.filter((_, i) => i >= slots - 1 && i !== activeIndex),
|
|
38
38
|
};
|
|
39
39
|
}
|
|
40
|
+
|
|
41
|
+
/** `"none"` draws every segment: the row scrolls sideways instead of folding a tail into a menu. */
|
|
42
|
+
export function resolveSegmentMax(max: number | "none"): number {
|
|
43
|
+
return max === "none" ? Number.POSITIVE_INFINITY : max;
|
|
44
|
+
}
|
|
@@ -36,13 +36,13 @@ import { Mark } from "../mark/index.js";
|
|
|
36
36
|
// =========================
|
|
37
37
|
// TYPES
|
|
38
38
|
// =========================
|
|
39
|
-
|
|
39
|
+
interface Props extends SegmentContent {
|
|
40
40
|
/**
|
|
41
41
|
* `true`/`false` when the caller owns the state. `"auto"` when bits-ui owns it
|
|
42
42
|
* in a `data-state` on the trigger, and the stylesheet answers.
|
|
43
43
|
*/
|
|
44
44
|
active?: boolean | "auto";
|
|
45
|
-
}
|
|
45
|
+
}
|
|
46
46
|
|
|
47
47
|
// =========================
|
|
48
48
|
// PROPS
|
|
@@ -7,13 +7,13 @@ import Segment from "./segment.svelte";
|
|
|
7
7
|
import type { TabDef } from "./animated-tabs.svelte";
|
|
8
8
|
import SegmentedTrack from "./segmented-track.svelte";
|
|
9
9
|
import SegmentedOverflow from "./segmented-overflow.svelte";
|
|
10
|
-
import { SEGMENT_MAX, splitSegments } from "./collapse.js";
|
|
10
|
+
import { SEGMENT_MAX, resolveSegmentMax, splitSegments } from "./collapse.js";
|
|
11
11
|
import { segmentedTriggerClass } from "./segmented.js";
|
|
12
12
|
|
|
13
13
|
// =========================
|
|
14
14
|
// TYPES
|
|
15
15
|
// =========================
|
|
16
|
-
|
|
16
|
+
interface Props {
|
|
17
17
|
// WHY: picks which rows are listed, with no panel of its own. That's a
|
|
18
18
|
// segmented control, not a tab set.
|
|
19
19
|
|
|
@@ -33,8 +33,9 @@ type Props = {
|
|
|
33
33
|
/** Fill the row rather than sit at its natural width. On for the list-page
|
|
34
34
|
* filter strip, off for a segment sitting in a toolbar beside others. */
|
|
35
35
|
block?: boolean;
|
|
36
|
-
/** Objects the row may draw before its tail becomes a menu. See `collapse.ts`.
|
|
37
|
-
|
|
36
|
+
/** Objects the row may draw before its tail becomes a menu. See `collapse.ts`.
|
|
37
|
+
* `"none"` never folds. */
|
|
38
|
+
max?: number | "none";
|
|
38
39
|
/** What the menu segment says. Translate at the call site. */
|
|
39
40
|
moreLabel?: string;
|
|
40
41
|
class?: string;
|
|
@@ -70,7 +71,7 @@ let merged = $derived(
|
|
|
70
71
|
let split = $derived(
|
|
71
72
|
splitSegments(
|
|
72
73
|
merged,
|
|
73
|
-
max,
|
|
74
|
+
resolveSegmentMax(max),
|
|
74
75
|
merged.findIndex((option) => option.value === value),
|
|
75
76
|
),
|
|
76
77
|
);
|
|
@@ -17,7 +17,7 @@ import { segmentedTriggerClass } from "./segmented.js";
|
|
|
17
17
|
// =========================
|
|
18
18
|
// TYPES
|
|
19
19
|
// =========================
|
|
20
|
-
|
|
20
|
+
interface Props {
|
|
21
21
|
// WHY a third component: tabs are a `tablist`, filters a `group` of toggles,
|
|
22
22
|
// and this goes to sibling pages, so it's a `nav` of links with `aria-current`.
|
|
23
23
|
|
|
@@ -18,7 +18,7 @@ import type { SegmentContent } from "./segment.svelte";
|
|
|
18
18
|
|
|
19
19
|
// WHY never active: `splitSegments` pulls the current item into a visible slot,
|
|
20
20
|
// so this never fights bits-ui's `data-state="open"`.
|
|
21
|
-
|
|
21
|
+
interface Props {
|
|
22
22
|
items: readonly (SegmentContent & { value: V })[];
|
|
23
23
|
onSelect: (value: V) => void;
|
|
24
24
|
/** What the segment says. Translate at the call site. */
|
|
@@ -10,7 +10,7 @@ import { slidingPill } from "../../lib/sliding-pill.js";
|
|
|
10
10
|
// =========================
|
|
11
11
|
// TYPES
|
|
12
12
|
// =========================
|
|
13
|
-
|
|
13
|
+
interface Props {
|
|
14
14
|
/** Fill the row rather than sit at its natural width. */
|
|
15
15
|
fill?: boolean;
|
|
16
16
|
/** `pill`, a bordered well with a travelling highlight, or `underline`, a travelling rule. */
|
|
@@ -86,12 +86,17 @@ let {
|
|
|
86
86
|
.track :global(.ui-segment[data-state="active"] + .ui-segment)::before,
|
|
87
87
|
.track :global(.ui-segment[aria-current="page"] + .ui-segment)::before,
|
|
88
88
|
.track :global(.ui-segment[aria-pressed="true"] + .ui-segment)::before,
|
|
89
|
-
.track :global([data-slot="tabs-list"]:has(> .ui-segment:last-child[data-state="active"]) + .ui-segment)::before
|
|
90
|
-
.track :global(.ui-segment:hover)::before,
|
|
91
|
-
.track :global(.ui-segment:hover + .ui-segment)::before {
|
|
89
|
+
.track :global([data-slot="tabs-list"]:has(> .ui-segment:last-child[data-state="active"]) + .ui-segment)::before {
|
|
92
90
|
opacity: 0;
|
|
93
91
|
}
|
|
94
92
|
|
|
93
|
+
@media (hover: hover) and (pointer: fine) {
|
|
94
|
+
.track :global(.ui-segment:hover)::before,
|
|
95
|
+
.track :global(.ui-segment:hover + .ui-segment)::before {
|
|
96
|
+
opacity: 0;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
95
100
|
/* The label's colour lands with the pill, on the same fast step. */
|
|
96
101
|
.track :global(.ui-segment) {
|
|
97
102
|
transition:
|
|
@@ -169,6 +174,26 @@ let {
|
|
|
169
174
|
box-shadow: inset 0 -2px 0 var(--segmented-rule, var(--color-foreground));
|
|
170
175
|
}
|
|
171
176
|
|
|
177
|
+
/* On a coarse pointer the well is 44px and its segments fill it, so the hit area is the strip.
|
|
178
|
+
A clipped well can't hold a hit area that hangs outside it. The border becomes a ring drawn
|
|
179
|
+
over the segments, and the pill keeps its 2px gap inside that ring. */
|
|
180
|
+
@media (any-pointer: coarse) {
|
|
181
|
+
.track[data-variant="pill"]::after {
|
|
182
|
+
content: "";
|
|
183
|
+
position: absolute;
|
|
184
|
+
inset: 0;
|
|
185
|
+
z-index: 2;
|
|
186
|
+
border: 1px solid var(--color-border);
|
|
187
|
+
border-radius: inherit;
|
|
188
|
+
pointer-events: none;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
.track[data-variant="pill"] .pill {
|
|
192
|
+
top: 3px;
|
|
193
|
+
bottom: 3px;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
172
197
|
@media (prefers-reduced-motion: reduce) {
|
|
173
198
|
.pill {
|
|
174
199
|
transition: opacity var(--duration-quick) var(--ease-smooth-out);
|
|
@@ -7,7 +7,7 @@ describe("segmentedTrackClass", () => {
|
|
|
7
7
|
for (const variant of ["pill", "underline"] as const) {
|
|
8
8
|
const classes = segmentedTrackClass({ variant }).split(" ");
|
|
9
9
|
expect(classes).toContain("h-7");
|
|
10
|
-
expect(classes).toContain("any-pointer-coarse:h-
|
|
10
|
+
expect(classes).toContain("any-pointer-coarse:h-11");
|
|
11
11
|
}
|
|
12
12
|
});
|
|
13
13
|
|
|
@@ -15,12 +15,12 @@ export const segmentedTrackClass = tv({
|
|
|
15
15
|
// drew 30px beside a 28px button. One toolbar row came out at five heights.
|
|
16
16
|
|
|
17
17
|
// WHY no size variant: `sm` drew 24px, and every tab control must share one height.
|
|
18
|
-
base: "h-7 any-pointer-coarse:h-
|
|
18
|
+
base: "h-7 any-pointer-coarse:h-11 shrink-0 items-stretch",
|
|
19
19
|
variants: {
|
|
20
20
|
// `pill`: a bordered well with a travelling highlight. `underline`: bare
|
|
21
21
|
// labels over one hairline, the active one marked by a rule that travels.
|
|
22
22
|
variant: {
|
|
23
|
-
pill: "overflow-hidden rounded-lg border border-border p-0.5",
|
|
23
|
+
pill: "overflow-hidden rounded-lg border border-border p-0.5 any-pointer-coarse:border-0 any-pointer-coarse:px-[3px] any-pointer-coarse:py-0",
|
|
24
24
|
underline: "gap-5 border-b border-border",
|
|
25
25
|
},
|
|
26
26
|
// Fill the row rather than sit at its natural width. For a narrow rail.
|
|
@@ -25,7 +25,7 @@ const POP_WINDOW_MS = 350 + 4 * 40;
|
|
|
25
25
|
import { Star } from "phosphor-svelte";
|
|
26
26
|
import { cn } from "@heyramzi/utils";
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
interface Props {
|
|
29
29
|
/** Current rating value (0 = unrated). */
|
|
30
30
|
value: number;
|
|
31
31
|
/** Maximum rating. Defaults to 5. */
|
|
@@ -187,8 +187,10 @@ function handleLeave() {
|
|
|
187
187
|
box-shadow: 0 0 0 2px var(--color-ring);
|
|
188
188
|
}
|
|
189
189
|
|
|
190
|
-
|
|
191
|
-
|
|
190
|
+
@media (hover: hover) and (pointer: fine) {
|
|
191
|
+
.hit-button:not(.is-disabled):hover {
|
|
192
|
+
transform: scale(1.1);
|
|
193
|
+
}
|
|
192
194
|
}
|
|
193
195
|
|
|
194
196
|
.hit-button:not(.is-disabled):active {
|
|
@@ -248,9 +250,14 @@ function handleLeave() {
|
|
|
248
250
|
transition: none;
|
|
249
251
|
}
|
|
250
252
|
|
|
251
|
-
.hit-button:not(.is-disabled):hover,
|
|
252
253
|
.hit-button:not(.is-disabled):active {
|
|
253
254
|
transform: none;
|
|
254
255
|
}
|
|
256
|
+
|
|
257
|
+
@media (hover: hover) and (pointer: fine) {
|
|
258
|
+
.hit-button:not(.is-disabled):hover {
|
|
259
|
+
transform: none;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
255
262
|
}
|
|
256
263
|
</style>
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
import type { Snippet } from "svelte";
|
|
3
|
+
import type { TableColumn } from "./types.js";
|
|
4
|
+
|
|
5
|
+
export type TableCell<Row> = { row: Row; column: TableColumn<Row>; value: unknown; text: string };
|
|
6
|
+
export type { TableColumn };
|
|
7
|
+
</script>
|
|
8
|
+
|
|
9
|
+
<script lang="ts" generics="Row extends object">
|
|
10
|
+
// =========================
|
|
11
|
+
// IMPORTS
|
|
12
|
+
// =========================
|
|
13
|
+
import { cn } from "@heyramzi/utils";
|
|
14
|
+
import * as Table from "../../shadcn/table/index.js";
|
|
15
|
+
|
|
16
|
+
// =========================
|
|
17
|
+
// TYPES
|
|
18
|
+
// =========================
|
|
19
|
+
interface Props {
|
|
20
|
+
columns: TableColumn<Row>[];
|
|
21
|
+
rows: Row[];
|
|
22
|
+
/** What names a row, so a filter keeps its rows in place. */
|
|
23
|
+
rowKey: (row: Row) => string;
|
|
24
|
+
/** What the table is, for a screen reader. */
|
|
25
|
+
label: string;
|
|
26
|
+
/** A totals row, drawn under the body in the same columns. */
|
|
27
|
+
footer?: Row;
|
|
28
|
+
/** Draws a cell its own way. Print `text` for the plain case. */
|
|
29
|
+
cell?: Snippet<[TableCell<Row>]>;
|
|
30
|
+
rowClass?: (row: Row) => string | undefined;
|
|
31
|
+
/** Below this width the table scrolls sideways inside its frame. */
|
|
32
|
+
minWidth?: string;
|
|
33
|
+
class?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// =========================
|
|
37
|
+
// PROPS
|
|
38
|
+
// =========================
|
|
39
|
+
let { columns, rows, rowKey, label, footer, cell, rowClass, minWidth = "0", class: className }: Props = $props();
|
|
40
|
+
|
|
41
|
+
// =========================
|
|
42
|
+
// FUNCTIONS
|
|
43
|
+
// =========================
|
|
44
|
+
const key = (column: TableColumn<Row>) => column.key;
|
|
45
|
+
|
|
46
|
+
function textOf(column: TableColumn<Row>, row: Row): string {
|
|
47
|
+
const value = (row as Record<string, unknown>)[key(column)];
|
|
48
|
+
if (column.format) return column.format(value, row);
|
|
49
|
+
return value === null || value === undefined ? "" : String(value);
|
|
50
|
+
}
|
|
51
|
+
</script>
|
|
52
|
+
|
|
53
|
+
<!-- =========================== -->
|
|
54
|
+
<!-- MARKUP -->
|
|
55
|
+
<!-- =========================== -->
|
|
56
|
+
{#snippet body(row: Row)}
|
|
57
|
+
{#each columns as column (column.key)}
|
|
58
|
+
<Table.Cell
|
|
59
|
+
class={cn(
|
|
60
|
+
"align-top",
|
|
61
|
+
column.numeric ? "text-end tabular-nums" : "whitespace-normal",
|
|
62
|
+
column.muted && "text-muted-foreground",
|
|
63
|
+
)}
|
|
64
|
+
>
|
|
65
|
+
{#if cell}
|
|
66
|
+
{@render cell({ row, column, value: (row as Record<string, unknown>)[column.key], text: textOf(column, row) })}
|
|
67
|
+
{:else}
|
|
68
|
+
{textOf(column, row)}
|
|
69
|
+
{/if}
|
|
70
|
+
</Table.Cell>
|
|
71
|
+
{/each}
|
|
72
|
+
{/snippet}
|
|
73
|
+
|
|
74
|
+
<div class={cn("overflow-hidden rounded-xl border", className)}>
|
|
75
|
+
<Table.Root aria-label={label} style={`min-width: ${minWidth}`} class="min-w-0">
|
|
76
|
+
<Table.Header>
|
|
77
|
+
<Table.Row>
|
|
78
|
+
{#each columns as column (column.key)}
|
|
79
|
+
<Table.Head
|
|
80
|
+
class={cn("whitespace-normal", column.numeric && "text-end")}
|
|
81
|
+
style={column.width ? `width: ${column.width}` : undefined}
|
|
82
|
+
>
|
|
83
|
+
{column.label}
|
|
84
|
+
</Table.Head>
|
|
85
|
+
{/each}
|
|
86
|
+
</Table.Row>
|
|
87
|
+
</Table.Header>
|
|
88
|
+
<Table.Body>
|
|
89
|
+
{#each rows as row (rowKey(row))}
|
|
90
|
+
<Table.Row class={rowClass?.(row)}>
|
|
91
|
+
{@render body(row)}
|
|
92
|
+
</Table.Row>
|
|
93
|
+
{/each}
|
|
94
|
+
</Table.Body>
|
|
95
|
+
{#if footer}
|
|
96
|
+
<Table.Footer>
|
|
97
|
+
<Table.Row>
|
|
98
|
+
{@render body(footer)}
|
|
99
|
+
</Table.Row>
|
|
100
|
+
</Table.Footer>
|
|
101
|
+
{/if}
|
|
102
|
+
</Table.Root>
|
|
103
|
+
</div>
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { createRawSnippet, flushSync, mount, unmount } from "svelte";
|
|
2
|
+
import { describe, expect, it } from "vite-plus/test";
|
|
3
|
+
import TableView from "./table-view.svelte";
|
|
4
|
+
|
|
5
|
+
type Row = { id: string; name: string; total: number };
|
|
6
|
+
const rows: Row[] = [
|
|
7
|
+
{ id: "a", name: "Audit", total: 1200 },
|
|
8
|
+
{ id: "b", name: "Build", total: 4800 },
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
function render(extra: Record<string, unknown> = {}) {
|
|
12
|
+
const target = document.createElement("div");
|
|
13
|
+
document.body.appendChild(target);
|
|
14
|
+
const app = mount(TableView<Row>, {
|
|
15
|
+
target,
|
|
16
|
+
props: {
|
|
17
|
+
label: "Lines",
|
|
18
|
+
rows,
|
|
19
|
+
rowKey: (row: Row) => row.id,
|
|
20
|
+
columns: [
|
|
21
|
+
{ key: "name", label: "Name" },
|
|
22
|
+
{ key: "total", label: "Total", numeric: true, format: (v: unknown) => `$${v}` },
|
|
23
|
+
],
|
|
24
|
+
...extra,
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
flushSync();
|
|
28
|
+
return { target, done: () => { void unmount(app); target.remove(); } };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe("TableView", () => {
|
|
32
|
+
it("names the table and prints each row through its column's format", () => {
|
|
33
|
+
const { target, done } = render();
|
|
34
|
+
expect(target.querySelector("table")?.getAttribute("aria-label")).toBe("Lines");
|
|
35
|
+
const cells = [...target.querySelectorAll("tbody td")].map((td) => td.textContent?.trim());
|
|
36
|
+
expect(cells).toEqual(["Audit", "$1200", "Build", "$4800"]);
|
|
37
|
+
done();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("right-aligns a numeric column and draws a footer in the same columns", () => {
|
|
41
|
+
const { target, done } = render({ footer: { id: "t", name: "Total", total: 6000 } });
|
|
42
|
+
expect(target.querySelector("thead th:last-child")?.className).toContain("text-end");
|
|
43
|
+
expect(target.querySelector("tfoot")?.textContent).toContain("$6000");
|
|
44
|
+
done();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("lets a caller draw a cell", () => {
|
|
48
|
+
const cell = createRawSnippet(() => ({ render: () => `<b>x</b>` }));
|
|
49
|
+
const { target, done } = render({ cell });
|
|
50
|
+
expect(target.querySelectorAll("tbody b").length).toBe(4);
|
|
51
|
+
done();
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** One column of a `TableView`. */
|
|
2
|
+
export interface TableColumn<Row> {
|
|
3
|
+
/** The key of the value in the row. */
|
|
4
|
+
key: string;
|
|
5
|
+
label: string;
|
|
6
|
+
/** A number or an amount: right-aligned, tabular figures, never wraps. */
|
|
7
|
+
numeric?: boolean;
|
|
8
|
+
/** Turns the raw value into the text shown. A string value that has no `format` prints as it is. A money column passes its own currency formatter. */
|
|
9
|
+
format?: (value: unknown, row: Row) => string;
|
|
10
|
+
/** A column width such as `"30%"` or `"8rem"`. Leave it out and the table shares the rest. */
|
|
11
|
+
width?: string;
|
|
12
|
+
/** Draws the column in the muted colour. */
|
|
13
|
+
muted?: boolean;
|
|
14
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one light and dark system: mode-watcher's store and no-flash script, behind one provider.
|
|
3
|
+
* Mount `ThemeProvider` once in the root layout, flip the mode with `toggleMode` or `setMode`, and
|
|
4
|
+
* read it from `mode.current` or `userPrefersMode.current`. `ThemeToggle` and `revealTheme`
|
|
5
|
+
* draw the control and the wipe.
|
|
6
|
+
*/
|
|
7
|
+
export { default as ThemeProvider } from "./theme-provider.svelte";
|
|
8
|
+
export { revealTheme } from "../../lib/theme-transition.js";
|
|
9
|
+
export {
|
|
10
|
+
mode,
|
|
11
|
+
resetMode,
|
|
12
|
+
setMode,
|
|
13
|
+
systemPrefersMode,
|
|
14
|
+
toggleMode,
|
|
15
|
+
userPrefersMode,
|
|
16
|
+
type UserPrefersMode,
|
|
17
|
+
} from "mode-watcher";
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// =========================
|
|
3
|
+
// IMPORTS
|
|
4
|
+
// =========================
|
|
5
|
+
import { ModeWatcher, userPrefersMode, type UserPrefersMode } from "mode-watcher";
|
|
6
|
+
import type { ComponentProps } from "svelte";
|
|
7
|
+
|
|
8
|
+
// =========================
|
|
9
|
+
// TYPES
|
|
10
|
+
// =========================
|
|
11
|
+
type WatcherProps = ComponentProps<typeof ModeWatcher>;
|
|
12
|
+
|
|
13
|
+
interface Props extends Omit<WatcherProps, "defaultMode"> {
|
|
14
|
+
/**
|
|
15
|
+
* The choice the server already knows, for a browser that has none stored. A stored
|
|
16
|
+
* choice wins, so it seeds a first visit and never overrides a click.
|
|
17
|
+
*/
|
|
18
|
+
initial?: UserPrefersMode;
|
|
19
|
+
/**
|
|
20
|
+
* Fires when the person changes the mode, never on load. An app that saves the choice
|
|
21
|
+
* on its server posts it here.
|
|
22
|
+
*/
|
|
23
|
+
onChange?: (mode: UserPrefersMode) => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// =========================
|
|
27
|
+
// PROPS
|
|
28
|
+
// =========================
|
|
29
|
+
let { initial = "system", onChange, synchronousModeChanges = true, ...watcher }: Props = $props();
|
|
30
|
+
|
|
31
|
+
// =========================
|
|
32
|
+
// EFFECTS
|
|
33
|
+
// =========================
|
|
34
|
+
// The first run is the mode read from storage on mount, which nobody chose.
|
|
35
|
+
let seen: UserPrefersMode | null = null;
|
|
36
|
+
$effect(() => {
|
|
37
|
+
const next = userPrefersMode.current;
|
|
38
|
+
if (seen !== null && next !== seen) onChange?.(next);
|
|
39
|
+
seen = next;
|
|
40
|
+
});
|
|
41
|
+
</script>
|
|
42
|
+
|
|
43
|
+
<!-- WHY it renders ModeWatcher and nothing else: its inline head script sets the class and
|
|
44
|
+
colour-scheme before the first paint, so there is no flash and no cookie to read.
|
|
45
|
+
WHY synchronous: `revealTheme` snapshots the page the moment its callback returns, so the
|
|
46
|
+
class has to be on the root by then. -->
|
|
47
|
+
<ModeWatcher defaultMode={initial} {synchronousModeChanges} {...watcher} />
|