@lyeve-labs/ui-kit 0.42.2 → 0.44.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.
@@ -0,0 +1,152 @@
1
+ <script lang="ts" module>
2
+ /** One difference between two versions, named by the field it is in. */
3
+ export type DiffChange =
4
+ | { kind: 'added'; label: string; value: string }
5
+ | { kind: 'removed'; label: string; value: string }
6
+ | { kind: 'changed'; label: string; before: string; after: string }
7
+ | {
8
+ kind: 'moved';
9
+ label: string;
10
+ /** Where it moved, in the caller's words, such as "From position 2 to 4". */
11
+ value: string;
12
+ };
13
+ </script>
14
+
15
+ <script lang="ts">
16
+ /**
17
+ * What changed between two versions, one row per field.
18
+ *
19
+ * Each row names its field and says what happened to it in a word: Added,
20
+ * Removed, Changed or Moved, so the kind of change never rests on color. A
21
+ * changed field shows the old value marked Removed above the new one marked
22
+ * Added. Every value takes its direction from its own text, and a value
23
+ * past 400 characters shows its start with a Show all button, cut between
24
+ * two characters a reader sees as whole, so an emoji or an accent is never
25
+ * split.
26
+ *
27
+ * The kit draws the list. Working out what changed is the caller's.
28
+ */
29
+ import { cn } from '../utils/cn.js';
30
+ import type { AccentTone } from '../internal/tone.js';
31
+ import Badge from './Badge.svelte';
32
+ import Button from './Button.svelte';
33
+
34
+ interface Props {
35
+ changes: DiffChange[];
36
+ class?: string;
37
+ }
38
+
39
+ let { changes, class: klass = '' }: Props = $props();
40
+
41
+ /** How many characters of a value show before Show all. */
42
+ const CLAMP = 400;
43
+
44
+ const KIND: Record<DiffChange['kind'], { word: string; tone: AccentTone }> = {
45
+ added: { word: 'Added', tone: 'success' },
46
+ removed: { word: 'Removed', tone: 'danger' },
47
+ changed: { word: 'Changed', tone: 'warn' },
48
+ moved: { word: 'Moved', tone: 'brand' },
49
+ };
50
+
51
+ /**
52
+ * The values a reader opened past the clamp, by row and side, and the
53
+ * changes they were opened in. Other changes start folded, because a row's
54
+ * position there names another value.
55
+ */
56
+ let opened = $state.raw<{ changes: DiffChange[]; keys: Set<string> } | null>(null);
57
+
58
+ function isOpen(key: string): boolean {
59
+ return opened?.changes === changes && opened.keys.has(key);
60
+ }
61
+
62
+ function toggle(key: string) {
63
+ const keys = new Set(opened?.changes === changes ? opened.keys : []);
64
+ if (!keys.delete(key)) keys.add(key);
65
+ opened = { changes, keys };
66
+ }
67
+
68
+ /**
69
+ * The first CLAMP characters a reader sees as whole, or null when the text
70
+ * is no longer than that. A string no longer in code units than the clamp
71
+ * cannot hold more characters, so only a long one is segmented.
72
+ */
73
+ function clip(text: string): string | null {
74
+ if (text.length <= CLAMP) return null;
75
+ let seen = 0;
76
+ for (const { index } of new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment(
77
+ text,
78
+ )) {
79
+ if (seen === CLAMP) return text.slice(0, index);
80
+ seen++;
81
+ }
82
+ return null;
83
+ }
84
+ </script>
85
+
86
+ <!-- `what` names the value in its button, so a list of buttons tells each
87
+ Show all apart. -->
88
+ {#snippet shown(key: string, text: string, tint: string, what: string)}
89
+ {@const head = clip(text)}
90
+ {@const full = isOpen(key)}
91
+ <div class="min-w-0">
92
+ {#if text === ''}
93
+ <p class="text-faint italic">Empty</p>
94
+ {:else}
95
+ <p
96
+ dir="auto"
97
+ class={cn(
98
+ 'rounded-md whitespace-pre-wrap text-fg [overflow-wrap:anywhere]',
99
+ tint && `px-2 py-1 ${tint}`,
100
+ )}
101
+ >
102
+ {head === null || full ? text : head}
103
+ </p>
104
+ {#if head !== null}
105
+ <Button
106
+ variant="ghost"
107
+ size="sm"
108
+ class="mt-1"
109
+ aria-label="{full ? 'Show less' : 'Show all'} of {what}"
110
+ aria-expanded={full}
111
+ onclick={() => toggle(key)}
112
+ >
113
+ {full ? 'Show less' : 'Show all'}
114
+ </Button>
115
+ {/if}
116
+ {/if}
117
+ </div>
118
+ {/snippet}
119
+
120
+ <div class={cn('text-sm', klass)}>
121
+ {#if changes.length === 0}
122
+ <p class="text-muted">These versions are the same.</p>
123
+ {:else}
124
+ <ul class="divide-y divide-line">
125
+ {#each changes as change, i (i)}
126
+ <li class="flex flex-col gap-2 py-3 first:pt-0 last:pb-0">
127
+ <div class="flex flex-wrap items-center gap-2">
128
+ <span dir="auto" class="min-w-0 font-medium text-fg [overflow-wrap:anywhere]">
129
+ {change.label}
130
+ </span>
131
+ <Badge tone={KIND[change.kind].tone}>{KIND[change.kind].word}</Badge>
132
+ </div>
133
+ {#if change.kind === 'changed'}
134
+ <div class="grid grid-cols-[auto_minmax(0,1fr)] items-baseline gap-x-3 gap-y-1.5">
135
+ <span class="text-xs font-medium text-muted">Removed</span>
136
+ {@render shown(
137
+ `${i}-before`,
138
+ change.before,
139
+ 'bg-danger/10',
140
+ `${change.label}, removed`,
141
+ )}
142
+ <span class="text-xs font-medium text-muted">Added</span>
143
+ {@render shown(`${i}-after`, change.after, 'bg-success/10', `${change.label}, added`)}
144
+ </div>
145
+ {:else}
146
+ {@render shown(`${i}`, change.value, '', change.label)}
147
+ {/if}
148
+ </li>
149
+ {/each}
150
+ </ul>
151
+ {/if}
152
+ </div>
@@ -0,0 +1,27 @@
1
+ /** One difference between two versions, named by the field it is in. */
2
+ export type DiffChange = {
3
+ kind: 'added';
4
+ label: string;
5
+ value: string;
6
+ } | {
7
+ kind: 'removed';
8
+ label: string;
9
+ value: string;
10
+ } | {
11
+ kind: 'changed';
12
+ label: string;
13
+ before: string;
14
+ after: string;
15
+ } | {
16
+ kind: 'moved';
17
+ label: string;
18
+ /** Where it moved, in the caller's words, such as "From position 2 to 4". */
19
+ value: string;
20
+ };
21
+ interface Props {
22
+ changes: DiffChange[];
23
+ class?: string;
24
+ }
25
+ declare const DiffView: import("svelte").Component<Props, {}, "">;
26
+ type DiffView = ReturnType<typeof DiffView>;
27
+ export default DiffView;
@@ -34,8 +34,10 @@
34
34
  *
35
35
  * A submit outcome has no such permanent host, because the page mounts this
36
36
  * component at the moment the submit resolves. So the region goes in empty
37
- * and the message is placed inside it on the next frame, which is the same
38
- * shape of change a toast makes.
37
+ * and the message is placed inside it two frames later, which is the same
38
+ * shape of change a toast makes. One frame is not enough: Firefox builds its
39
+ * accessibility tree after the frame's callbacks run, so a region filled on
40
+ * the first frame reaches it already full and Orca hears nothing.
39
41
  *
40
42
  * The server runs no effect and has no frames, so it renders the message
41
43
  * straight away: a page delivered without its client bundle still carries
@@ -44,7 +46,9 @@
44
46
  let filled = $state(typeof window === 'undefined');
45
47
 
46
48
  $effect(() => {
47
- const frame = requestAnimationFrame(() => (filled = true));
49
+ let frame = requestAnimationFrame(() => {
50
+ frame = requestAnimationFrame(() => (filled = true));
51
+ });
48
52
  return () => cancelAnimationFrame(frame);
49
53
  });
50
54
  </script>
@@ -54,12 +58,14 @@
54
58
  interrupts whatever the reader is doing, because the submit did not go
55
59
  through and the user has to act on it. A confirmation takes aria-live
56
60
  "polite" and waits for a pause, because the work is done and nothing is being
57
- asked of anyone.
61
+ asked of anyone. It takes no role: browsers expose role="status" as a status
62
+ bar, and Orca speaks nothing inserted into one.
58
63
  -->
59
64
  <div
60
65
  class="flex items-start gap-2 text-sm {tones[tone]} {klass}"
61
- role={tone === 'danger' ? 'alert' : 'status'}
66
+ role={tone === 'danger' ? 'alert' : undefined}
62
67
  aria-live={tone === 'danger' ? 'assertive' : 'polite'}
68
+ aria-atomic={tone === 'danger' ? undefined : 'true'}
63
69
  >
64
70
  {#if filled}
65
71
  <!-- Stroked SVG, not a literal cross or tick: a font glyph lands at
@@ -58,7 +58,7 @@
58
58
  xmlns="http://www.w3.org/2000/svg"
59
59
  aria-hidden="true"
60
60
  >
61
- <g transform="translate(6 4)">
61
+ <g transform="translate(6 3)">
62
62
  <polygon class="fill-mark-ink" points="0,14 14,7 14,40 0,47" />
63
63
  <polygon class="fill-mark-foot" points="0,47 14,40 14,54 0,61" />
64
64
  <polygon class="fill-mark-step" points="14,40 28,33 28,47 14,54" />