@sveltier/ui 0.1.0-alpha.0 → 0.1.0-alpha.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.
- package/dist/components/Accordion/Accordion.svelte +74 -0
- package/dist/components/Accordion/Accordion.svelte.d.ts +25 -0
- package/dist/components/Accordion/accordion.css +88 -0
- package/dist/components/Accordion/index.d.ts +2 -0
- package/dist/components/Accordion/index.js +1 -0
- package/dist/components/Accordion/types.d.ts +64 -0
- package/dist/components/Accordion/types.js +1 -0
- package/dist/components/Dialog/Dialog.svelte +36 -0
- package/dist/components/Dialog/Dialog.svelte.d.ts +4 -0
- package/dist/components/Dialog/index.d.ts +2 -0
- package/dist/components/Dialog/index.js +1 -0
- package/dist/components/Dialog/types.d.ts +50 -0
- package/dist/components/Dialog/types.js +1 -0
- package/dist/components/Menu/Menu.svelte +195 -0
- package/dist/components/Menu/Menu.svelte.d.ts +4 -0
- package/dist/components/Menu/index.d.ts +2 -0
- package/dist/components/Menu/index.js +1 -0
- package/dist/components/Menu/menu.css +103 -0
- package/dist/components/Menu/types.d.ts +90 -0
- package/dist/components/Menu/types.js +1 -0
- package/dist/components/Modal/Modal.svelte +146 -0
- package/dist/components/Modal/Modal.svelte.d.ts +4 -0
- package/dist/components/Modal/modal.css +150 -0
- package/dist/components/Modal/types.d.ts +54 -0
- package/dist/components/Modal/types.js +1 -0
- package/dist/components/Popover/Popover.svelte +115 -0
- package/dist/components/Popover/Popover.svelte.d.ts +4 -0
- package/dist/components/Popover/index.d.ts +2 -0
- package/dist/components/Popover/index.js +1 -0
- package/dist/components/Popover/popover.css +67 -0
- package/dist/components/Popover/types.d.ts +83 -0
- package/dist/components/Popover/types.js +1 -0
- package/dist/components/Sheet/Sheet.svelte +56 -0
- package/dist/components/Sheet/Sheet.svelte.d.ts +4 -0
- package/dist/components/Sheet/index.d.ts +2 -0
- package/dist/components/Sheet/index.js +2 -0
- package/dist/components/Sheet/sheet.css +61 -0
- package/dist/components/Sheet/types.d.ts +51 -0
- package/dist/components/Sheet/types.js +1 -0
- package/dist/components/Tooltip/Tooltip.svelte +161 -0
- package/dist/components/Tooltip/Tooltip.svelte.d.ts +4 -0
- package/dist/components/Tooltip/index.d.ts +2 -0
- package/dist/components/Tooltip/index.js +1 -0
- package/dist/components/Tooltip/tooltip.css +44 -0
- package/dist/components/Tooltip/types.d.ts +55 -0
- package/dist/components/Tooltip/types.js +1 -0
- package/dist/core/errors/codes.d.ts +15 -3
- package/dist/core/errors/codes.js +15 -3
- package/dist/core/locale/locale.d.ts +1 -0
- package/dist/core/locale/locale.js +2 -0
- package/dist/core/overlay/types.d.ts +33 -0
- package/dist/core/overlay/types.js +1 -0
- package/dist/core/provider/UIProvider.svelte +5 -2
- package/dist/core/theme/css.js +6 -2
- package/dist/core/toast/ToastOutlet.svelte +192 -0
- package/dist/core/toast/ToastOutlet.svelte.d.ts +11 -0
- package/dist/core/toast/ToastProvider.svelte +39 -0
- package/dist/core/toast/ToastProvider.svelte.d.ts +19 -0
- package/dist/core/toast/toast.css +134 -0
- package/dist/core/toast/toast.svelte.d.ts +117 -0
- package/dist/core/toast/toast.svelte.js +133 -0
- package/dist/dev/off.d.ts +1 -0
- package/dist/dev/off.js +1 -0
- package/dist/dev/on.d.ts +14 -2
- package/dist/dev/on.js +22 -6
- package/dist/index.d.ts +9 -0
- package/dist/index.js +11 -0
- package/dist/primitives/layer/dismissable.d.ts +6 -0
- package/dist/primitives/layer/dismissable.js +11 -4
- package/dist/styles/props.css +5 -2
- package/package.json +1 -1
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
import type { HTMLAttributes } from 'svelte/elements';
|
|
3
|
+
import type { CloseReason, TriggerAttributes } from '../../core/overlay/types.js';
|
|
4
|
+
import type { ElementProps } from '../../core/props/element.js';
|
|
5
|
+
import type { Density, Rounded, Size } from '../../core/props/types.js';
|
|
6
|
+
import type { Align, Side } from '../../primitives/anchored/anchored.js';
|
|
7
|
+
/** The Popover props that `DefaultsProvider` and `UIProvider` can set for a subtree. */
|
|
8
|
+
export interface PopoverDefaults {
|
|
9
|
+
/**
|
|
10
|
+
* The widest it grows, from `xs` (13rem) to `xl` (34rem); narrower content keeps it narrower.
|
|
11
|
+
* @default 'md'
|
|
12
|
+
*/
|
|
13
|
+
size: Size;
|
|
14
|
+
/**
|
|
15
|
+
* Multiplies the padding, never the text.
|
|
16
|
+
* @default 'default'
|
|
17
|
+
*/
|
|
18
|
+
density: Density;
|
|
19
|
+
/**
|
|
20
|
+
* Corner rounding. `true` keeps the theme's radius for large surfaces.
|
|
21
|
+
* @default true
|
|
22
|
+
*/
|
|
23
|
+
rounded: Rounded;
|
|
24
|
+
}
|
|
25
|
+
declare module '../../core/defaults/context.js' {
|
|
26
|
+
interface ComponentDefaults {
|
|
27
|
+
Popover: PopoverDefaults;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* A panel next to the control that opens it, for content the user works in: a few fields, a
|
|
32
|
+
* choice, a short explanation with a link. It does not block the page: Escape, a press outside
|
|
33
|
+
* and focus moving out close it. Focus moves into it when it opens and back to the control when
|
|
34
|
+
* it closes from inside. For text that only describes a control, use a Tooltip; for a task that
|
|
35
|
+
* needs the whole screen, a Dialog. `class`, `style`, `ref` and other attributes go to the panel.
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* <Popover title="Amount range">
|
|
39
|
+
* {#snippet trigger(attributes)}<Button {...attributes} variant="outlined">Amount</Button>{/snippet}
|
|
40
|
+
* <NumberField label="From" bind:value={from} />
|
|
41
|
+
* {#snippet actions({ close })}<Button onclick={close}>Apply</Button>{/snippet}
|
|
42
|
+
* </Popover>
|
|
43
|
+
*/
|
|
44
|
+
export type PopoverProps = ElementProps<HTMLDivElement, Omit<HTMLAttributes<HTMLDivElement>, 'title' | 'children' | 'popover' | 'role'>, Partial<PopoverDefaults> & {
|
|
45
|
+
/** The control that opens and closes it. Spread the attributes it receives onto it. */
|
|
46
|
+
trigger: Snippet<[TriggerAttributes]>;
|
|
47
|
+
/**
|
|
48
|
+
* Whether it is open. Bindable; it sets this to `false` when it closes itself.
|
|
49
|
+
* @default false
|
|
50
|
+
*/
|
|
51
|
+
open?: boolean;
|
|
52
|
+
/** A heading, which also names it. Without it, name it with `aria-label`. */
|
|
53
|
+
title?: string;
|
|
54
|
+
/** The content. */
|
|
55
|
+
children?: Snippet;
|
|
56
|
+
/** Buttons at the end. `close()` closes it with the reason `'action'`. */
|
|
57
|
+
actions?: Snippet<[{
|
|
58
|
+
close: () => void;
|
|
59
|
+
}]>;
|
|
60
|
+
/**
|
|
61
|
+
* The side of the control it opens on, moved to the opposite side when there is no room.
|
|
62
|
+
* `start` and `end` follow the writing direction.
|
|
63
|
+
* @default 'bottom'
|
|
64
|
+
*/
|
|
65
|
+
side?: Side;
|
|
66
|
+
/**
|
|
67
|
+
* Its alignment along that side.
|
|
68
|
+
* @default 'start'
|
|
69
|
+
*/
|
|
70
|
+
align?: Align;
|
|
71
|
+
/**
|
|
72
|
+
* The room between the control and the panel, in px.
|
|
73
|
+
* @default 8
|
|
74
|
+
*/
|
|
75
|
+
offset?: number;
|
|
76
|
+
/**
|
|
77
|
+
* Escape, a press outside and focus moving out do not close it: an answer is needed.
|
|
78
|
+
* @default false
|
|
79
|
+
*/
|
|
80
|
+
persistent?: boolean;
|
|
81
|
+
/** Called when it closes itself, with the reason. */
|
|
82
|
+
onclose?: (reason: CloseReason) => void;
|
|
83
|
+
}>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// A modal panel against an edge of the screen, on the shared modal shell. See SheetProps.
|
|
3
|
+
import type { TransitionConfig } from 'svelte/transition';
|
|
4
|
+
import { checkValue, DEV } from '#dev';
|
|
5
|
+
import { useDefaults } from '../../core/defaults/context.js';
|
|
6
|
+
import { sharedClasses } from '../../core/props/classes.js';
|
|
7
|
+
import { easing, motion } from '../../primitives/motion/motion.js';
|
|
8
|
+
import Modal from '../Modal/Modal.svelte';
|
|
9
|
+
import './sheet.css';
|
|
10
|
+
import { SHEET_SIDES, type SheetDefaults, type SheetProps } from './types.js';
|
|
11
|
+
|
|
12
|
+
let {
|
|
13
|
+
size,
|
|
14
|
+
density,
|
|
15
|
+
rounded,
|
|
16
|
+
side = 'end',
|
|
17
|
+
open = $bindable(false),
|
|
18
|
+
class: className,
|
|
19
|
+
ref = $bindable(null),
|
|
20
|
+
...rest
|
|
21
|
+
}: SheetProps = $props();
|
|
22
|
+
|
|
23
|
+
const resolve = useDefaults<SheetDefaults>('Sheet', {
|
|
24
|
+
size: 'sm',
|
|
25
|
+
density: 'default',
|
|
26
|
+
rounded: true
|
|
27
|
+
});
|
|
28
|
+
const p = $derived(resolve({ size, density, rounded }));
|
|
29
|
+
|
|
30
|
+
if (DEV) $effect(() => checkValue('Sheet', 'side', side, SHEET_SIDES));
|
|
31
|
+
|
|
32
|
+
// The panel slides in from its edge, with the theme's timing; start and end follow the
|
|
33
|
+
// writing direction.
|
|
34
|
+
function slide(node: Element): TransitionConfig {
|
|
35
|
+
const timing = motion(node);
|
|
36
|
+
const rtl = getComputedStyle(node).direction === 'rtl';
|
|
37
|
+
const axis = side === 'top' || side === 'bottom' ? 'Y' : 'X';
|
|
38
|
+
const sign = side === 'bottom' ? 1 : side === 'top' ? -1 : (side === 'end') !== rtl ? 1 : -1;
|
|
39
|
+
return {
|
|
40
|
+
duration: timing.normal,
|
|
41
|
+
easing: easing(timing.ease),
|
|
42
|
+
css: (_, u) => `transform: translate${axis}(${sign * u * 100}%)`
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
</script>
|
|
46
|
+
|
|
47
|
+
<Modal
|
|
48
|
+
{...rest}
|
|
49
|
+
component="Sheet"
|
|
50
|
+
placement={side}
|
|
51
|
+
panelClass={sharedClasses(p, 'Sheet')}
|
|
52
|
+
enter={slide}
|
|
53
|
+
class={['sv-sheet', className]}
|
|
54
|
+
bind:open
|
|
55
|
+
bind:ref
|
|
56
|
+
/>
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
@layer sv.tokens, sv.base, sv.components, sv.utilities;
|
|
2
|
+
|
|
3
|
+
@layer sv.components {
|
|
4
|
+
/*
|
|
5
|
+
* Sheets: the panel against one edge, the full length of it, rounded on its inner corners only.
|
|
6
|
+
* A sheet from the start or the end is as wide as its size; one from the top or the bottom is
|
|
7
|
+
* as wide as its size at most, and as tall as its content allows.
|
|
8
|
+
*/
|
|
9
|
+
.sv-modal:not(.sv-modal--center) {
|
|
10
|
+
padding: 0;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
.sv-modal--start,
|
|
14
|
+
.sv-modal--end {
|
|
15
|
+
align-items: stretch;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.sv-modal--start {
|
|
19
|
+
justify-items: start;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.sv-modal--end {
|
|
23
|
+
justify-items: end;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.sv-modal--top {
|
|
27
|
+
align-items: start;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.sv-modal--bottom {
|
|
31
|
+
align-items: end;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
:is(.sv-modal--start, .sv-modal--end) > .sv-modal__panel {
|
|
35
|
+
block-size: 100%;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
:is(.sv-modal--top, .sv-modal--bottom) > .sv-modal__panel {
|
|
39
|
+
max-block-size: calc(100% - var(--sv-space-34));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.sv-modal--start > .sv-modal__panel {
|
|
43
|
+
border-start-start-radius: 0;
|
|
44
|
+
border-end-start-radius: 0;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.sv-modal--end > .sv-modal__panel {
|
|
48
|
+
border-start-end-radius: 0;
|
|
49
|
+
border-end-end-radius: 0;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.sv-modal--top > .sv-modal__panel {
|
|
53
|
+
border-start-start-radius: 0;
|
|
54
|
+
border-start-end-radius: 0;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
.sv-modal--bottom > .sv-modal__panel {
|
|
58
|
+
border-end-start-radius: 0;
|
|
59
|
+
border-end-end-radius: 0;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { HTMLDialogAttributes } from 'svelte/elements';
|
|
2
|
+
import type { ElementProps } from '../../core/props/element.js';
|
|
3
|
+
import type { Density, Rounded, Size } from '../../core/props/types.js';
|
|
4
|
+
import type { ModalContentProps } from '../Modal/types.js';
|
|
5
|
+
/** The Sheet props that `DefaultsProvider` and `UIProvider` can set for a subtree. */
|
|
6
|
+
export interface SheetDefaults {
|
|
7
|
+
/**
|
|
8
|
+
* The width of the panel, from `xs` (21rem) to `xl` (55rem); for a sheet from the top or the
|
|
9
|
+
* bottom, the most it spans.
|
|
10
|
+
* @default 'sm'
|
|
11
|
+
*/
|
|
12
|
+
size: Size;
|
|
13
|
+
/**
|
|
14
|
+
* Multiplies the padding, never the text.
|
|
15
|
+
* @default 'default'
|
|
16
|
+
*/
|
|
17
|
+
density: Density;
|
|
18
|
+
/**
|
|
19
|
+
* Corner rounding of the inner corners. `true` keeps the theme's radius for large surfaces.
|
|
20
|
+
* @default true
|
|
21
|
+
*/
|
|
22
|
+
rounded: Rounded;
|
|
23
|
+
}
|
|
24
|
+
declare module '../../core/defaults/context.js' {
|
|
25
|
+
interface ComponentDefaults {
|
|
26
|
+
Sheet: SheetDefaults;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export declare const SHEET_SIDES: readonly ["start", "end", "top", "bottom"];
|
|
30
|
+
/** The edge a sheet comes from. `start` and `end` follow the writing direction. */
|
|
31
|
+
export type SheetSide = (typeof SHEET_SIDES)[number];
|
|
32
|
+
/**
|
|
33
|
+
* A modal panel that slides in from an edge of the screen: a drawer from the start or the end,
|
|
34
|
+
* a sheet from the bottom or the top. It is a Dialog in every other way: the platform's own
|
|
35
|
+
* `<dialog>`, focus moved in and given back, Escape and the backdrop close it. `class`, `style`,
|
|
36
|
+
* `ref` and other attributes go to the `<dialog>`.
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* <Sheet title="Filters" side="end">
|
|
40
|
+
* {#snippet trigger(attributes)}<Button {...attributes} variant="outlined">Filters</Button>{/snippet}
|
|
41
|
+
* <Checkbox label="Pending only" bind:checked={pending} />
|
|
42
|
+
* {#snippet actions({ close })}<Button onclick={close}>Show results</Button>{/snippet}
|
|
43
|
+
* </Sheet>
|
|
44
|
+
*/
|
|
45
|
+
export type SheetProps = ElementProps<HTMLDialogElement, Omit<HTMLDialogAttributes, 'open' | 'title' | 'children'>, Partial<SheetDefaults> & ModalContentProps & {
|
|
46
|
+
/**
|
|
47
|
+
* The edge it slides in from.
|
|
48
|
+
* @default 'end'
|
|
49
|
+
*/
|
|
50
|
+
side?: SheetSide;
|
|
51
|
+
}>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const SHEET_SIDES = ['start', 'end', 'top', 'bottom'];
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
<script module lang="ts">
|
|
2
|
+
// Until when the next tooltip skips its delay: while one shows, and shortly after it hides,
|
|
3
|
+
// so moving across a toolbar shows each one at once.
|
|
4
|
+
let warmUntil = 0;
|
|
5
|
+
</script>
|
|
6
|
+
|
|
7
|
+
<script lang="ts">
|
|
8
|
+
// A short text that describes a control, on hover and keyboard focus. See TooltipProps.
|
|
9
|
+
import { createAttachmentKey, type Attachment } from 'svelte/attachments';
|
|
10
|
+
import { on } from 'svelte/events';
|
|
11
|
+
import { checkValue, DEV, warnNamelessTrigger } from '#dev';
|
|
12
|
+
import { useDefaults } from '../../core/defaults/context.js';
|
|
13
|
+
import type { TriggerAttributes } from '../../core/overlay/types.js';
|
|
14
|
+
import { ALIGNS, anchored, SIDES } from '../../primitives/anchored/anchored.js';
|
|
15
|
+
import { dismissable } from '../../primitives/layer/dismissable.js';
|
|
16
|
+
import './tooltip.css';
|
|
17
|
+
import type { TooltipDefaults, TooltipProps } from './types.js';
|
|
18
|
+
|
|
19
|
+
let {
|
|
20
|
+
text,
|
|
21
|
+
trigger,
|
|
22
|
+
side,
|
|
23
|
+
align,
|
|
24
|
+
delay,
|
|
25
|
+
open = $bindable(false),
|
|
26
|
+
id: idProp,
|
|
27
|
+
class: className,
|
|
28
|
+
ref = $bindable(null),
|
|
29
|
+
...rest
|
|
30
|
+
}: TooltipProps = $props();
|
|
31
|
+
|
|
32
|
+
const resolve = useDefaults<TooltipDefaults>('Tooltip', {
|
|
33
|
+
side: 'top',
|
|
34
|
+
align: 'center',
|
|
35
|
+
delay: 377
|
|
36
|
+
});
|
|
37
|
+
const p = $derived(resolve({ side, align, delay }));
|
|
38
|
+
|
|
39
|
+
if (DEV) {
|
|
40
|
+
$effect(() => checkValue('Tooltip', 'side', p.side, SIDES));
|
|
41
|
+
$effect(() => checkValue('Tooltip', 'align', p.align, ALIGNS));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const uid = $props.id();
|
|
45
|
+
const id = $derived(idProp ?? uid);
|
|
46
|
+
|
|
47
|
+
// Room between the control and the tooltip, in px, clear of a focus ring.
|
|
48
|
+
const OFFSET = 8;
|
|
49
|
+
// Time to cross that room onto the tooltip before it hides, in ms.
|
|
50
|
+
const GRACE = 144;
|
|
51
|
+
const WARM = 377;
|
|
52
|
+
|
|
53
|
+
// The control it describes, which it is placed against.
|
|
54
|
+
let anchor = $state<HTMLElement | null>(null);
|
|
55
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
56
|
+
// A press on the control hides the tooltip until the pointer leaves it.
|
|
57
|
+
let pressed = false;
|
|
58
|
+
|
|
59
|
+
function show() {
|
|
60
|
+
clearTimeout(timer);
|
|
61
|
+
open = true;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function hide() {
|
|
65
|
+
clearTimeout(timer);
|
|
66
|
+
open = false;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function later(action: () => void, ms: number) {
|
|
70
|
+
clearTimeout(timer);
|
|
71
|
+
timer = setTimeout(action, ms);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Touch has no hover: a tap is a press, and a long press belongs to the platform.
|
|
75
|
+
function enter(event: PointerEvent) {
|
|
76
|
+
if (event.pointerType === 'touch' || pressed) return;
|
|
77
|
+
if (open || performance.now() < warmUntil) show();
|
|
78
|
+
else later(show, p.delay);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function leave() {
|
|
82
|
+
pressed = false;
|
|
83
|
+
later(hide, GRACE);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function press() {
|
|
87
|
+
pressed = true;
|
|
88
|
+
hide();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Keyboard focus shows it at once; a click that focuses the control does not.
|
|
92
|
+
function focus(event: FocusEvent) {
|
|
93
|
+
if ((event.currentTarget as HTMLElement).matches(':focus-visible')) show();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const control: Attachment<HTMLElement> = (node) => {
|
|
97
|
+
anchor = node;
|
|
98
|
+
if (DEV) warnNamelessTrigger('Tooltip', node);
|
|
99
|
+
const stops = [
|
|
100
|
+
on(node, 'pointerenter', enter),
|
|
101
|
+
on(node, 'pointerleave', leave),
|
|
102
|
+
on(node, 'pointerdown', press),
|
|
103
|
+
on(node, 'focus', focus),
|
|
104
|
+
on(node, 'blur', hide)
|
|
105
|
+
];
|
|
106
|
+
return () => {
|
|
107
|
+
for (const stop of stops) stop();
|
|
108
|
+
clearTimeout(timer);
|
|
109
|
+
if (anchor === node) anchor = null;
|
|
110
|
+
};
|
|
111
|
+
};
|
|
112
|
+
const CONTROL = createAttachmentKey();
|
|
113
|
+
const triggerAttributes: TriggerAttributes = $derived({
|
|
114
|
+
'aria-describedby': id,
|
|
115
|
+
[CONTROL]: control
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// In the top layer while open. It stays placed until it has faded out, and a hidden tooltip
|
|
119
|
+
// is not placed at all, so a page with many of them pays nothing for those not shown.
|
|
120
|
+
let leaving = $state(false);
|
|
121
|
+
const popover: Attachment<HTMLElement> = (node) => {
|
|
122
|
+
if (!open) return;
|
|
123
|
+
node.showPopover();
|
|
124
|
+
warmUntil = Infinity;
|
|
125
|
+
return () => {
|
|
126
|
+
warmUntil = performance.now() + WARM;
|
|
127
|
+
if (!node.matches(':popover-open')) return;
|
|
128
|
+
node.hidePopover();
|
|
129
|
+
leaving = true;
|
|
130
|
+
void Promise.allSettled(node.getAnimations().map(({ finished }) => finished)).then(
|
|
131
|
+
() => (leaving = false)
|
|
132
|
+
);
|
|
133
|
+
};
|
|
134
|
+
};
|
|
135
|
+
const place: Attachment<HTMLElement> = (node) => {
|
|
136
|
+
if (open || leaving) {
|
|
137
|
+
return anchored({ anchor, side: p.side, align: p.align, offset: OFFSET })(node);
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
// Escape hides it first, as the topmost layer; a press outside hides it and goes through.
|
|
141
|
+
const layer: Attachment<HTMLElement> = (node) => {
|
|
142
|
+
if (open) return dismissable({ onDismiss: hide, transient: true })(node);
|
|
143
|
+
};
|
|
144
|
+
</script>
|
|
145
|
+
|
|
146
|
+
{@render trigger(triggerAttributes)}
|
|
147
|
+
<div
|
|
148
|
+
bind:this={ref}
|
|
149
|
+
{...rest}
|
|
150
|
+
{id}
|
|
151
|
+
role="tooltip"
|
|
152
|
+
popover="manual"
|
|
153
|
+
class={['sv-tooltip', className]}
|
|
154
|
+
{@attach popover}
|
|
155
|
+
{@attach place}
|
|
156
|
+
{@attach layer}
|
|
157
|
+
onpointerenter={() => clearTimeout(timer)}
|
|
158
|
+
onpointerleave={leave}
|
|
159
|
+
>
|
|
160
|
+
{text}
|
|
161
|
+
</div>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default as Tooltip } from './Tooltip.svelte';
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
@layer sv.tokens, sv.base, sv.components, sv.utilities;
|
|
2
|
+
|
|
3
|
+
@layer sv.components {
|
|
4
|
+
/*
|
|
5
|
+
* The page inverted: the text colour as the background, so it reads on any surface in both
|
|
6
|
+
* schemes. As wide as its text up to a short line, never wider than the screen. It grows in
|
|
7
|
+
* from 94.4% and fades with the theme's fast timing, and stays in the top layer until it has
|
|
8
|
+
* faded out; with reduced motion it simply appears.
|
|
9
|
+
*/
|
|
10
|
+
.sv-tooltip {
|
|
11
|
+
inline-size: max-content;
|
|
12
|
+
max-inline-size: min(21rem, 100vw - 2 * var(--sv-space-8));
|
|
13
|
+
padding-block: var(--sv-space-3);
|
|
14
|
+
padding-inline: var(--sv-space-8);
|
|
15
|
+
border: 0;
|
|
16
|
+
border-radius: var(--sv-radius-sm);
|
|
17
|
+
overflow: visible;
|
|
18
|
+
background: var(--sv-color-text);
|
|
19
|
+
box-shadow: var(--sv-shadow);
|
|
20
|
+
color: var(--sv-color-background);
|
|
21
|
+
font-family: var(--sv-font-ui);
|
|
22
|
+
font-size: var(--sv-text-xs);
|
|
23
|
+
line-height: var(--sv-leading-snug);
|
|
24
|
+
overflow-wrap: break-word;
|
|
25
|
+
opacity: 0;
|
|
26
|
+
transform: scale(0.944);
|
|
27
|
+
transition-property: opacity, transform, display, overlay;
|
|
28
|
+
transition-duration: var(--sv-duration-fast);
|
|
29
|
+
transition-timing-function: var(--sv-ease);
|
|
30
|
+
transition-behavior: allow-discrete;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.sv-tooltip:popover-open {
|
|
34
|
+
opacity: 1;
|
|
35
|
+
transform: none;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
@starting-style {
|
|
39
|
+
.sv-tooltip:popover-open {
|
|
40
|
+
opacity: 0;
|
|
41
|
+
transform: scale(0.944);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
import type { HTMLAttributes } from 'svelte/elements';
|
|
3
|
+
import type { TriggerAttributes } from '../../core/overlay/types.js';
|
|
4
|
+
import type { ElementProps } from '../../core/props/element.js';
|
|
5
|
+
import type { Align, Side } from '../../primitives/anchored/anchored.js';
|
|
6
|
+
/** The Tooltip props that `DefaultsProvider` and `UIProvider` can set for a subtree. */
|
|
7
|
+
export interface TooltipDefaults {
|
|
8
|
+
/**
|
|
9
|
+
* The side of the control it shows on, moved to the opposite side when there is no room.
|
|
10
|
+
* `start` and `end` follow the writing direction.
|
|
11
|
+
* @default 'top'
|
|
12
|
+
*/
|
|
13
|
+
side: Side;
|
|
14
|
+
/**
|
|
15
|
+
* Its alignment along that side.
|
|
16
|
+
* @default 'center'
|
|
17
|
+
*/
|
|
18
|
+
align: Align;
|
|
19
|
+
/**
|
|
20
|
+
* How long the pointer rests on the control before it shows, in ms. Keyboard focus shows it
|
|
21
|
+
* at once, and so does moving on from another tooltip, as across a toolbar.
|
|
22
|
+
* @default 377
|
|
23
|
+
*/
|
|
24
|
+
delay: number;
|
|
25
|
+
}
|
|
26
|
+
declare module '../../core/defaults/context.js' {
|
|
27
|
+
interface ComponentDefaults {
|
|
28
|
+
Tooltip: TooltipDefaults;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* A short text that describes a control: shown when the pointer rests on it and at once on
|
|
33
|
+
* keyboard focus, hidden on Escape, on leaving and on a press. It stays while the pointer moves
|
|
34
|
+
* onto it. It holds text only; for links, buttons or fields, use a Popover. The text is the
|
|
35
|
+
* control's description (`aria-describedby`), not its name: an icon-only button still needs
|
|
36
|
+
* `aria-label`. `class`, `style`, `ref` and other attributes go to the tooltip.
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* <Tooltip text="Copy the IBAN">
|
|
40
|
+
* {#snippet trigger(attributes)}
|
|
41
|
+
* <Button {...attributes} icon variant="text" aria-label="Copy the IBAN"><Icon path={copy} /></Button>
|
|
42
|
+
* {/snippet}
|
|
43
|
+
* </Tooltip>
|
|
44
|
+
*/
|
|
45
|
+
export type TooltipProps = ElementProps<HTMLDivElement, Omit<HTMLAttributes<HTMLDivElement>, 'children' | 'popover' | 'role'>, Partial<TooltipDefaults> & {
|
|
46
|
+
/** The text. */
|
|
47
|
+
text: string;
|
|
48
|
+
/** The control it describes. Spread the attributes it receives onto it. */
|
|
49
|
+
trigger: Snippet<[TriggerAttributes]>;
|
|
50
|
+
/**
|
|
51
|
+
* Whether it shows. Bindable; it sets this as the pointer and focus come and go.
|
|
52
|
+
* @default false
|
|
53
|
+
*/
|
|
54
|
+
open?: boolean;
|
|
55
|
+
}>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -101,11 +101,23 @@ export declare const ERROR_CODES: {
|
|
|
101
101
|
readonly cause: "A closable chip contains a close button, and a button cannot be placed inside another button or a link.";
|
|
102
102
|
readonly fix: "Keep the chip `closable` and put its action in a `Button` next to it, or remove `closable`.";
|
|
103
103
|
};
|
|
104
|
+
readonly SV4002: {
|
|
105
|
+
readonly kind: "warning";
|
|
106
|
+
readonly title: "Tooltip on a control without an accessible name";
|
|
107
|
+
readonly cause: "A tooltip describes the control it is on; it does not name it. A control with no text, `aria-label` or `aria-labelledby`, such as an icon-only button, is announced without a name.";
|
|
108
|
+
readonly fix: "Give the control `aria-label`, as every icon-only button needs, or visible text. The tooltip can then say more than the name.";
|
|
109
|
+
};
|
|
110
|
+
readonly SV4003: {
|
|
111
|
+
readonly kind: "error";
|
|
112
|
+
readonly title: "useToast() outside a UIProvider";
|
|
113
|
+
readonly cause: "`useToast()` reads the toasts from the component tree, and there is no `<UIProvider>` or `<ToastProvider>` above the component that called it.";
|
|
114
|
+
readonly fix: "Wrap the app in `<UIProvider>` in the root layout, or in `<ToastProvider>` when it uses `<ThemeProvider>` alone, and call `useToast()` while a component inside it initialises.";
|
|
115
|
+
};
|
|
104
116
|
readonly SV5001: {
|
|
105
117
|
readonly kind: "warning";
|
|
106
|
-
readonly title: "Field without an accessible name";
|
|
107
|
-
readonly cause: "Assistive technology announces a form control by its name, and this
|
|
108
|
-
readonly fix: "Give
|
|
118
|
+
readonly title: "Field or dialog without an accessible name";
|
|
119
|
+
readonly cause: "Assistive technology announces a form control or a dialog by its name, and this one has no label, title, `aria-label` or `aria-labelledby`.";
|
|
120
|
+
readonly fix: "Give a field a `label` and a dialog a `title`, or name it with `aria-label`, or with `aria-labelledby` when its name is visible elsewhere.";
|
|
109
121
|
};
|
|
110
122
|
};
|
|
111
123
|
/** A stable error code, such as `'SV1001'`. */
|
|
@@ -87,10 +87,22 @@ export const ERROR_CODES = {
|
|
|
87
87
|
cause: 'A closable chip contains a close button, and a button cannot be placed inside another button or a link.',
|
|
88
88
|
fix: 'Keep the chip `closable` and put its action in a `Button` next to it, or remove `closable`.'
|
|
89
89
|
},
|
|
90
|
+
SV4002: {
|
|
91
|
+
kind: 'warning',
|
|
92
|
+
title: 'Tooltip on a control without an accessible name',
|
|
93
|
+
cause: 'A tooltip describes the control it is on; it does not name it. A control with no text, `aria-label` or `aria-labelledby`, such as an icon-only button, is announced without a name.',
|
|
94
|
+
fix: 'Give the control `aria-label`, as every icon-only button needs, or visible text. The tooltip can then say more than the name.'
|
|
95
|
+
},
|
|
96
|
+
SV4003: {
|
|
97
|
+
kind: 'error',
|
|
98
|
+
title: 'useToast() outside a UIProvider',
|
|
99
|
+
cause: '`useToast()` reads the toasts from the component tree, and there is no `<UIProvider>` or `<ToastProvider>` above the component that called it.',
|
|
100
|
+
fix: 'Wrap the app in `<UIProvider>` in the root layout, or in `<ToastProvider>` when it uses `<ThemeProvider>` alone, and call `useToast()` while a component inside it initialises.'
|
|
101
|
+
},
|
|
90
102
|
SV5001: {
|
|
91
103
|
kind: 'warning',
|
|
92
|
-
title: 'Field without an accessible name',
|
|
93
|
-
cause: 'Assistive technology announces a form control by its name, and this
|
|
94
|
-
fix: 'Give
|
|
104
|
+
title: 'Field or dialog without an accessible name',
|
|
105
|
+
cause: 'Assistive technology announces a form control or a dialog by its name, and this one has no label, title, `aria-label` or `aria-labelledby`.',
|
|
106
|
+
fix: 'Give a field a `label` and a dialog a `title`, or name it with `aria-label`, or with `aria-labelledby` when its name is visible elsewhere.'
|
|
95
107
|
}
|
|
96
108
|
};
|
|
@@ -3,6 +3,8 @@ import { getContext, hasContext, setContext } from 'svelte';
|
|
|
3
3
|
export const MESSAGES = {
|
|
4
4
|
close: 'Close',
|
|
5
5
|
loading: 'Loading',
|
|
6
|
+
// The region of toasts, followed by its key: "Notifications (F8)".
|
|
7
|
+
notifications: 'Notifications',
|
|
6
8
|
progress: 'Progress',
|
|
7
9
|
// Validation: the HTML constraints of form fields.
|
|
8
10
|
required: 'Required',
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { Attachment } from 'svelte/attachments';
|
|
2
|
+
export declare const CLOSE_REASONS: readonly ["escape", "outside", "focus", "action"];
|
|
3
|
+
/**
|
|
4
|
+
* Why an overlay closed itself: `'escape'` (the Escape key), `'outside'` (a press outside it,
|
|
5
|
+
* on the backdrop of a modal), `'focus'` (focus moved out of a menu or a popover) or `'action'`
|
|
6
|
+
* (its close button, `close()` from its snippets, or a choice made in it).
|
|
7
|
+
*/
|
|
8
|
+
export type CloseReason = (typeof CLOSE_REASONS)[number];
|
|
9
|
+
/**
|
|
10
|
+
* What an overlay passes to its `trigger` snippet: the ARIA that ties the control to the
|
|
11
|
+
* overlay, the handlers that open it, and an attachment that tells the overlay which element
|
|
12
|
+
* opened it (focus goes back there on close). Spread it onto the control.
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* <Dialog title="Rename the account">
|
|
16
|
+
* {#snippet trigger(attributes)}<Button {...attributes}>Rename</Button>{/snippet}
|
|
17
|
+
* …
|
|
18
|
+
* </Dialog>
|
|
19
|
+
*/
|
|
20
|
+
export interface TriggerAttributes {
|
|
21
|
+
/** The kind of overlay the control opens. */
|
|
22
|
+
'aria-haspopup'?: 'dialog' | 'menu' | 'true';
|
|
23
|
+
/** Whether the overlay is open, for overlays that stay beside the control. */
|
|
24
|
+
'aria-expanded'?: boolean;
|
|
25
|
+
/** The overlay's id while it is open. */
|
|
26
|
+
'aria-controls'?: string;
|
|
27
|
+
/** The overlay's id, for a tooltip that describes the control. */
|
|
28
|
+
'aria-describedby'?: string;
|
|
29
|
+
onclick?: (event: MouseEvent) => void;
|
|
30
|
+
onkeydown?: (event: KeyboardEvent) => void;
|
|
31
|
+
/** The attachment that hands the control to the overlay. */
|
|
32
|
+
[attachment: symbol]: Attachment<HTMLElement>;
|
|
33
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const CLOSE_REASONS = ['escape', 'outside', 'focus', 'action'];
|