@lyeve-labs/ui-kit 0.22.2 → 0.23.1
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.
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import type { Snippet } from 'svelte';
|
|
3
3
|
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements';
|
|
4
4
|
import Spinner from './Spinner.svelte';
|
|
5
|
+
import Tooltip from './Tooltip.svelte';
|
|
5
6
|
import { safeHref } from '../internal/href.js';
|
|
6
7
|
|
|
7
8
|
type Variant = 'primary' | 'secondary' | 'danger' | 'ghost' | 'outline' | 'violet';
|
|
@@ -25,6 +26,18 @@
|
|
|
25
26
|
type?: 'button' | 'submit' | 'reset';
|
|
26
27
|
href?: string;
|
|
27
28
|
full?: boolean;
|
|
29
|
+
/**
|
|
30
|
+
* The words shown on hover and focus. Defaults to the button's own
|
|
31
|
+
* aria-label, so an icon-only button names itself to a sighted reader the
|
|
32
|
+
* way it already does to a screen reader; a string replaces those words,
|
|
33
|
+
* and `false` keeps the button silent.
|
|
34
|
+
*
|
|
35
|
+
* Thirty-five icon-only actions across one console had a name and no
|
|
36
|
+
* hint, so a pointer user learned what the trash can did by pressing it.
|
|
37
|
+
* The hint lives here rather than on each caller because the rule that
|
|
38
|
+
* makes a row action icon-only is the kit's, and so is the fix.
|
|
39
|
+
*/
|
|
40
|
+
hint?: string | false;
|
|
28
41
|
class?: string;
|
|
29
42
|
onclick?: (e: MouseEvent) => void;
|
|
30
43
|
children: Snippet;
|
|
@@ -38,12 +51,18 @@
|
|
|
38
51
|
type = 'button',
|
|
39
52
|
href = undefined,
|
|
40
53
|
full = false,
|
|
54
|
+
hint = undefined,
|
|
41
55
|
class: klass = '',
|
|
42
56
|
onclick,
|
|
43
57
|
children,
|
|
44
58
|
...rest
|
|
45
59
|
}: Props = $props();
|
|
46
60
|
|
|
61
|
+
/** The hint text, or nothing: an explicit string, else the aria-label, unless switched off. */
|
|
62
|
+
const hintText = $derived(
|
|
63
|
+
hint === false ? undefined : (hint ?? (rest['aria-label'] || undefined)),
|
|
64
|
+
);
|
|
65
|
+
|
|
47
66
|
/**
|
|
48
67
|
* Colour per variant, in three states: at rest, under a pointer, and held.
|
|
49
68
|
*
|
|
@@ -104,27 +123,40 @@
|
|
|
104
123
|
const inert = $derived(disabled || loading);
|
|
105
124
|
</script>
|
|
106
125
|
|
|
107
|
-
{#
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
126
|
+
{#snippet control()}
|
|
127
|
+
{#if href}
|
|
128
|
+
<a
|
|
129
|
+
href={inert ? undefined : resolvedHref}
|
|
130
|
+
class="{cls} {inert ? 'pointer-events-none opacity-50' : ''}"
|
|
131
|
+
aria-disabled={inert ? 'true' : undefined}
|
|
132
|
+
tabindex={inert ? -1 : undefined}
|
|
133
|
+
{...rest}
|
|
134
|
+
>
|
|
135
|
+
{#if loading}<Spinner size={spinnerSize} />{/if}
|
|
136
|
+
{@render children()}
|
|
137
|
+
</a>
|
|
138
|
+
{:else}
|
|
139
|
+
<button
|
|
140
|
+
{type}
|
|
141
|
+
disabled={disabled || loading}
|
|
142
|
+
aria-busy={loading ? 'true' : undefined}
|
|
143
|
+
{onclick}
|
|
144
|
+
class={cls}
|
|
145
|
+
{...rest}
|
|
146
|
+
>
|
|
147
|
+
{#if loading}<Spinner size={spinnerSize} />{/if}
|
|
148
|
+
{@render children()}
|
|
149
|
+
</button>
|
|
150
|
+
{/if}
|
|
151
|
+
{/snippet}
|
|
152
|
+
|
|
153
|
+
<!-- The hint repeats the name, so it does not describe the control a second
|
|
154
|
+
time; the aria-label already announces it. The wrapper takes the full
|
|
155
|
+
width along with the button, or a full button would shrink to its text. -->
|
|
156
|
+
{#if hintText}
|
|
157
|
+
<Tooltip text={hintText} describe={false} class={full ? 'w-full' : ''}>
|
|
158
|
+
{@render control()}
|
|
159
|
+
</Tooltip>
|
|
118
160
|
{:else}
|
|
119
|
-
|
|
120
|
-
{type}
|
|
121
|
-
disabled={disabled || loading}
|
|
122
|
-
aria-busy={loading ? 'true' : undefined}
|
|
123
|
-
{onclick}
|
|
124
|
-
class={cls}
|
|
125
|
-
{...rest}
|
|
126
|
-
>
|
|
127
|
-
{#if loading}<Spinner size={spinnerSize} />{/if}
|
|
128
|
-
{@render children()}
|
|
129
|
-
</button>
|
|
161
|
+
{@render control()}
|
|
130
162
|
{/if}
|
|
@@ -16,6 +16,18 @@ interface Props extends Forwarded {
|
|
|
16
16
|
type?: 'button' | 'submit' | 'reset';
|
|
17
17
|
href?: string;
|
|
18
18
|
full?: boolean;
|
|
19
|
+
/**
|
|
20
|
+
* The words shown on hover and focus. Defaults to the button's own
|
|
21
|
+
* aria-label, so an icon-only button names itself to a sighted reader the
|
|
22
|
+
* way it already does to a screen reader; a string replaces those words,
|
|
23
|
+
* and `false` keeps the button silent.
|
|
24
|
+
*
|
|
25
|
+
* Thirty-five icon-only actions across one console had a name and no
|
|
26
|
+
* hint, so a pointer user learned what the trash can did by pressing it.
|
|
27
|
+
* The hint lives here rather than on each caller because the rule that
|
|
28
|
+
* makes a row action icon-only is the kit's, and so is the fix.
|
|
29
|
+
*/
|
|
30
|
+
hint?: string | false;
|
|
19
31
|
class?: string;
|
|
20
32
|
onclick?: (e: MouseEvent) => void;
|
|
21
33
|
children: Snippet;
|
|
@@ -6,24 +6,84 @@
|
|
|
6
6
|
interface Props {
|
|
7
7
|
text: string;
|
|
8
8
|
position?: Position;
|
|
9
|
+
/**
|
|
10
|
+
* Whether the text is the trigger's accessible description.
|
|
11
|
+
*
|
|
12
|
+
* On by default, because a tooltip that nothing points at is read by
|
|
13
|
+
* nobody. Off for a trigger that already carries the same words as its
|
|
14
|
+
* name: an icon-only button named by aria-label would otherwise be
|
|
15
|
+
* announced as "Delete, Delete", so the hint stays visual there and the
|
|
16
|
+
* name does the announcing.
|
|
17
|
+
*/
|
|
18
|
+
describe?: boolean;
|
|
9
19
|
class?: string;
|
|
10
20
|
children: Snippet;
|
|
11
21
|
}
|
|
12
22
|
|
|
13
|
-
let { text, position = 'top', class: cls = '', children }: Props = $props();
|
|
23
|
+
let { text, position = 'top', describe = true, class: cls = '', children }: Props = $props();
|
|
14
24
|
|
|
15
25
|
let visible = $state(false);
|
|
16
26
|
let wrapper = $state<HTMLElement>();
|
|
17
27
|
|
|
18
28
|
const id = $props.id();
|
|
19
29
|
|
|
30
|
+
/**
|
|
31
|
+
* The box is positioned from the trigger's bounding box, fixed to the
|
|
32
|
+
* viewport, rather than absolutely inside the wrapper.
|
|
33
|
+
*
|
|
34
|
+
* Inside the wrapper it took the wrapper's width, so a hint on an icon
|
|
35
|
+
* button broke one character per line, and any ancestor that scrolls cut
|
|
36
|
+
* it off: a Table's scroller is overflow-x auto, which makes its vertical
|
|
37
|
+
* overflow auto as well, so a hint on a row action was clipped at the
|
|
38
|
+
* header. Nothing between a fixed box and the viewport can clip it. The
|
|
39
|
+
* translate carries the half of the geometry that depends on the box's own
|
|
40
|
+
* size, which is not known until it renders.
|
|
41
|
+
*/
|
|
20
42
|
const pos: Record<Position, string> = {
|
|
21
|
-
top: '
|
|
22
|
-
bottom: '
|
|
23
|
-
left: '
|
|
24
|
-
right: '
|
|
43
|
+
top: '-translate-x-1/2 -translate-y-full',
|
|
44
|
+
bottom: '-translate-x-1/2',
|
|
45
|
+
left: '-translate-x-full -translate-y-1/2',
|
|
46
|
+
right: '-translate-y-1/2',
|
|
25
47
|
};
|
|
26
48
|
|
|
49
|
+
/** The distance between the trigger's edge and the box. */
|
|
50
|
+
const GAP = 8;
|
|
51
|
+
|
|
52
|
+
let anchor = $state({ top: 0, left: 0 });
|
|
53
|
+
|
|
54
|
+
function place() {
|
|
55
|
+
const r = wrapper?.getBoundingClientRect();
|
|
56
|
+
if (!r) return;
|
|
57
|
+
switch (position) {
|
|
58
|
+
case 'top':
|
|
59
|
+
anchor = { top: r.top - GAP, left: r.left + r.width / 2 };
|
|
60
|
+
break;
|
|
61
|
+
case 'bottom':
|
|
62
|
+
anchor = { top: r.bottom + GAP, left: r.left + r.width / 2 };
|
|
63
|
+
break;
|
|
64
|
+
case 'left':
|
|
65
|
+
anchor = { top: r.top + r.height / 2, left: r.left - GAP };
|
|
66
|
+
break;
|
|
67
|
+
case 'right':
|
|
68
|
+
anchor = { top: r.top + r.height / 2, left: r.right + GAP };
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function show() {
|
|
74
|
+
place();
|
|
75
|
+
visible = true;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// A fixed box does not follow a trigger that scrolls away from under it,
|
|
79
|
+
// so a scroll anywhere closes the hint rather than leaving it stranded.
|
|
80
|
+
$effect(() => {
|
|
81
|
+
if (!visible) return;
|
|
82
|
+
const close = () => (visible = false);
|
|
83
|
+
window.addEventListener('scroll', close, { capture: true, passive: true });
|
|
84
|
+
return () => window.removeEventListener('scroll', close, { capture: true });
|
|
85
|
+
});
|
|
86
|
+
|
|
27
87
|
/**
|
|
28
88
|
* `role="tooltip"` on its own is inert: assistive technology reads a tooltip
|
|
29
89
|
* only because the element it describes points at it. The trigger arrives as
|
|
@@ -32,6 +92,7 @@
|
|
|
32
92
|
* text was visible to a sighted mouse user and to nobody else.
|
|
33
93
|
*/
|
|
34
94
|
$effect(() => {
|
|
95
|
+
if (!describe) return;
|
|
35
96
|
const trigger = wrapper?.querySelector<HTMLElement>(
|
|
36
97
|
'a[href], button, input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
|
37
98
|
);
|
|
@@ -57,9 +118,9 @@
|
|
|
57
118
|
<span
|
|
58
119
|
bind:this={wrapper}
|
|
59
120
|
class="relative inline-flex {cls}"
|
|
60
|
-
onmouseenter={
|
|
121
|
+
onmouseenter={show}
|
|
61
122
|
onmouseleave={() => (visible = false)}
|
|
62
|
-
onfocusin={
|
|
123
|
+
onfocusin={show}
|
|
63
124
|
onfocusout={() => (visible = false)}
|
|
64
125
|
{onkeydown}
|
|
65
126
|
>
|
|
@@ -70,9 +131,11 @@
|
|
|
70
131
|
moving onto it does not dismiss it, which SC 1.4.13 also requires. -->
|
|
71
132
|
<span
|
|
72
133
|
{id}
|
|
73
|
-
role=
|
|
134
|
+
role={describe ? 'tooltip' : undefined}
|
|
135
|
+
aria-hidden={describe ? undefined : 'true'}
|
|
74
136
|
hidden={!visible}
|
|
75
|
-
|
|
137
|
+
style="top: {anchor.top}px; left: {anchor.left}px"
|
|
138
|
+
class="fixed {pos[position]} z-tooltip w-max max-w-[min(16rem,calc(100vw-2rem))]
|
|
76
139
|
rounded-lg border border-line bg-surface-2 px-2.5 py-1.5 text-xs text-fg shadow-xl"
|
|
77
140
|
>
|
|
78
141
|
{text}
|
|
@@ -3,6 +3,16 @@ type Position = 'top' | 'bottom' | 'left' | 'right';
|
|
|
3
3
|
interface Props {
|
|
4
4
|
text: string;
|
|
5
5
|
position?: Position;
|
|
6
|
+
/**
|
|
7
|
+
* Whether the text is the trigger's accessible description.
|
|
8
|
+
*
|
|
9
|
+
* On by default, because a tooltip that nothing points at is read by
|
|
10
|
+
* nobody. Off for a trigger that already carries the same words as its
|
|
11
|
+
* name: an icon-only button named by aria-label would otherwise be
|
|
12
|
+
* announced as "Delete, Delete", so the hint stays visual there and the
|
|
13
|
+
* name does the announcing.
|
|
14
|
+
*/
|
|
15
|
+
describe?: boolean;
|
|
6
16
|
class?: string;
|
|
7
17
|
children: Snippet;
|
|
8
18
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -89,4 +89,4 @@ export type { ConfirmOptions } from './components/dialog/dialog-manager.svelte.j
|
|
|
89
89
|
export type { DialogOptions, DialogEntry, DialogSize } from './components/dialog/types.js';
|
|
90
90
|
export { cn, type ClassValue } from './utils/cn.js';
|
|
91
91
|
export { getTheme, getThemePreference, nextThemePreference, resolveTheme, setTheme, setThemePreference, systemTheme, themeBootScript, toggleTheme, watchSystemTheme, THEME_PREFERENCES, type Theme, type ThemePreference, } from './utils/theme.js';
|
|
92
|
-
export declare const VERSION = "0.
|
|
92
|
+
export declare const VERSION = "0.23.1";
|
package/dist/index.js
CHANGED
|
@@ -96,4 +96,4 @@ export { getTheme, getThemePreference, nextThemePreference, resolveTheme, setThe
|
|
|
96
96
|
// ── Version ────────────────────────────────────────────────────────────────
|
|
97
97
|
// Generated from package.json by `pnpm version:sync`. Bump package.json, never
|
|
98
98
|
// this line; the build and the test suite fail when the two disagree.
|
|
99
|
-
export const VERSION = '0.
|
|
99
|
+
export const VERSION = '0.23.1';
|
package/package.json
CHANGED