@heyramzi/ui 2.23.0 → 2.24.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.
Files changed (123) hide show
  1. package/COMPONENTS.md +3 -6
  2. package/dist/ai/confirmation/index.js +3 -6
  3. package/dist/ai/index.js +1 -5
  4. package/dist/charts/dashboard/index.js +1 -3
  5. package/dist/charts/primitives/chart-utils.js +14 -0
  6. package/dist/components/chip/index.js +1 -0
  7. package/dist/components/language-flag/flags.js +5 -0
  8. package/dist/components/language-flag/index.js +1 -0
  9. package/dist/components/list-page/index.js +1 -3
  10. package/dist/components/work-parts/index.js +1 -1
  11. package/dist/editor/doc-blocks.js +2 -1
  12. package/dist/effects/animated-beam/path-calculator.svelte.js +13 -6
  13. package/dist/lib/dates.js +41 -0
  14. package/dist/lib/panel.js +2 -2
  15. package/dist/lib/tone.js +13 -0
  16. package/dist/record/format.js +2 -18
  17. package/dist/record/logic.js +3 -6
  18. package/dist/status/index.js +1 -1
  19. package/dist/work/dates.js +2 -3
  20. package/dist/work/detail/detail-types.js +0 -8
  21. package/dist/work/docs/edited.js +8 -7
  22. package/dist/work/inbox/inbox-logic.js +5 -20
  23. package/dist/work/index.js +0 -2
  24. package/dist/work/time/duration.js +2 -3
  25. package/package.json +1 -21
  26. package/src/ai/confirmation/Confirmation.svelte +2 -2
  27. package/src/ai/confirmation/{ConfirmationRequest.svelte → ConfirmationState.svelte} +6 -3
  28. package/src/ai/confirmation/confirmation-context.svelte.ts +2 -2
  29. package/src/ai/confirmation/confirmation-parts.test.svelte +17 -0
  30. package/src/ai/confirmation/confirmation-parts.test.ts +23 -0
  31. package/src/ai/confirmation/index.ts +6 -21
  32. package/src/ai/index.ts +3 -13
  33. package/src/ai/typing-dots/TypingDots.svelte +32 -12
  34. package/src/ai/typing-dots/typing-dots.test.ts +36 -0
  35. package/src/canvas/ui/canvas-ui.css +12 -0
  36. package/src/charts/README.md +2 -2
  37. package/src/charts/area-chart.svelte +3 -10
  38. package/src/charts/bar-chart.svelte +4 -12
  39. package/src/charts/dashboard/data-table.svelte +21 -21
  40. package/src/charts/dashboard/index.ts +1 -3
  41. package/src/charts/dashboard/rating-chip.svelte +9 -11
  42. package/src/charts/dashboard/stat-tile.svelte +3 -4
  43. package/src/charts/line-chart.svelte +3 -10
  44. package/src/charts/primitives/chart-utils.test.ts +17 -0
  45. package/src/charts/primitives/chart-utils.ts +16 -0
  46. package/src/charts/time-series-chart.svelte +8 -4
  47. package/src/components/chip/count-chip.svelte +35 -0
  48. package/src/components/chip/index.ts +1 -0
  49. package/src/components/data-table/data-table.ts +1 -1
  50. package/src/components/data-table/row-context-menu.svelte +3 -4
  51. package/src/components/data-table/sortable-head.svelte +45 -10
  52. package/src/components/language-flag/flags.ts +5 -0
  53. package/src/components/language-flag/index.ts +1 -0
  54. package/src/components/language-flag/language-flag.svelte +3 -11
  55. package/src/components/list-page/empty-state.svelte +90 -12
  56. package/src/components/list-page/filter-popover.svelte +2 -5
  57. package/src/components/list-page/grouped-list.svelte +2 -4
  58. package/src/components/list-page/index.ts +1 -3
  59. package/src/components/section-header/section-header.svelte +2 -6
  60. package/src/components/segmented/segment.svelte +13 -19
  61. package/src/components/segmented/segmented-filter.svelte +1 -2
  62. package/src/components/table-view/table-view.svelte +31 -45
  63. package/src/components/table-view/table-view.test.ts +6 -3
  64. package/src/components/work-parts/group-head.svelte +1 -1
  65. package/src/components/work-parts/index.ts +1 -1
  66. package/src/editor/doc-blocks.ts +2 -1
  67. package/src/effects/animated-beam/path-calculator.svelte.ts +19 -5
  68. package/src/effects/marquee/Marquee.css +6 -3
  69. package/src/effects/marquee/Marquee.svelte +6 -2
  70. package/src/inbox/thread-inbox.svelte +6 -4
  71. package/src/lib/dates.test.ts +63 -0
  72. package/src/lib/dates.ts +63 -0
  73. package/src/lib/panel.svelte +116 -0
  74. package/src/lib/panel.ts +2 -2
  75. package/src/lib/tone.ts +15 -0
  76. package/src/panel/side-panel.svelte +4 -4
  77. package/src/record/format.ts +3 -15
  78. package/src/record/logic.ts +2 -6
  79. package/src/record/record-stats.svelte +4 -3
  80. package/src/record/steps-rail.svelte +7 -4
  81. package/src/status/index.ts +1 -1
  82. package/src/status/status-pill.svelte +2 -8
  83. package/src/thread/thread.svelte +3 -6
  84. package/src/work/README.md +0 -1
  85. package/src/work/dates.ts +3 -3
  86. package/src/work/demo.svelte +9 -1
  87. package/src/work/detail/detail-demo.svelte +30 -27
  88. package/src/work/detail/detail-types.ts +1 -29
  89. package/src/work/detail/task-detail.svelte +18 -17
  90. package/src/work/docs/doc-menu.svelte +2 -1
  91. package/src/work/docs/edited.ts +4 -7
  92. package/src/work/inbox/inbox-card.svelte +4 -9
  93. package/src/work/inbox/inbox-logic.ts +4 -16
  94. package/src/work/index.ts +1 -3
  95. package/src/work/time/duration.ts +2 -3
  96. package/dist/ai/thinking-dots/index.js +0 -3
  97. package/dist/components/icon/index.js +0 -1
  98. package/dist/components/locale-switch/index.js +0 -1
  99. package/dist/icons/trades.js +0 -49
  100. package/dist/status/pill-tint.js +0 -13
  101. package/dist/work/presets/sales.js +0 -39
  102. package/src/ai/confirmation/ConfirmationAccepted.svelte +0 -23
  103. package/src/ai/confirmation/ConfirmationFailed.svelte +0 -23
  104. package/src/ai/confirmation/ConfirmationRejected.svelte +0 -23
  105. package/src/ai/thinking-dots/ThinkingDots.svelte +0 -42
  106. package/src/ai/thinking-dots/index.ts +0 -5
  107. package/src/charts/dashboard/dashboard-empty.svelte +0 -55
  108. package/src/charts/dashboard/data-panel.svelte +0 -81
  109. package/src/charts/dashboard/data-sort-head.svelte +0 -102
  110. package/src/components/icon/icon.svelte +0 -23
  111. package/src/components/icon/index.ts +0 -1
  112. package/src/components/list-page/detail-empty.svelte +0 -45
  113. package/src/components/list-page/detail-panel.svelte +0 -82
  114. package/src/components/list-page/rail-empty.svelte +0 -44
  115. package/src/components/locale-switch/index.ts +0 -1
  116. package/src/components/locale-switch/locale-switch.svelte +0 -59
  117. package/src/components/work-parts/count-chip.svelte +0 -20
  118. package/src/icons/TradeIcon.svelte +0 -16
  119. package/src/icons/trades.ts +0 -89
  120. package/src/status/pill-tint.ts +0 -16
  121. package/src/work/detail/task-property.svelte +0 -17
  122. package/src/work/detail/task-thread.svelte +0 -221
  123. package/src/work/presets/sales.ts +0 -16
@@ -1,4 +1,10 @@
1
1
  <script lang="ts" generics="T">
2
+ /*
3
+ * The one sortable column head, for TableView, the dashboard DataTable and hand-built
4
+ * tables. Every sortable head shows its caret: hover-only makes people hunt.
5
+ */
6
+
7
+ // ========================= IMPORTS
2
8
  import type { Snippet } from "svelte";
3
9
  import { CaretDown, CaretUp, CaretUpDown } from "phosphor-svelte";
4
10
  import { cn } from "../../lib/utils.js";
@@ -8,21 +14,36 @@
8
14
  interface Props {
9
15
  key: keyof T & string;
10
16
  sort: SortState<T>;
17
+ /** Called on click and on Enter/Space. Choosing the next direction is the caller's. */
11
18
  onSort: (key: keyof T & string) => void;
19
+ /** Numbers right-align to their digits, so their caret leads the label. */
20
+ numeric?: boolean;
21
+ /** The long form of an abbreviated head, as the browser tooltip. */
22
+ title?: string;
12
23
  class?: string;
24
+ style?: string;
13
25
  children: Snippet;
14
26
  }
15
27
 
16
28
  // ========================= PROPS
17
- let { key, sort, onSort, class: className, children }: Props = $props();
29
+ let {
30
+ key,
31
+ sort,
32
+ onSort,
33
+ numeric = false,
34
+ title,
35
+ class: className,
36
+ style,
37
+ children,
38
+ }: Props = $props();
18
39
 
19
40
  // ========================= DERIVED
20
- let active = $derived(sort?.key === key);
21
- let direction = $derived<SortDirection | null>(sort && active ? sort.direction : null);
41
+ let direction = $derived<SortDirection | null>(sort?.key === key ? sort.direction : null);
22
42
  let ariaSort = $derived<"ascending" | "descending" | "none">(
23
43
  direction === "asc" ? "ascending" : direction === "desc" ? "descending" : "none",
24
44
  );
25
45
 
46
+ // ========================= FUNCTIONS
26
47
  function handleKeydown(e: KeyboardEvent) {
27
48
  if (e.key === "Enter" || e.key === " ") {
28
49
  e.preventDefault();
@@ -33,9 +54,14 @@
33
54
 
34
55
  <th
35
56
  data-slot="table-head"
57
+ scope="col"
58
+ {title}
59
+ {style}
36
60
  class={cn(
37
61
  "text-muted-foreground h-8 px-2 text-left align-middle text-xs font-medium whitespace-nowrap select-none",
38
62
  "cursor-pointer transition-colors duration-quick ease-smooth-out hover:bg-foreground/4 motion-reduce:transition-none",
63
+ "outline-none focus-visible:shadow-(--ring-focus-inset)",
64
+ numeric && "text-right",
39
65
  className,
40
66
  )}
41
67
  tabindex="0"
@@ -43,14 +69,23 @@
43
69
  onclick={() => onSort(key)}
44
70
  onkeydown={handleKeydown}
45
71
  >
46
- <span class="inline-flex items-center gap-1">
47
- {@render children()}
48
- {#if direction === "asc"}
49
- <CaretUp class="size-3.5 text-foreground" weight="bold" />
50
- {:else if direction === "desc"}
51
- <CaretDown class="size-3.5 text-foreground" weight="bold" />
72
+ <!-- Pinned to the line's top, not its baseline: a leading caret gave the span the icon's foot
73
+ as its baseline and lifted a numeric label 1.5px above the plain heads beside it. -->
74
+ <span class="inline-flex items-center gap-1 align-top">
75
+ {#if numeric}
76
+ {@render caret()}{@render children()}
52
77
  {:else}
53
- <CaretUpDown class="size-3.5 text-muted-foreground" />
78
+ {@render children()}{@render caret()}
54
79
  {/if}
55
80
  </span>
56
81
  </th>
82
+
83
+ {#snippet caret()}
84
+ {#if direction === "asc"}
85
+ <CaretUp class="size-3.5 shrink-0 text-foreground" weight="bold" aria-hidden="true" />
86
+ {:else if direction === "desc"}
87
+ <CaretDown class="size-3.5 shrink-0 text-foreground" weight="bold" aria-hidden="true" />
88
+ {:else}
89
+ <CaretUpDown class="size-3.5 shrink-0 text-muted-foreground" aria-hidden="true" />
90
+ {/if}
91
+ {/snippet}
@@ -0,0 +1,5 @@
1
+ /** Language code to flag image. The host serves these files from /flags. */
2
+ export const LANGUAGE_FLAGS: Record<string, string> = {
3
+ en: "/flags/US.svg",
4
+ fr: "/flags/FR.svg",
5
+ };
@@ -1 +1,2 @@
1
1
  export { default as LanguageFlag } from "./language-flag.svelte";
2
+ export { LANGUAGE_FLAGS } from "./flags.js";
@@ -3,22 +3,14 @@
3
3
  // IMPORTS
4
4
  // =========================
5
5
  import { cn } from "../../lib/utils.js";
6
-
7
- // =========================
8
- // CONSTANTS
9
- // =========================
10
- // The host serves these files from /flags. Pass `flags` for another set or path.
11
- const DEFAULT_FLAGS: Record<string, string> = {
12
- en: "/flags/US.svg",
13
- fr: "/flags/FR.svg",
14
- };
6
+ import { LANGUAGE_FLAGS } from "./flags.js";
15
7
 
16
8
  // =========================
17
9
  // PROPS
18
10
  // =========================
19
11
  interface Props {
20
12
  language: string;
21
- /** Language code to image URL. */
13
+ /** Language code to image URL, for another set or path. */
22
14
  flags?: Record<string, string>;
23
15
  size?: "sm" | "md";
24
16
  showLabel?: boolean;
@@ -27,7 +19,7 @@
27
19
 
28
20
  let {
29
21
  language,
30
- flags = DEFAULT_FLAGS,
22
+ flags = LANGUAGE_FLAGS,
31
23
  size = "sm",
32
24
  showLabel = false,
33
25
  class: className,
@@ -2,8 +2,10 @@
2
2
  // =========================
3
3
  // IMPORTS
4
4
  // =========================
5
+ import type { Snippet } from "svelte";
5
6
  import { Button } from "../../shadcn/button/index.js";
6
7
  import * as Empty from "../../shadcn/empty/index.js";
8
+ import { cn } from "../../lib/utils.js";
7
9
  import type { PhosphorIcon, IconWeight } from "../../lib/icon-types.js";
8
10
 
9
11
  // =========================
@@ -12,25 +14,93 @@
12
14
  interface Props {
13
15
  icon?: PhosphorIcon;
14
16
  iconWeight?: IconWeight;
17
+ /** Replaces the muted colour of a bare icon. Ignored by the tile, which stays foreground. */
18
+ iconClass?: string;
15
19
  title: string;
16
20
  description?: string;
17
21
  actionLabel?: string;
18
22
  actionHref?: string;
19
23
  onAction?: () => void;
24
+ /** Several ways out, drawn in one row after the single action. */
25
+ actions?: Snippet;
26
+ /** `card` fills the white panel; `dashed` frames a whole dashboard section. */
27
+ surface?: "plain" | "card" | "dashed";
28
+ /** `sm` fits a 280px rail: a bare icon and one line of small type. */
29
+ size?: "default" | "sm";
30
+ class?: string;
20
31
  }
21
32
 
33
+ /** A null `icon` draws the icon in a white tile. */
34
+ type Look = {
35
+ root: string;
36
+ icon: string | null;
37
+ header: string;
38
+ title: string;
39
+ description: string;
40
+ };
41
+
22
42
  // =========================
23
43
  // PROPS
24
44
  // =========================
25
45
  let {
26
46
  icon: Icon,
27
47
  iconWeight,
48
+ iconClass,
28
49
  title,
29
50
  description,
30
51
  actionLabel,
31
52
  actionHref,
32
53
  onAction,
54
+ actions,
55
+ surface = "plain",
56
+ size = "default",
57
+ class: className,
33
58
  }: Props = $props();
59
+
60
+ // =========================
61
+ // CONSTANTS
62
+ // =========================
63
+ // A white tile, not a brand tint: the accent marks the one action (DESIGN.md rule 1).
64
+ const PAGE: Look = {
65
+ root: "py-16",
66
+ icon: null,
67
+ header: "max-w-md",
68
+ title: "font-heading text-base font-semibold text-balance [overflow-wrap:anywhere]",
69
+ description: "",
70
+ };
71
+
72
+ const RAIL: Look = {
73
+ root: "gap-2 px-6 py-12",
74
+ icon: "size-6",
75
+ header: "max-w-none",
76
+ title: "",
77
+ description: "max-w-56 text-xs leading-relaxed",
78
+ };
79
+
80
+ // A dashed box stands in for a whole dashboard section, so it takes a heading's size.
81
+ const SECTION: Look = {
82
+ root: "p-10 text-wrap",
83
+ icon: "size-10",
84
+ header: "max-w-md gap-1.5",
85
+ title: "font-heading text-lg font-semibold tracking-normal",
86
+ description: "text-sm",
87
+ };
88
+
89
+ // A framed box sizes as a block, so a caller's `m-4` insets it instead of pushing it 32px wide.
90
+ const SURFACE: Record<NonNullable<Props["surface"]>, string> = {
91
+ plain: "",
92
+ card: "w-auto flex-initial bg-card",
93
+ dashed: "w-auto flex-initial border-2 border-border",
94
+ };
95
+
96
+ // =========================
97
+ // DERIVED
98
+ // =========================
99
+ let look = $derived(size === "sm" ? RAIL : surface === "dashed" ? SECTION : PAGE);
100
+ // Dashboard empties carry their own `t-enter`, and a rail empty flips on every filter keystroke.
101
+ let reveal = $derived(look === PAGE);
102
+ let heading = $derived(look === SECTION);
103
+ let hasAction = $derived(Boolean(actionLabel && (actionHref || onAction)));
34
104
  </script>
35
105
 
36
106
  <!-- =========================== -->
@@ -38,29 +108,37 @@
38
108
  <!-- =========================== -->
39
109
  <!-- transitions-dev texts reveal: the parts arrive in reading order, so the page reads as
40
110
  loaded and empty. Reduced motion keeps the fade. -->
41
- <Empty.Root class="t-empty py-16">
42
- <Empty.Header class="max-w-md">
43
- {#if Icon}
44
- <!-- A white tile, not a brand tint: the accent marks the one action (DESIGN.md
45
- rule 1), and the button below is it. -->
111
+ <Empty.Root class={cn(reveal && "t-empty", look.root, SURFACE[surface], className)}>
112
+ {#if Icon && look.icon}
113
+ <Icon
114
+ weight={iconWeight}
115
+ class={cn(look.icon, iconClass ?? "text-muted-foreground")}
116
+ aria-hidden="true"
117
+ />
118
+ {/if}
119
+ <Empty.Header class={look.header}>
120
+ {#if Icon && !look.icon}
46
121
  <Empty.Media class="mb-3 size-12 rounded-xl bg-card text-foreground shadow-card">
47
122
  <Icon weight={iconWeight} class="size-6" />
48
123
  </Empty.Media>
49
124
  {/if}
50
- <Empty.Title class="font-heading text-base font-semibold text-balance [overflow-wrap:anywhere]"
51
- >{title}</Empty.Title
125
+ <Empty.Title
126
+ class={look.title}
127
+ role={heading ? "heading" : undefined}
128
+ aria-level={heading ? 2 : undefined}>{title}</Empty.Title
52
129
  >
53
130
  {#if description}
54
- <Empty.Description>{description}</Empty.Description>
131
+ <Empty.Description class={look.description}>{description}</Empty.Description>
55
132
  {/if}
56
133
  </Empty.Header>
57
- {#if actionLabel && (actionHref || onAction)}
58
- <Empty.Content>
59
- {#if actionHref}
134
+ {#if hasAction || actions}
135
+ <Empty.Content class="max-w-md flex-row flex-wrap justify-center gap-2">
136
+ {#if hasAction && actionHref}
60
137
  <Button href={actionHref}>{actionLabel}</Button>
61
- {:else}
138
+ {:else if hasAction}
62
139
  <Button onclick={onAction}>{actionLabel}</Button>
63
140
  {/if}
141
+ {@render actions?.()}
64
142
  </Empty.Content>
65
143
  {/if}
66
144
  </Empty.Root>
@@ -8,6 +8,7 @@
8
8
  import { cn } from "../../lib/utils.js";
9
9
  import { Button } from "../../shadcn/button/index.js";
10
10
  import { toolbarTriggerClass } from "../toolbar/toolbar-trigger.js";
11
+ import CountChip from "../chip/count-chip.svelte";
11
12
  import type { PhosphorIcon } from "../../lib/icon-types.js";
12
13
 
13
14
  // phosphor-svelte v2 (resolved here) and v3 (what consumers run) differ only in
@@ -70,11 +71,7 @@
70
71
  {@render trailing()}
71
72
  {/if}
72
73
  {#if hasCount}
73
- <span
74
- class="ml-0.5 inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-brand/12 px-1 text-xs leading-none font-semibold text-brand tabular-nums dark:text-brand-light"
75
- >
76
- {count.toLocaleString()}
77
- </span>
74
+ <CountChip {count} size="sm" tone="brand" class="ml-0.5" />
78
75
  {/if}
79
76
  </Popover.Trigger>
80
77
  <Popover.Content class={cn("p-2", width, contentClass)} {align}>
@@ -6,6 +6,7 @@
6
6
  import { SvelteSet } from "svelte/reactivity";
7
7
  import { CaretDown, Plus } from "phosphor-svelte";
8
8
  import { cn } from "../../lib/utils.js";
9
+ import CountChip from "../chip/count-chip.svelte";
9
10
 
10
11
  // =========================
11
12
  // TYPES
@@ -110,10 +111,7 @@
110
111
  />
111
112
  {@render header(group, { collapsed: isCollapsed })}
112
113
  {#if getCount}
113
- <span
114
- class="rounded-full bg-foreground/6 px-1.5 text-xs leading-5 font-medium text-muted-foreground tabular-nums"
115
- >{getCount(group)}</span
116
- >
114
+ <CountChip count={getCount(group)} />
117
115
  {/if}
118
116
  </button>
119
117
  {#if actions}
@@ -1,6 +1,5 @@
1
- export { default as DetailEmpty } from "./detail-empty.svelte";
2
1
  export { default as DetailHeader } from "./detail-header.svelte";
3
- export { default as DetailPanel } from "./detail-panel.svelte";
2
+ export { default as DetailPanel } from "../../lib/panel.svelte";
4
3
  export { default as DetailRow } from "./detail-row.svelte";
5
4
  export { default as DetailStack } from "./detail-stack.svelte";
6
5
  export { default as EmptyState } from "./empty-state.svelte";
@@ -9,7 +8,6 @@ export { default as PrevNext } from "./prev-next.svelte";
9
8
  export { stepTo } from "./step.js";
10
9
  export { default as NoticeBar } from "./notice-bar.svelte";
11
10
  export { default as RailBody } from "./rail-body.svelte";
12
- export { default as RailEmpty } from "./rail-empty.svelte";
13
11
  export { default as RailHead } from "./rail-head.svelte";
14
12
  export { default as RailRow } from "./rail-row.svelte";
15
13
  export { default as SectionLabel } from "../section-header/section-label.svelte";
@@ -4,6 +4,7 @@
4
4
  // =========================
5
5
  import type { Snippet } from "svelte";
6
6
  import SectionLabel from "./section-label.svelte";
7
+ import CountChip from "../chip/count-chip.svelte";
7
8
 
8
9
  // =========================
9
10
  // TYPES
@@ -36,12 +37,7 @@
36
37
  <svelte:element this={`h${level}`} class="flex items-center gap-2">
37
38
  <SectionLabel class="min-w-0 [overflow-wrap:anywhere]">{title}</SectionLabel>
38
39
  {#if count !== undefined}
39
- <span
40
- class="shrink-0 rounded-full bg-foreground/6 px-1.5 py-0.5 text-xs leading-none font-medium tabular-nums text-muted-foreground"
41
- aria-label={`${count.toLocaleString()} ${countLabel}`}
42
- >
43
- {count.toLocaleString()}
44
- </span>
40
+ <CountChip {count} size="sm" aria-label={`${count.toLocaleString()} ${countLabel}`} />
45
41
  {/if}
46
42
  </svelte:element>
47
43
  {#if description}
@@ -30,8 +30,8 @@
30
30
  // IMPORTS
31
31
  // =========================
32
32
  import { cn } from "../../lib/utils.js";
33
- import { Icon } from "../icon/index.js";
34
33
  import { Mark } from "../mark/index.js";
34
+ import CountChip from "../chip/count-chip.svelte";
35
35
 
36
36
  // =========================
37
37
  // TYPES
@@ -47,7 +47,7 @@
47
47
  // =========================
48
48
  // PROPS
49
49
  // =========================
50
- let { label, count, icon, image, done = false, badge, active = false }: Props = $props();
50
+ let { label, count, icon: Icon, image, done = false, badge, active = false }: Props = $props();
51
51
 
52
52
  // =========================
53
53
  // DERIVED
@@ -69,8 +69,8 @@
69
69
  auto && "group-data-[state=active]/segment:opacity-100",
70
70
  )}
71
71
  />
72
- {:else if icon}
73
- <Icon {icon} size={16} class="shrink-0" />
72
+ {:else if Icon}
73
+ <Icon size={16} class="shrink-0" />
74
74
  {/if}
75
75
 
76
76
  {label}
@@ -80,21 +80,15 @@
80
80
  {/if}
81
81
 
82
82
  {#if count != null}
83
- <span
84
- class={cn(
85
- // WHY 16px, not 18: inside a 22px segment, 18 read as a chip nested in the pill.
86
-
87
- // WHY min-width equals height with 4px padding: `min-w-5 px-1.5` made a digit
88
- // 20 by 18, so `rounded-full` drew an ellipse.
89
- "inline-flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-xs leading-none font-medium tabular-nums",
90
- "bg-foreground/8 text-muted-foreground",
91
- on && "bg-brand/14 text-brand",
92
- auto &&
93
- "group-data-[state=active]/segment:bg-brand/14 group-data-[state=active]/segment:text-brand",
94
- )}
95
- >
96
- {count.toLocaleString()}
97
- </span>
83
+ <!-- WHY 16px, not 18: inside a 22px segment, 18 read as a chip nested in the pill. -->
84
+ <CountChip
85
+ {count}
86
+ size="sm"
87
+ tone={on ? "brand" : "neutral"}
88
+ class={auto
89
+ ? "group-data-[state=active]/segment:bg-brand/12 group-data-[state=active]/segment:text-brand dark:group-data-[state=active]/segment:text-brand-light"
90
+ : undefined}
91
+ />
98
92
  {:else if done}
99
93
  <span
100
94
  class={cn(
@@ -13,7 +13,6 @@
13
13
  splitSegments,
14
14
  } from "./collapse.js";
15
15
  import { segmentedTriggerClass } from "./segmented.js";
16
- import { Icon } from "../icon/index.js";
17
16
  import { Mark } from "../mark/index.js";
18
17
  import { CaretDown } from "phosphor-svelte";
19
18
  import { Picker } from "../picker/index.js";
@@ -162,7 +161,7 @@
162
161
  )}
163
162
  />
164
163
  {:else if markOnly && option.icon}
165
- <Icon icon={option.icon} size={16} class="shrink-0" />
164
+ <option.icon size={16} class="shrink-0" />
166
165
  {:else}
167
166
  <Segment
168
167
  label={option.label}
@@ -11,12 +11,12 @@
11
11
  // =========================
12
12
  // IMPORTS
13
13
  // =========================
14
- import { CaretDown, CaretUp, CaretUpDown } from "phosphor-svelte";
14
+ import { CaretDown } from "phosphor-svelte";
15
15
  import { cn } from "../../lib/utils.js";
16
16
  import * as Table from "../../shadcn/table/index.js";
17
17
  import * as ContextMenu from "../../shadcn/context-menu/index.js";
18
18
  import { Checkbox } from "../../shadcn/checkbox/index.js";
19
- import { TableSkeleton } from "../data-table/index.js";
19
+ import { SortableHead, TableSkeleton } from "../data-table/index.js";
20
20
  import { toggleSort, type SortState } from "../data-table/data-table.js";
21
21
 
22
22
  // =========================
@@ -174,12 +174,6 @@
174
174
  return value === null || value === undefined ? "" : String(value);
175
175
  }
176
176
 
177
- function ariaSort(column: TableColumn<Row>): "ascending" | "descending" | "none" | undefined {
178
- if (!column.sortable) return undefined;
179
- if (sort?.key !== column.key) return "none";
180
- return sort.direction === "asc" ? "ascending" : "descending";
181
- }
182
-
183
177
  function toggleRow(row: Row, on: boolean) {
184
178
  const next = new Set(selected);
185
179
  if (on) next.add(rowKey(row));
@@ -358,43 +352,35 @@
358
352
  </Table.Head>
359
353
  {/if}
360
354
  {#each columns as column (column.key)}
361
- <Table.Head
362
- class={cn(
363
- "whitespace-normal",
364
- column.numeric && "text-end",
365
- column.sortable && "h-8 p-0",
366
- column.hideBelow && HIDE[column.hideBelow],
367
- column.sticky && "bg-card sticky left-0 z-20",
368
- column.headClass ?? column.class,
369
- )}
370
- style={column.width ? `width: ${column.width}` : undefined}
371
- aria-sort={ariaSort(column)}
372
- >
373
- {#if column.sortable}
374
- {@const dir = sort?.key === column.key ? sort.direction : null}
375
- <button
376
- type="button"
377
- class={cn(
378
- "t-press transition-colors duration-quick ease-smooth-out hover:bg-foreground/4 flex h-8 w-full items-center gap-1 px-2 text-xs font-medium outline-none focus-visible:shadow-(--ring-focus-inset)",
379
- column.numeric ? "justify-end" : "justify-start",
380
- )}
381
- onclick={() => (sort = toggleSort(sort, column.key as keyof Row & string))}
382
- >
383
- {#if head}{@render head(column)}{:else}{column.label}{/if}
384
- {#if dir === "asc"}
385
- <CaretUp class="text-foreground size-3.5" weight="bold" aria-hidden="true" />
386
- {:else if dir === "desc"}
387
- <CaretDown class="text-foreground size-3.5" weight="bold" aria-hidden="true" />
388
- {:else}
389
- <CaretUpDown class="text-muted-foreground size-3.5" aria-hidden="true" />
390
- {/if}
391
- </button>
392
- {:else if head}
393
- {@render head(column)}
394
- {:else}
395
- {column.label}
396
- {/if}
397
- </Table.Head>
355
+ {@const headClass = cn(
356
+ "whitespace-normal",
357
+ column.numeric && "text-end",
358
+ column.hideBelow && HIDE[column.hideBelow],
359
+ column.sticky && "bg-card sticky left-0 z-20",
360
+ column.headClass ?? column.class,
361
+ )}
362
+ {@const headStyle = column.width ? `width: ${column.width}` : undefined}
363
+ {#if column.sortable}
364
+ <!-- A sticky head keeps its card under the hover tint, or rows show through it. -->
365
+ <SortableHead
366
+ key={column.key as keyof Row & string}
367
+ {sort}
368
+ onSort={(key) => (sort = toggleSort(sort, key))}
369
+ numeric={column.numeric}
370
+ class={cn(
371
+ headClass,
372
+ column.sticky &&
373
+ "hover:bg-[color-mix(in_oklab,var(--color-foreground)_4%,var(--color-card))]",
374
+ )}
375
+ style={headStyle}
376
+ >
377
+ {#if head}{@render head(column)}{:else}{column.label}{/if}
378
+ </SortableHead>
379
+ {:else}
380
+ <Table.Head class={headClass} style={headStyle}>
381
+ {#if head}{@render head(column)}{:else}{column.label}{/if}
382
+ </Table.Head>
383
+ {/if}
398
384
  {/each}
399
385
  {#if rowHref}
400
386
  <th class="w-0 p-0"><span class="sr-only">Open</span></th>
@@ -64,14 +64,17 @@ describe("TableView extensions", () => {
64
64
  { key: "total", label: "Total", numeric: true, sortable: true },
65
65
  ];
66
66
 
67
- it("sorts from a head button and sets aria-sort", () => {
67
+ it("sorts from a head, by click or Enter, and sets aria-sort", () => {
68
68
  const { target, done } = render({ columns: sortable, sort: { key: "name", direction: "asc" } });
69
- const heads = target.querySelectorAll("thead th");
69
+ const heads = target.querySelectorAll<HTMLElement>("thead th");
70
70
  expect(heads[0].getAttribute("aria-sort")).toBe("ascending");
71
71
  expect(heads[1].getAttribute("aria-sort")).toBe("none");
72
- (heads[1].querySelector("button") as HTMLButtonElement).click();
72
+ heads[1].click();
73
73
  flushSync();
74
74
  expect(target.querySelectorAll("thead th")[1].getAttribute("aria-sort")).toBe("ascending");
75
+ heads[1].dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
76
+ flushSync();
77
+ expect(target.querySelectorAll("thead th")[1].getAttribute("aria-sort")).toBe("descending");
75
78
  done();
76
79
  });
77
80
 
@@ -3,7 +3,7 @@
3
3
  import { Plus } from "phosphor-svelte";
4
4
  import { cn } from "../../lib/utils.js";
5
5
  import { Button } from "../../shadcn/button/index.js";
6
- import CountChip from "./count-chip.svelte";
6
+ import CountChip from "../chip/count-chip.svelte";
7
7
 
8
8
  interface Props {
9
9
  label: string;
@@ -1,4 +1,4 @@
1
- export { default as CountChip } from "./count-chip.svelte";
1
+ export { default as CountChip } from "../chip/count-chip.svelte";
2
2
  export { default as GroupHead } from "./group-head.svelte";
3
3
  export { default as PropertyRow } from "./property-row.svelte";
4
4
  export { default as WorkCard } from "./work-card.svelte";
@@ -5,6 +5,7 @@
5
5
  import { Extension } from "@tiptap/core";
6
6
  import { Plugin, PluginKey } from "@tiptap/pm/state";
7
7
  import { Decoration, DecorationSet } from "@tiptap/pm/view";
8
+ import { copyText } from "../components/copy/clipboard.js";
8
9
  import { anchorIds } from "./anchors.js";
9
10
 
10
11
  const LINK_ICON = `<svg width="12" height="12" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true"><path d="M240 88.23a54.43 54.43 0 0 1-16 37L189.25 160a54.27 54.27 0 0 1-38.63 16h-.05A54.63 54.63 0 0 1 96 119.84a8 8 0 0 1 16 .45A38.62 38.62 0 0 0 150.58 160a38.4 38.4 0 0 0 27.31-11.31l34.75-34.75a38.63 38.63 0 0 0-54.63-54.63l-11 11A8 8 0 0 1 135.7 59l11-11a54.65 54.65 0 0 1 77.3 0A54.86 54.86 0 0 1 240 88.23ZM109 185.66l-11 11A38.41 38.41 0 0 1 70.6 208a38.63 38.63 0 0 1-27.29-65.94L78 107.31a38.63 38.63 0 0 1 54.63 0 8 8 0 0 0 11.31-11.31 54.65 54.65 0 0 0-77.3 0L32 130.75A54.62 54.62 0 0 0 70.56 224a54.28 54.28 0 0 0 38.64-16l11-11a8 8 0 0 0-11.2-11.34Z"/></svg>`;
@@ -59,7 +60,7 @@ export const HeadingAnchors = Extension.create<HeadingAnchorsOptions>({
59
60
  button.addEventListener("click", () => {
60
61
  const { origin, pathname, search } = window.location;
61
62
  const url = `${origin}${pathname}${search}#${id}`;
62
- void navigator.clipboard?.writeText(url).catch(() => {});
63
+ void copyText(url);
63
64
  button.dataset.copied = "true";
64
65
  setTimeout(() => delete button.dataset.copied, 1400);
65
66
  options.oncopy?.(url, id);
@@ -34,12 +34,26 @@ export function usePathCalculator() {
34
34
  const rectA = fromRef.getBoundingClientRect();
35
35
  const rectB = toRef.getBoundingClientRect();
36
36
 
37
- svgDimensions = { width: containerRect.width, height: containerRect.height };
37
+ // A scaled ancestor shrinks every rect, but the SVG draws in the container's own pixels.
38
+ // Divide the scale out, or the beams stop short of their nodes.
39
+ const scaleX = containerRef.offsetWidth ? containerRect.width / containerRef.offsetWidth : 1;
40
+ const scaleY = containerRef.offsetHeight ? containerRect.height / containerRef.offsetHeight : 1;
41
+ const local = (rect: DOMRect) =>
42
+ new DOMRect(
43
+ (rect.left - containerRect.left) / scaleX,
44
+ (rect.top - containerRect.top) / scaleY,
45
+ rect.width / scaleX,
46
+ rect.height / scaleY,
47
+ );
48
+ const a = local(rectA);
49
+ const b = local(rectB);
38
50
 
39
- const startX = anchorX(rectA, containerRect.left, fromAnchor, startXOffset);
40
- const startY = rectA.top - containerRect.top + rectA.height / 2 + startYOffset;
41
- const endX = anchorX(rectB, containerRect.left, toAnchor, endXOffset);
42
- const endY = rectB.top - containerRect.top + rectB.height / 2 + endYOffset;
51
+ svgDimensions = { width: containerRect.width / scaleX, height: containerRect.height / scaleY };
52
+
53
+ const startX = anchorX(a, 0, fromAnchor, startXOffset);
54
+ const startY = a.top + a.height / 2 + startYOffset;
55
+ const endX = anchorX(b, 0, toAnchor, endXOffset);
56
+ const endY = b.top + b.height / 2 + endYOffset;
43
57
 
44
58
  // A cubic with horizontal handles, not a quadratic through a raised midpoint.
45
59