@vit-foundation/ui 0.30.0 → 0.32.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/README.md +1 -0
- package/dist/components/madlib/InlineSelect.svelte +221 -0
- package/dist/components/madlib/InlineSelect.svelte.d.ts +49 -0
- package/dist/components/madlib/Madlib.svelte +264 -0
- package/dist/components/madlib/Madlib.svelte.d.ts +72 -0
- package/dist/components/madlib/sentenceTree.d.ts +92 -0
- package/dist/components/madlib/sentenceTree.js +140 -0
- package/dist/components/overlay/HoverCard.svelte +138 -0
- package/dist/components/overlay/HoverCard.svelte.d.ts +52 -0
- package/dist/components/overlay/anchor.d.ts +92 -0
- package/dist/components/overlay/anchor.js +61 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/madlib.d.ts +17 -0
- package/dist/madlib.js +15 -0
- package/dist/overlay.d.ts +15 -0
- package/dist/overlay.js +15 -0
- package/dist/styles/tokens.css +36 -0
- package/package.json +11 -1
package/README.md
CHANGED
|
@@ -34,6 +34,7 @@ Everything exports flat from the root, and again grouped by role:
|
|
|
34
34
|
| [`/community`](./docs/components/community.md) | AuthPageShell, LoginForm, SignupForm, GoogleAuthForm, AccountPanel, NewsletterSignup, CommentSection, ReactionBar, ContactForm |
|
|
35
35
|
| [`/admin`](./docs/components/admin.md) | DecorMosaic, PageHeading, Sidebar — shell-level composition for the foundation's internal tools |
|
|
36
36
|
| [`/scrolly`](./docs/components/scrolly.md) | ScrollySteps, ScrollyStepIndicator, CrossfadeVideo, GlassCard, the stepStyle ramp — the only entry point that does NOT re-export from the root, and the only one with a peer of its own |
|
|
37
|
+
| [`/madlib`](./docs/components/madlib.md) | Madlib, InlineSelect, the sentence-tree helpers (walkTree, completePath, defaultPath, replaceAt, leafPaths) — a sentence with blanks, themed with `--vit-madlib-*`; like `/scrolly`, not re-exported from the root |
|
|
37
38
|
| [`/edit`](./docs/edit-mode.md) | Editable, setEditAdapter(adapter, EDIT_CHROME)/getEditAdapter, descriptors and helpers, collectionEditing, LocalizedText |
|
|
38
39
|
| [`/config`](./docs/getting-started.md#wiring-an-app-uiprovider) | UiProvider, UiConfig, the locale set, the default Catalan messages |
|
|
39
40
|
| `/contract` | The component-free half: LOCALES, BASE_LOCALE, localize, REACTIONS, PAGE_COPY_KEYS and the edit-descriptor types — the one subpath a host may import from SERVER code |
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component InlineSelect
|
|
3
|
+
|
|
4
|
+
A select that sits inside running text: the chosen label, underlined, with a
|
|
5
|
+
small caret, and a listbox that drops below it. It inherits the font, size
|
|
6
|
+
and line-height of the text around it, which is the whole point — a native
|
|
7
|
+
`<select>` cannot be made to read as a word in a sentence.
|
|
8
|
+
|
|
9
|
+
It is the control `<Madlib>` renders for each blank, and it is exported on
|
|
10
|
+
its own for a lone blank in a heading ("Practices in [Spain]").
|
|
11
|
+
|
|
12
|
+
## Theming
|
|
13
|
+
Reads the madlib token family, with fallbacks, so it renders standalone:
|
|
14
|
+
|
|
15
|
+
| property | role |
|
|
16
|
+
|--------------------------------|----------------------------------------|
|
|
17
|
+
| `--vit-madlib-control-weight` | weight of the chosen label |
|
|
18
|
+
| `--vit-madlib-muted-color` | the unchosen options |
|
|
19
|
+
| `--vit-madlib-select-accent` | the underline while open or hovered |
|
|
20
|
+
| `--vit-madlib-menu-bg` | the listbox surface |
|
|
21
|
+
| `--vit-madlib-menu-hover` | the option under the pointer / chosen |
|
|
22
|
+
| `--vit-madlib-menu-shadow` | the listbox shadow |
|
|
23
|
+
| `--vit-madlib-menu-max-height` | the listbox scroll height |
|
|
24
|
+
| `--vit-madlib-menu-z` | the listbox stacking order |
|
|
25
|
+
|
|
26
|
+
@property value - The chosen option's value
|
|
27
|
+
@property options - The options offered, in order
|
|
28
|
+
@property onchange - Fired with the new value when an option is picked
|
|
29
|
+
@property onopen - Fired when the listbox is opened, before it shows
|
|
30
|
+
@property label - Accessible name for the control, when the surrounding text does not give one
|
|
31
|
+
@property class - Extra classes appended to the wrapper
|
|
32
|
+
-->
|
|
33
|
+
<script lang="ts" module>
|
|
34
|
+
/** One option row. `value` is what the control carries: a string, always. */
|
|
35
|
+
export interface InlineOption {
|
|
36
|
+
value: string;
|
|
37
|
+
label: string;
|
|
38
|
+
disabled?: boolean;
|
|
39
|
+
}
|
|
40
|
+
</script>
|
|
41
|
+
|
|
42
|
+
<script lang="ts">
|
|
43
|
+
import { fly } from 'svelte/transition';
|
|
44
|
+
|
|
45
|
+
let {
|
|
46
|
+
value,
|
|
47
|
+
options,
|
|
48
|
+
onchange,
|
|
49
|
+
onopen,
|
|
50
|
+
label,
|
|
51
|
+
class: className = ''
|
|
52
|
+
}: {
|
|
53
|
+
value: string;
|
|
54
|
+
options: readonly InlineOption[];
|
|
55
|
+
onchange?: (value: string) => void;
|
|
56
|
+
onopen?: () => void;
|
|
57
|
+
label?: string;
|
|
58
|
+
class?: string;
|
|
59
|
+
} = $props();
|
|
60
|
+
|
|
61
|
+
let open = $state(false);
|
|
62
|
+
let wrapper: HTMLElement | undefined = $state();
|
|
63
|
+
|
|
64
|
+
const chosen = $derived(options.find((option) => option.value === value)?.label ?? '');
|
|
65
|
+
|
|
66
|
+
function toggle() {
|
|
67
|
+
if (!open) onopen?.();
|
|
68
|
+
open = !open;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function pick(option: InlineOption) {
|
|
72
|
+
if (option.disabled) return;
|
|
73
|
+
open = false;
|
|
74
|
+
onchange?.(option.value);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function onkeydown(event: KeyboardEvent) {
|
|
78
|
+
if (event.key === 'Escape') {
|
|
79
|
+
open = false;
|
|
80
|
+
} else if (event.key === 'ArrowDown' && !open) {
|
|
81
|
+
event.preventDefault();
|
|
82
|
+
toggle();
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Any click that lands outside the control closes it. */
|
|
87
|
+
function onwindowclick(event: MouseEvent) {
|
|
88
|
+
if (open && wrapper && !wrapper.contains(event.target as Node)) open = false;
|
|
89
|
+
}
|
|
90
|
+
</script>
|
|
91
|
+
|
|
92
|
+
<svelte:window onclick={onwindowclick} />
|
|
93
|
+
|
|
94
|
+
<span bind:this={wrapper} class="vit-inline-select {className}">
|
|
95
|
+
<button
|
|
96
|
+
type="button"
|
|
97
|
+
class="vit-inline-select__trigger"
|
|
98
|
+
class:vit-inline-select__trigger--open={open}
|
|
99
|
+
aria-haspopup="listbox"
|
|
100
|
+
aria-expanded={open}
|
|
101
|
+
aria-label={label}
|
|
102
|
+
onclick={toggle}
|
|
103
|
+
{onkeydown}
|
|
104
|
+
>
|
|
105
|
+
<!-- The trigger is as wide as the CHOSEN label only, so the sentence
|
|
106
|
+
reflows as the value changes. The menu carries the widest option. -->
|
|
107
|
+
<span class="vit-inline-select__value">{chosen}</span>
|
|
108
|
+
<svg class="vit-inline-select__caret" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
|
109
|
+
<path
|
|
110
|
+
d="M2.5 4.5L6 8L9.5 4.5"
|
|
111
|
+
stroke="currentColor"
|
|
112
|
+
stroke-width="1.5"
|
|
113
|
+
stroke-linecap="round"
|
|
114
|
+
stroke-linejoin="round"
|
|
115
|
+
/>
|
|
116
|
+
</svg>
|
|
117
|
+
</button>
|
|
118
|
+
|
|
119
|
+
{#if open}
|
|
120
|
+
<ul class="vit-inline-select__menu" role="listbox" transition:fly={{ y: -8, duration: 150 }}>
|
|
121
|
+
{#each options as option (option.value)}
|
|
122
|
+
<li
|
|
123
|
+
role="option"
|
|
124
|
+
aria-selected={option.value === value}
|
|
125
|
+
aria-disabled={option.disabled || undefined}
|
|
126
|
+
class="vit-inline-select__option"
|
|
127
|
+
class:vit-inline-select__option--chosen={option.value === value}
|
|
128
|
+
class:vit-inline-select__option--disabled={option.disabled}
|
|
129
|
+
tabindex={option.disabled ? -1 : 0}
|
|
130
|
+
onclick={() => pick(option)}
|
|
131
|
+
onkeydown={(event) => event.key === 'Enter' && pick(option)}
|
|
132
|
+
>
|
|
133
|
+
{option.label}
|
|
134
|
+
</li>
|
|
135
|
+
{/each}
|
|
136
|
+
</ul>
|
|
137
|
+
{/if}
|
|
138
|
+
</span>
|
|
139
|
+
|
|
140
|
+
<style>
|
|
141
|
+
.vit-inline-select {
|
|
142
|
+
position: relative;
|
|
143
|
+
display: inline-block;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
.vit-inline-select__trigger {
|
|
147
|
+
display: inline;
|
|
148
|
+
padding: 0 0 0.1em;
|
|
149
|
+
margin: 0;
|
|
150
|
+
border: 0;
|
|
151
|
+
border-bottom: 2px solid currentColor;
|
|
152
|
+
background: transparent;
|
|
153
|
+
color: inherit;
|
|
154
|
+
font: inherit;
|
|
155
|
+
font-weight: var(--vit-madlib-control-weight, 700);
|
|
156
|
+
cursor: pointer;
|
|
157
|
+
transition: border-color 200ms;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
.vit-inline-select__trigger:hover,
|
|
161
|
+
.vit-inline-select__trigger--open {
|
|
162
|
+
border-bottom-color: var(--vit-madlib-select-accent, currentColor);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
.vit-inline-select__value {
|
|
166
|
+
white-space: nowrap;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
.vit-inline-select__caret {
|
|
170
|
+
display: inline;
|
|
171
|
+
width: 0.5em;
|
|
172
|
+
height: 0.5em;
|
|
173
|
+
margin-left: 0.125em;
|
|
174
|
+
flex-shrink: 0;
|
|
175
|
+
transition: transform 200ms;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
.vit-inline-select__trigger--open .vit-inline-select__caret {
|
|
179
|
+
transform: rotate(180deg);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
.vit-inline-select__menu {
|
|
183
|
+
position: absolute;
|
|
184
|
+
left: 0;
|
|
185
|
+
z-index: var(--vit-madlib-menu-z, 40);
|
|
186
|
+
margin: 0.25em 0 0;
|
|
187
|
+
padding: 0.25em 0;
|
|
188
|
+
list-style: none;
|
|
189
|
+
width: max-content;
|
|
190
|
+
max-width: 42rem;
|
|
191
|
+
max-height: var(--vit-madlib-menu-max-height, 15rem);
|
|
192
|
+
overflow: auto;
|
|
193
|
+
background: var(--vit-madlib-menu-bg, #ffffff);
|
|
194
|
+
box-shadow: var(--vit-madlib-menu-shadow, none);
|
|
195
|
+
font: inherit;
|
|
196
|
+
scrollbar-width: thin;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
.vit-inline-select__option {
|
|
200
|
+
padding: 0.375em 1em;
|
|
201
|
+
font-weight: var(--vit-madlib-control-weight, 700);
|
|
202
|
+
color: var(--vit-madlib-muted-color, color-mix(in srgb, currentColor 35%, transparent));
|
|
203
|
+
cursor: pointer;
|
|
204
|
+
transition: background-color 150ms;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
.vit-inline-select__option:hover,
|
|
208
|
+
.vit-inline-select__option:focus-visible {
|
|
209
|
+
background: var(--vit-madlib-menu-hover, color-mix(in srgb, currentColor 10%, transparent));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
.vit-inline-select__option--chosen {
|
|
213
|
+
color: inherit;
|
|
214
|
+
background: var(--vit-madlib-menu-hover, color-mix(in srgb, currentColor 10%, transparent));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
.vit-inline-select__option--disabled {
|
|
218
|
+
opacity: 0.4;
|
|
219
|
+
pointer-events: none;
|
|
220
|
+
}
|
|
221
|
+
</style>
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/** One option row. `value` is what the control carries: a string, always. */
|
|
2
|
+
export interface InlineOption {
|
|
3
|
+
value: string;
|
|
4
|
+
label: string;
|
|
5
|
+
disabled?: boolean;
|
|
6
|
+
}
|
|
7
|
+
type $$ComponentProps = {
|
|
8
|
+
value: string;
|
|
9
|
+
options: readonly InlineOption[];
|
|
10
|
+
onchange?: (value: string) => void;
|
|
11
|
+
onopen?: () => void;
|
|
12
|
+
label?: string;
|
|
13
|
+
class?: string;
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* InlineSelect
|
|
17
|
+
*
|
|
18
|
+
* A select that sits inside running text: the chosen label, underlined, with a
|
|
19
|
+
* small caret, and a listbox that drops below it. It inherits the font, size
|
|
20
|
+
* and line-height of the text around it, which is the whole point — a native
|
|
21
|
+
* `<select>` cannot be made to read as a word in a sentence.
|
|
22
|
+
*
|
|
23
|
+
* It is the control `<Madlib>` renders for each blank, and it is exported on
|
|
24
|
+
* its own for a lone blank in a heading ("Practices in [Spain]").
|
|
25
|
+
*
|
|
26
|
+
* ## Theming
|
|
27
|
+
* Reads the madlib token family, with fallbacks, so it renders standalone:
|
|
28
|
+
*
|
|
29
|
+
* | property | role |
|
|
30
|
+
* |--------------------------------|----------------------------------------|
|
|
31
|
+
* | `--vit-madlib-control-weight` | weight of the chosen label |
|
|
32
|
+
* | `--vit-madlib-muted-color` | the unchosen options |
|
|
33
|
+
* | `--vit-madlib-select-accent` | the underline while open or hovered |
|
|
34
|
+
* | `--vit-madlib-menu-bg` | the listbox surface |
|
|
35
|
+
* | `--vit-madlib-menu-hover` | the option under the pointer / chosen |
|
|
36
|
+
* | `--vit-madlib-menu-shadow` | the listbox shadow |
|
|
37
|
+
* | `--vit-madlib-menu-max-height` | the listbox scroll height |
|
|
38
|
+
* | `--vit-madlib-menu-z` | the listbox stacking order |
|
|
39
|
+
*
|
|
40
|
+
* @property value - The chosen option's value
|
|
41
|
+
* @property options - The options offered, in order
|
|
42
|
+
* @property onchange - Fired with the new value when an option is picked
|
|
43
|
+
* @property onopen - Fired when the listbox is opened, before it shows
|
|
44
|
+
* @property label - Accessible name for the control, when the surrounding text does not give one
|
|
45
|
+
* @property class - Extra classes appended to the wrapper
|
|
46
|
+
*/
|
|
47
|
+
declare const InlineSelect: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
48
|
+
type InlineSelect = ReturnType<typeof InlineSelect>;
|
|
49
|
+
export default InlineSelect;
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component Madlib
|
|
3
|
+
|
|
4
|
+
A sentence with blanks, each blank a control: "Show me [open positions] in
|
|
5
|
+
[Barcelona] over the last [12 months]". The blanks are the levels of a
|
|
6
|
+
{@link ./sentenceTree} — what a level offers depends on what was chosen
|
|
7
|
+
above it — and the value is the **path** of chosen ids, root to leaf.
|
|
8
|
+
|
|
9
|
+
The component owns nothing but the projection. `walkTree` turns the path into
|
|
10
|
+
one level per blank; `replaceAt` turns a pick into the next full path,
|
|
11
|
+
keeping the deeper choices that still apply; the host receives that path in
|
|
12
|
+
`onchange` and decides what it means. It never holds a copy of the path, so
|
|
13
|
+
a host that rejects or rewrites a change simply does not pass it back.
|
|
14
|
+
|
|
15
|
+
## Controls
|
|
16
|
+
Every blank is an {@link ./InlineSelect} unless `controls` says otherwise. A
|
|
17
|
+
level rendered as a `toggle` lists its options in the text — active one bold
|
|
18
|
+
and underlined, the others muted, separated by bars — and takes a line of its
|
|
19
|
+
own together with the text before it, so a two-way branch can read as a
|
|
20
|
+
headline over the rest of the sentence:
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
Farming in the present | future
|
|
24
|
+
Show me where [all crops] are grown, by [area]
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Theming
|
|
28
|
+
CSS custom properties with plain fallbacks; the font, size and weight of the
|
|
29
|
+
text around each control are inherited by the control:
|
|
30
|
+
|
|
31
|
+
| property | role |
|
|
32
|
+
|---------------------------------|---------------------------------------------|
|
|
33
|
+
| `--vit-madlib-font` | the sentence's font family |
|
|
34
|
+
| `--vit-madlib-color` | the sentence's colour |
|
|
35
|
+
| `--vit-madlib-lead-size` | font size of a toggle's line |
|
|
36
|
+
| `--vit-madlib-lead-line-height` | line height of a toggle's line |
|
|
37
|
+
| `--vit-madlib-size` | font size of the dropdown lines |
|
|
38
|
+
| `--vit-madlib-line-height` | line height of the dropdown lines |
|
|
39
|
+
| `--vit-madlib-weight` | weight of the running text |
|
|
40
|
+
| `--vit-madlib-control-weight` | weight of a chosen option |
|
|
41
|
+
| `--vit-madlib-muted-color` | unchosen toggle options, bars, menu options |
|
|
42
|
+
| `--vit-madlib-accent` | the active toggle option's underline |
|
|
43
|
+
|
|
44
|
+
The `InlineSelect` tokens (`--vit-madlib-select-accent`, `--vit-madlib-menu-*`)
|
|
45
|
+
apply to every dropdown in the sentence.
|
|
46
|
+
|
|
47
|
+
@property tree - The sentence tree
|
|
48
|
+
@property path - The chosen node ids, root to leaf
|
|
49
|
+
@property onchange - Fired with the next full path when any blank changes
|
|
50
|
+
@property controls - How each depth is rendered; `'dropdown'` when omitted
|
|
51
|
+
@property onopen - Fired when any control is activated, before the change (a
|
|
52
|
+
host cycling through sentences stops here)
|
|
53
|
+
@property contentWidth - Bindable: the widest rendered line, in px, remeasured on every change
|
|
54
|
+
@property class - Extra classes appended to the wrapper
|
|
55
|
+
-->
|
|
56
|
+
<script lang="ts" module>
|
|
57
|
+
import type { SentenceLevel, SentenceNode } from './sentenceTree.js';
|
|
58
|
+
|
|
59
|
+
/** How one level of the sentence is rendered. */
|
|
60
|
+
export type MadlibControl = 'dropdown' | 'toggle';
|
|
61
|
+
|
|
62
|
+
/** Picks the control for a level. Receives the depth and the resolved level. */
|
|
63
|
+
export type MadlibControls = (depth: number, level: SentenceLevel) => MadlibControl;
|
|
64
|
+
</script>
|
|
65
|
+
|
|
66
|
+
<script lang="ts">
|
|
67
|
+
import InlineSelect from './InlineSelect.svelte';
|
|
68
|
+
import { replaceAt, walkTree } from './sentenceTree.js';
|
|
69
|
+
|
|
70
|
+
let {
|
|
71
|
+
tree,
|
|
72
|
+
path,
|
|
73
|
+
onchange,
|
|
74
|
+
controls,
|
|
75
|
+
onopen,
|
|
76
|
+
contentWidth = $bindable(0),
|
|
77
|
+
class: className = ''
|
|
78
|
+
}: {
|
|
79
|
+
tree: SentenceNode;
|
|
80
|
+
path: readonly string[];
|
|
81
|
+
onchange: (path: string[]) => void;
|
|
82
|
+
controls?: MadlibControls;
|
|
83
|
+
onopen?: () => void;
|
|
84
|
+
contentWidth?: number;
|
|
85
|
+
class?: string;
|
|
86
|
+
} = $props();
|
|
87
|
+
|
|
88
|
+
/** One blank of the sentence, with the text that introduces it. */
|
|
89
|
+
interface Blank {
|
|
90
|
+
depth: number;
|
|
91
|
+
level: SentenceLevel;
|
|
92
|
+
control: MadlibControl;
|
|
93
|
+
/** The root's label for the first blank, the previous node's connector after. */
|
|
94
|
+
lead: string | undefined;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** A rendered line: a toggle stands alone on a lead line; dropdowns flow together. */
|
|
98
|
+
interface Line {
|
|
99
|
+
kind: 'lead' | 'body';
|
|
100
|
+
blanks: Blank[];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const levels = $derived(walkTree(tree, path));
|
|
104
|
+
|
|
105
|
+
const lines = $derived.by((): Line[] => {
|
|
106
|
+
const out: Line[] = [];
|
|
107
|
+
levels.forEach((level, depth) => {
|
|
108
|
+
const blank: Blank = {
|
|
109
|
+
depth,
|
|
110
|
+
level,
|
|
111
|
+
control: controls?.(depth, level) ?? 'dropdown',
|
|
112
|
+
lead: depth === 0 ? tree.label : levels[depth - 1].node.connector
|
|
113
|
+
};
|
|
114
|
+
const last = out[out.length - 1];
|
|
115
|
+
if (blank.control === 'toggle') out.push({ kind: 'lead', blanks: [blank] });
|
|
116
|
+
else if (last?.kind === 'body') last.blanks.push(blank);
|
|
117
|
+
else out.push({ kind: 'body', blanks: [blank] });
|
|
118
|
+
});
|
|
119
|
+
return out;
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* A leaf's own `connector` has no next control to introduce, so it trails
|
|
124
|
+
* the sentence rather than being dropped.
|
|
125
|
+
*/
|
|
126
|
+
const trailing = $derived.by(() => {
|
|
127
|
+
const last = levels[levels.length - 1];
|
|
128
|
+
return last && !last.node.children?.length ? last.node.connector : undefined;
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
let root: HTMLElement | undefined = $state();
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The space between a control and the text after it — none when that text
|
|
135
|
+
* opens with punctuation (", under a" hugs the control it follows).
|
|
136
|
+
* @param text - The connector or leading text about to be rendered
|
|
137
|
+
* @returns A single space, or nothing before punctuation
|
|
138
|
+
*/
|
|
139
|
+
const gap = (text: string) => (/^[,.;:!?)]/.test(text) ? '' : ' ');
|
|
140
|
+
|
|
141
|
+
function pick(depth: number, id: string) {
|
|
142
|
+
onchange(replaceAt(tree, path, depth, id));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function activate(depth: number, id: string) {
|
|
146
|
+
onopen?.();
|
|
147
|
+
pick(depth, id);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The widest rendered line: inline rects grouped by their row. A host
|
|
152
|
+
* aligning something under the sentence (a legend, a caption) wants the
|
|
153
|
+
* text's width, not the block's.
|
|
154
|
+
*/
|
|
155
|
+
function measure() {
|
|
156
|
+
if (!root) return;
|
|
157
|
+
const range = document.createRange();
|
|
158
|
+
range.selectNodeContents(root);
|
|
159
|
+
const left = root.getBoundingClientRect().left;
|
|
160
|
+
const rows: Record<number, number> = {};
|
|
161
|
+
for (const rect of range.getClientRects()) {
|
|
162
|
+
const row = Math.round(rect.top);
|
|
163
|
+
rows[row] = Math.max(rows[row] ?? 0, rect.right - left);
|
|
164
|
+
}
|
|
165
|
+
contentWidth = Math.ceil(Math.max(0, ...Object.values(rows)));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
$effect(() => {
|
|
169
|
+
void path;
|
|
170
|
+
const frame = requestAnimationFrame(measure);
|
|
171
|
+
return () => cancelAnimationFrame(frame);
|
|
172
|
+
});
|
|
173
|
+
</script>
|
|
174
|
+
|
|
175
|
+
<div bind:this={root} class="vit-madlib {className}">
|
|
176
|
+
{#if levels.length === 0}
|
|
177
|
+
<span class="vit-madlib__line vit-madlib__line--lead">{tree.label}</span>
|
|
178
|
+
{/if}
|
|
179
|
+
{#each lines as line, i (i)}
|
|
180
|
+
<span class="vit-madlib__line vit-madlib__line--{line.kind}">
|
|
181
|
+
{#each line.blanks as blank (blank.depth)}
|
|
182
|
+
{#if blank.lead}<span class="vit-madlib__text">{`${gap(blank.lead)}${blank.lead} `}</span
|
|
183
|
+
>{/if}
|
|
184
|
+
{#if blank.control === 'toggle'}
|
|
185
|
+
{#each blank.level.siblings as option, j (option.id)}
|
|
186
|
+
{#if j > 0}<span class="vit-madlib__separator" aria-hidden="true">|</span>{/if}
|
|
187
|
+
<button
|
|
188
|
+
type="button"
|
|
189
|
+
class="vit-madlib__toggle"
|
|
190
|
+
class:vit-madlib__toggle--active={option.id === blank.level.node.id}
|
|
191
|
+
aria-pressed={option.id === blank.level.node.id}
|
|
192
|
+
onclick={() => activate(blank.depth, option.id)}
|
|
193
|
+
>
|
|
194
|
+
{option.label}
|
|
195
|
+
</button>
|
|
196
|
+
{/each}
|
|
197
|
+
{:else}
|
|
198
|
+
<InlineSelect
|
|
199
|
+
value={blank.level.node.id}
|
|
200
|
+
options={blank.level.siblings.map((s) => ({ value: s.id, label: s.label }))}
|
|
201
|
+
onchange={(id) => pick(blank.depth, id)}
|
|
202
|
+
{onopen}
|
|
203
|
+
/>
|
|
204
|
+
{/if}
|
|
205
|
+
{/each}
|
|
206
|
+
{#if line === lines[lines.length - 1] && trailing}
|
|
207
|
+
<span class="vit-madlib__text">{`${gap(trailing)}${trailing}`}</span>
|
|
208
|
+
{/if}
|
|
209
|
+
</span>
|
|
210
|
+
{/each}
|
|
211
|
+
</div>
|
|
212
|
+
|
|
213
|
+
<style>
|
|
214
|
+
.vit-madlib {
|
|
215
|
+
font-family: var(--vit-madlib-font, inherit);
|
|
216
|
+
color: var(--vit-madlib-color, inherit);
|
|
217
|
+
font-weight: var(--vit-madlib-weight, inherit);
|
|
218
|
+
text-wrap: balance;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
.vit-madlib__line {
|
|
222
|
+
display: block;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
.vit-madlib__line--lead {
|
|
226
|
+
font-size: var(--vit-madlib-lead-size, 1em);
|
|
227
|
+
line-height: var(--vit-madlib-lead-line-height, 1.4);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
.vit-madlib__line--body {
|
|
231
|
+
font-size: var(--vit-madlib-size, 1em);
|
|
232
|
+
line-height: var(--vit-madlib-line-height, 1.4);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
.vit-madlib__separator {
|
|
236
|
+
padding: 0 0.375em;
|
|
237
|
+
color: var(--vit-madlib-muted-color, color-mix(in srgb, currentColor 35%, transparent));
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
.vit-madlib__toggle {
|
|
241
|
+
padding: 0 0 0.125em;
|
|
242
|
+
margin: 0;
|
|
243
|
+
border: 0;
|
|
244
|
+
border-bottom: 2px solid transparent;
|
|
245
|
+
background: transparent;
|
|
246
|
+
font: inherit;
|
|
247
|
+
color: var(--vit-madlib-muted-color, color-mix(in srgb, currentColor 35%, transparent));
|
|
248
|
+
cursor: pointer;
|
|
249
|
+
transition: color 200ms;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
.vit-madlib__toggle:hover {
|
|
253
|
+
color: inherit;
|
|
254
|
+
opacity: 0.6;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
.vit-madlib__toggle--active,
|
|
258
|
+
.vit-madlib__toggle--active:hover {
|
|
259
|
+
color: inherit;
|
|
260
|
+
opacity: 1;
|
|
261
|
+
font-weight: var(--vit-madlib-control-weight, 700);
|
|
262
|
+
border-bottom-color: var(--vit-madlib-accent, currentColor);
|
|
263
|
+
}
|
|
264
|
+
</style>
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { SentenceLevel, SentenceNode } from './sentenceTree.js';
|
|
2
|
+
/** How one level of the sentence is rendered. */
|
|
3
|
+
export type MadlibControl = 'dropdown' | 'toggle';
|
|
4
|
+
/** Picks the control for a level. Receives the depth and the resolved level. */
|
|
5
|
+
export type MadlibControls = (depth: number, level: SentenceLevel) => MadlibControl;
|
|
6
|
+
type $$ComponentProps = {
|
|
7
|
+
tree: SentenceNode;
|
|
8
|
+
path: readonly string[];
|
|
9
|
+
onchange: (path: string[]) => void;
|
|
10
|
+
controls?: MadlibControls;
|
|
11
|
+
onopen?: () => void;
|
|
12
|
+
contentWidth?: number;
|
|
13
|
+
class?: string;
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Madlib
|
|
17
|
+
*
|
|
18
|
+
* A sentence with blanks, each blank a control: "Show me [open positions] in
|
|
19
|
+
* [Barcelona] over the last [12 months]". The blanks are the levels of a
|
|
20
|
+
* {@link ./sentenceTree} — what a level offers depends on what was chosen
|
|
21
|
+
* above it — and the value is the **path** of chosen ids, root to leaf.
|
|
22
|
+
*
|
|
23
|
+
* The component owns nothing but the projection. `walkTree` turns the path into
|
|
24
|
+
* one level per blank; `replaceAt` turns a pick into the next full path,
|
|
25
|
+
* keeping the deeper choices that still apply; the host receives that path in
|
|
26
|
+
* `onchange` and decides what it means. It never holds a copy of the path, so
|
|
27
|
+
* a host that rejects or rewrites a change simply does not pass it back.
|
|
28
|
+
*
|
|
29
|
+
* ## Controls
|
|
30
|
+
* Every blank is an {@link ./InlineSelect} unless `controls` says otherwise. A
|
|
31
|
+
* level rendered as a `toggle` lists its options in the text — active one bold
|
|
32
|
+
* and underlined, the others muted, separated by bars — and takes a line of its
|
|
33
|
+
* own together with the text before it, so a two-way branch can read as a
|
|
34
|
+
* headline over the rest of the sentence:
|
|
35
|
+
*
|
|
36
|
+
* ```
|
|
37
|
+
* Farming in the present | future
|
|
38
|
+
* Show me where [all crops] are grown, by [area]
|
|
39
|
+
* ```
|
|
40
|
+
*
|
|
41
|
+
* ## Theming
|
|
42
|
+
* CSS custom properties with plain fallbacks; the font, size and weight of the
|
|
43
|
+
* text around each control are inherited by the control:
|
|
44
|
+
*
|
|
45
|
+
* | property | role |
|
|
46
|
+
* |---------------------------------|---------------------------------------------|
|
|
47
|
+
* | `--vit-madlib-font` | the sentence's font family |
|
|
48
|
+
* | `--vit-madlib-color` | the sentence's colour |
|
|
49
|
+
* | `--vit-madlib-lead-size` | font size of a toggle's line |
|
|
50
|
+
* | `--vit-madlib-lead-line-height` | line height of a toggle's line |
|
|
51
|
+
* | `--vit-madlib-size` | font size of the dropdown lines |
|
|
52
|
+
* | `--vit-madlib-line-height` | line height of the dropdown lines |
|
|
53
|
+
* | `--vit-madlib-weight` | weight of the running text |
|
|
54
|
+
* | `--vit-madlib-control-weight` | weight of a chosen option |
|
|
55
|
+
* | `--vit-madlib-muted-color` | unchosen toggle options, bars, menu options |
|
|
56
|
+
* | `--vit-madlib-accent` | the active toggle option's underline |
|
|
57
|
+
*
|
|
58
|
+
* The `InlineSelect` tokens (`--vit-madlib-select-accent`, `--vit-madlib-menu-*`)
|
|
59
|
+
* apply to every dropdown in the sentence.
|
|
60
|
+
*
|
|
61
|
+
* @property tree - The sentence tree
|
|
62
|
+
* @property path - The chosen node ids, root to leaf
|
|
63
|
+
* @property onchange - Fired with the next full path when any blank changes
|
|
64
|
+
* @property controls - How each depth is rendered; `'dropdown'` when omitted
|
|
65
|
+
* @property onopen - Fired when any control is activated, before the change (a
|
|
66
|
+
* host cycling through sentences stops here)
|
|
67
|
+
* @property contentWidth - Bindable: the widest rendered line, in px, remeasured on every change
|
|
68
|
+
* @property class - Extra classes appended to the wrapper
|
|
69
|
+
*/
|
|
70
|
+
declare const Madlib: import("svelte").Component<$$ComponentProps, {}, "contentWidth">;
|
|
71
|
+
type Madlib = ReturnType<typeof Madlib>;
|
|
72
|
+
export default Madlib;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The sentence tree: the data behind a madlib, and the path arithmetic on it.
|
|
3
|
+
*
|
|
4
|
+
* A madlib is a sentence with blanks — "Show me [open positions] in
|
|
5
|
+
* [Barcelona] over the last [12 months]" — where each blank is one level of a
|
|
6
|
+
* tree and the options offered at a level are the children of what was picked
|
|
7
|
+
* at the level above. A selection is therefore a **path**: one node id per
|
|
8
|
+
* level, root to leaf. Everything the component does is arithmetic on that
|
|
9
|
+
* path, so it lives here, pure and tested, and `<Madlib>` only projects it.
|
|
10
|
+
*
|
|
11
|
+
* The tree is plain data. `label` is what a node reads as when it is the
|
|
12
|
+
* chosen option; `connector` is the text that follows it before the next
|
|
13
|
+
* blank; the root's `label` is the sentence's leading text. Nothing here
|
|
14
|
+
* knows what a path *means* — that is the host's mapping, on the way out.
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* One node of a sentence tree.
|
|
18
|
+
*
|
|
19
|
+
* @property id - Unique among its siblings; the path carries these
|
|
20
|
+
* @property label - What the option reads as, in the control and in the sentence
|
|
21
|
+
* @property connector - Text after this node's control, before the next one. On a
|
|
22
|
+
* leaf it trails the sentence instead, since there is no next control
|
|
23
|
+
* @property children - The next level's options. A node without children is a leaf
|
|
24
|
+
*/
|
|
25
|
+
export interface SentenceNode {
|
|
26
|
+
id: string;
|
|
27
|
+
label: string;
|
|
28
|
+
connector?: string;
|
|
29
|
+
children?: SentenceNode[];
|
|
30
|
+
}
|
|
31
|
+
/** One resolved level of a path: the node chosen there, and every option it was chosen from. */
|
|
32
|
+
export interface SentenceLevel {
|
|
33
|
+
node: SentenceNode;
|
|
34
|
+
siblings: SentenceNode[];
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Resolves a path against the tree, one level per id, stopping at the first
|
|
38
|
+
* id that is not a child of the level above (or at a leaf). The result is what
|
|
39
|
+
* a control per level needs: the chosen node and its siblings.
|
|
40
|
+
*
|
|
41
|
+
* @param tree - The root node
|
|
42
|
+
* @param path - Node ids, root to leaf
|
|
43
|
+
* @returns One entry per resolved level, possibly fewer than `path.length`
|
|
44
|
+
*
|
|
45
|
+
* @example
|
|
46
|
+
* walkTree(tree, ['jobs', 'bcn'])
|
|
47
|
+
* // → [{ node: jobs, siblings: [jobs, projects] }, { node: bcn, siblings: [bcn, remote] }]
|
|
48
|
+
*/
|
|
49
|
+
export declare function walkTree(tree: SentenceNode, path: readonly string[]): SentenceLevel[];
|
|
50
|
+
/**
|
|
51
|
+
* Completes a partial path down to a leaf by taking the first child at every
|
|
52
|
+
* remaining level. The prefix is kept as far as it resolves; from the first
|
|
53
|
+
* id that does not, the defaults take over. `completePath(tree, [])` is the
|
|
54
|
+
* tree's default sentence.
|
|
55
|
+
*
|
|
56
|
+
* @param tree - The root node
|
|
57
|
+
* @param partial - Leading node ids, possibly empty, possibly partly invalid
|
|
58
|
+
* @returns A full root-to-leaf path
|
|
59
|
+
*/
|
|
60
|
+
export declare function completePath(tree: SentenceNode, partial: readonly string[]): string[];
|
|
61
|
+
/**
|
|
62
|
+
* The tree's default sentence: first child at every level.
|
|
63
|
+
*
|
|
64
|
+
* @param tree - The root node
|
|
65
|
+
* @returns A full root-to-leaf path, empty for a tree with no children
|
|
66
|
+
*/
|
|
67
|
+
export declare function defaultPath(tree: SentenceNode): string[];
|
|
68
|
+
/**
|
|
69
|
+
* Changes one level of a path and re-completes everything below it.
|
|
70
|
+
*
|
|
71
|
+
* Levels above `depth` are kept. Below it, each level prefers the id the
|
|
72
|
+
* previous path had at that same depth when it is still a valid option there
|
|
73
|
+
* — so switching "maize" to "wheat" keeps "by area" — and falls back to the
|
|
74
|
+
* first child when it is not, which is what happens across a branch change or
|
|
75
|
+
* when the levels shift.
|
|
76
|
+
*
|
|
77
|
+
* @param tree - The root node
|
|
78
|
+
* @param path - The current path
|
|
79
|
+
* @param depth - Index of the level being changed
|
|
80
|
+
* @param id - The new node id at that level
|
|
81
|
+
* @returns A full root-to-leaf path
|
|
82
|
+
*/
|
|
83
|
+
export declare function replaceAt(tree: SentenceNode, path: readonly string[], depth: number, id: string): string[];
|
|
84
|
+
/**
|
|
85
|
+
* Every root-to-leaf path, depth first, in the order the tree declares them.
|
|
86
|
+
* A host uses it to cycle through all sentences, or to check that each one
|
|
87
|
+
* resolves to something.
|
|
88
|
+
*
|
|
89
|
+
* @param tree - The root node
|
|
90
|
+
* @returns All full paths; `[[]]` for a tree with no children
|
|
91
|
+
*/
|
|
92
|
+
export declare function leafPaths(tree: SentenceNode): string[][];
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The sentence tree: the data behind a madlib, and the path arithmetic on it.
|
|
3
|
+
*
|
|
4
|
+
* A madlib is a sentence with blanks — "Show me [open positions] in
|
|
5
|
+
* [Barcelona] over the last [12 months]" — where each blank is one level of a
|
|
6
|
+
* tree and the options offered at a level are the children of what was picked
|
|
7
|
+
* at the level above. A selection is therefore a **path**: one node id per
|
|
8
|
+
* level, root to leaf. Everything the component does is arithmetic on that
|
|
9
|
+
* path, so it lives here, pure and tested, and `<Madlib>` only projects it.
|
|
10
|
+
*
|
|
11
|
+
* The tree is plain data. `label` is what a node reads as when it is the
|
|
12
|
+
* chosen option; `connector` is the text that follows it before the next
|
|
13
|
+
* blank; the root's `label` is the sentence's leading text. Nothing here
|
|
14
|
+
* knows what a path *means* — that is the host's mapping, on the way out.
|
|
15
|
+
*/
|
|
16
|
+
/** The child of `node` with this id, or undefined — a leaf has none. */
|
|
17
|
+
function childOf(node, id) {
|
|
18
|
+
return node.children?.find((child) => child.id === id);
|
|
19
|
+
}
|
|
20
|
+
/** Whether `node` still has a level below it. */
|
|
21
|
+
function hasChildren(node) {
|
|
22
|
+
return node.children !== undefined && node.children.length > 0;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Resolves a path against the tree, one level per id, stopping at the first
|
|
26
|
+
* id that is not a child of the level above (or at a leaf). The result is what
|
|
27
|
+
* a control per level needs: the chosen node and its siblings.
|
|
28
|
+
*
|
|
29
|
+
* @param tree - The root node
|
|
30
|
+
* @param path - Node ids, root to leaf
|
|
31
|
+
* @returns One entry per resolved level, possibly fewer than `path.length`
|
|
32
|
+
*
|
|
33
|
+
* @example
|
|
34
|
+
* walkTree(tree, ['jobs', 'bcn'])
|
|
35
|
+
* // → [{ node: jobs, siblings: [jobs, projects] }, { node: bcn, siblings: [bcn, remote] }]
|
|
36
|
+
*/
|
|
37
|
+
export function walkTree(tree, path) {
|
|
38
|
+
const levels = [];
|
|
39
|
+
let current = tree;
|
|
40
|
+
for (const id of path) {
|
|
41
|
+
if (!current.children)
|
|
42
|
+
break;
|
|
43
|
+
const match = childOf(current, id);
|
|
44
|
+
if (!match)
|
|
45
|
+
break;
|
|
46
|
+
levels.push({ node: match, siblings: current.children });
|
|
47
|
+
current = match;
|
|
48
|
+
}
|
|
49
|
+
return levels;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Completes a partial path down to a leaf by taking the first child at every
|
|
53
|
+
* remaining level. The prefix is kept as far as it resolves; from the first
|
|
54
|
+
* id that does not, the defaults take over. `completePath(tree, [])` is the
|
|
55
|
+
* tree's default sentence.
|
|
56
|
+
*
|
|
57
|
+
* @param tree - The root node
|
|
58
|
+
* @param partial - Leading node ids, possibly empty, possibly partly invalid
|
|
59
|
+
* @returns A full root-to-leaf path
|
|
60
|
+
*/
|
|
61
|
+
export function completePath(tree, partial) {
|
|
62
|
+
const path = [];
|
|
63
|
+
let current = tree;
|
|
64
|
+
for (const id of partial) {
|
|
65
|
+
const match = childOf(current, id);
|
|
66
|
+
if (!match)
|
|
67
|
+
break;
|
|
68
|
+
path.push(id);
|
|
69
|
+
current = match;
|
|
70
|
+
}
|
|
71
|
+
while (hasChildren(current)) {
|
|
72
|
+
const first = current.children[0];
|
|
73
|
+
path.push(first.id);
|
|
74
|
+
current = first;
|
|
75
|
+
}
|
|
76
|
+
return path;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* The tree's default sentence: first child at every level.
|
|
80
|
+
*
|
|
81
|
+
* @param tree - The root node
|
|
82
|
+
* @returns A full root-to-leaf path, empty for a tree with no children
|
|
83
|
+
*/
|
|
84
|
+
export function defaultPath(tree) {
|
|
85
|
+
return completePath(tree, []);
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Changes one level of a path and re-completes everything below it.
|
|
89
|
+
*
|
|
90
|
+
* Levels above `depth` are kept. Below it, each level prefers the id the
|
|
91
|
+
* previous path had at that same depth when it is still a valid option there
|
|
92
|
+
* — so switching "maize" to "wheat" keeps "by area" — and falls back to the
|
|
93
|
+
* first child when it is not, which is what happens across a branch change or
|
|
94
|
+
* when the levels shift.
|
|
95
|
+
*
|
|
96
|
+
* @param tree - The root node
|
|
97
|
+
* @param path - The current path
|
|
98
|
+
* @param depth - Index of the level being changed
|
|
99
|
+
* @param id - The new node id at that level
|
|
100
|
+
* @returns A full root-to-leaf path
|
|
101
|
+
*/
|
|
102
|
+
export function replaceAt(tree, path, depth, id) {
|
|
103
|
+
const next = [...path.slice(0, depth), id];
|
|
104
|
+
let current = tree;
|
|
105
|
+
for (const step of next) {
|
|
106
|
+
const match = childOf(current, step);
|
|
107
|
+
if (!match)
|
|
108
|
+
break;
|
|
109
|
+
current = match;
|
|
110
|
+
}
|
|
111
|
+
while (hasChildren(current)) {
|
|
112
|
+
const previous = path[next.length];
|
|
113
|
+
const kept = previous === undefined ? undefined : childOf(current, previous);
|
|
114
|
+
const chosen = kept ?? current.children[0];
|
|
115
|
+
next.push(chosen.id);
|
|
116
|
+
current = chosen;
|
|
117
|
+
}
|
|
118
|
+
return next;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Every root-to-leaf path, depth first, in the order the tree declares them.
|
|
122
|
+
* A host uses it to cycle through all sentences, or to check that each one
|
|
123
|
+
* resolves to something.
|
|
124
|
+
*
|
|
125
|
+
* @param tree - The root node
|
|
126
|
+
* @returns All full paths; `[[]]` for a tree with no children
|
|
127
|
+
*/
|
|
128
|
+
export function leafPaths(tree) {
|
|
129
|
+
const paths = [];
|
|
130
|
+
const visit = (node, prefix) => {
|
|
131
|
+
if (!hasChildren(node)) {
|
|
132
|
+
paths.push(prefix);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
for (const child of node.children)
|
|
136
|
+
visit(child, [...prefix, child.id]);
|
|
137
|
+
};
|
|
138
|
+
visit(tree, []);
|
|
139
|
+
return paths;
|
|
140
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component HoverCard
|
|
3
|
+
|
|
4
|
+
The chrome of a hover card: a fixed-width surface with a shimmer skeleton
|
|
5
|
+
while its content is still resolving.
|
|
6
|
+
|
|
7
|
+
It owns the *shell*, never the content — what goes inside is the host's
|
|
8
|
+
snippet. It pairs with {@link ./anchor}, which decides where it goes, and the
|
|
9
|
+
two are separate on purpose: placement is arithmetic a test can check, chrome
|
|
10
|
+
is CSS a test cannot.
|
|
11
|
+
|
|
12
|
+
## Why the shimmer is here
|
|
13
|
+
A hover card whose content arrives asynchronously flickers between empty and
|
|
14
|
+
full. Showing a skeleton for a beat makes the transition deliberate, and doing
|
|
15
|
+
it on *every* new target rather than only on slow ones keeps the rhythm
|
|
16
|
+
even — a card that sometimes shimmers and sometimes does not reads as jank.
|
|
17
|
+
Remount the component (a `{#key}` on the hovered target) and it shimmers
|
|
18
|
+
again; pass `skipShimmer` for a clone that is animating out, which should fade
|
|
19
|
+
real content rather than a fresh skeleton.
|
|
20
|
+
|
|
21
|
+
## Theming
|
|
22
|
+
Presentation is CSS custom properties with plain fallbacks, so the card
|
|
23
|
+
renders standalone and restyles without a CSS framework:
|
|
24
|
+
|
|
25
|
+
| property | default |
|
|
26
|
+
|----------------------------|-----------|
|
|
27
|
+
| `--vit-card-font` | `inherit` |
|
|
28
|
+
| `--vit-card-width` | `277px` |
|
|
29
|
+
| `--vit-card-padding` | `10px` |
|
|
30
|
+
| `--vit-card-gap` | `10px` |
|
|
31
|
+
| `--vit-card-bg` | `#ffffff` |
|
|
32
|
+
| `--vit-card-radius` | `0` |
|
|
33
|
+
| `--vit-card-shadow` | `none` |
|
|
34
|
+
| `--vit-card-shimmer-color` | `currentColor` |
|
|
35
|
+
|
|
36
|
+
@property skipShimmer - Suppresses the mount shimmer, for a card fading out
|
|
37
|
+
@property shimmerMs - How long the skeleton is held, in ms
|
|
38
|
+
@property lines - Skeleton rows drawn under the heading block
|
|
39
|
+
@property class - Extra classes appended to the card
|
|
40
|
+
@property children - The card's content
|
|
41
|
+
-->
|
|
42
|
+
<script lang="ts">
|
|
43
|
+
import { onMount, type Snippet } from 'svelte';
|
|
44
|
+
|
|
45
|
+
let {
|
|
46
|
+
skipShimmer = false,
|
|
47
|
+
shimmerMs = 150,
|
|
48
|
+
lines = 3,
|
|
49
|
+
class: className = '',
|
|
50
|
+
children
|
|
51
|
+
}: {
|
|
52
|
+
skipShimmer?: boolean;
|
|
53
|
+
shimmerMs?: number;
|
|
54
|
+
lines?: number;
|
|
55
|
+
class?: string;
|
|
56
|
+
children: Snippet;
|
|
57
|
+
} = $props();
|
|
58
|
+
|
|
59
|
+
// Fixed per mount — the leaving clone must never re-shimmer.
|
|
60
|
+
// svelte-ignore state_referenced_locally
|
|
61
|
+
let shimmer = $state(!skipShimmer);
|
|
62
|
+
|
|
63
|
+
onMount(() => {
|
|
64
|
+
if (!shimmer) return;
|
|
65
|
+
const t = setTimeout(() => (shimmer = false), shimmerMs);
|
|
66
|
+
return () => clearTimeout(t);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
/** Descending widths, so the skeleton reads as text rather than as bars. */
|
|
70
|
+
const widths = $derived(Array.from({ length: Math.max(0, lines) }, (_, i) => `${100 - i * 25}%`));
|
|
71
|
+
</script>
|
|
72
|
+
|
|
73
|
+
<div class="vit-card {className}">
|
|
74
|
+
{#if shimmer}
|
|
75
|
+
<div class="vit-card__skeleton">
|
|
76
|
+
<span class="vit-card__line" style="height: 12px; width: 100%"></span>
|
|
77
|
+
<span class="vit-card__line" style="height: 8px; width: 5rem"></span>
|
|
78
|
+
<span class="vit-card__rows">
|
|
79
|
+
{#each widths as w, i (i)}
|
|
80
|
+
<span class="vit-card__line" style="height: 17px; width: {w}"></span>
|
|
81
|
+
{/each}
|
|
82
|
+
</span>
|
|
83
|
+
</div>
|
|
84
|
+
{:else}
|
|
85
|
+
{@render children()}
|
|
86
|
+
{/if}
|
|
87
|
+
</div>
|
|
88
|
+
|
|
89
|
+
<style>
|
|
90
|
+
.vit-card {
|
|
91
|
+
display: flex;
|
|
92
|
+
flex-direction: column;
|
|
93
|
+
gap: var(--vit-card-gap, 10px);
|
|
94
|
+
width: var(--vit-card-width, 277px);
|
|
95
|
+
padding: var(--vit-card-padding, 10px);
|
|
96
|
+
overflow: hidden;
|
|
97
|
+
background: var(--vit-card-bg, #ffffff);
|
|
98
|
+
border-radius: var(--vit-card-radius, 0);
|
|
99
|
+
box-shadow: var(--vit-card-shadow, none);
|
|
100
|
+
font-family: var(--vit-card-font, inherit);
|
|
101
|
+
}
|
|
102
|
+
.vit-card__skeleton {
|
|
103
|
+
display: flex;
|
|
104
|
+
flex-direction: column;
|
|
105
|
+
gap: 6px;
|
|
106
|
+
}
|
|
107
|
+
.vit-card__rows {
|
|
108
|
+
display: flex;
|
|
109
|
+
flex-direction: column;
|
|
110
|
+
gap: 4px;
|
|
111
|
+
margin-top: 8px;
|
|
112
|
+
}
|
|
113
|
+
.vit-card__line {
|
|
114
|
+
display: block;
|
|
115
|
+
border-radius: 2px;
|
|
116
|
+
background: var(--vit-card-shimmer-color, currentColor);
|
|
117
|
+
animation: vit-card-shimmer 400ms ease-in-out infinite;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
@keyframes vit-card-shimmer {
|
|
121
|
+
0% {
|
|
122
|
+
opacity: 0.3;
|
|
123
|
+
}
|
|
124
|
+
50% {
|
|
125
|
+
opacity: 0.6;
|
|
126
|
+
}
|
|
127
|
+
100% {
|
|
128
|
+
opacity: 0.3;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
@media (prefers-reduced-motion: reduce) {
|
|
133
|
+
.vit-card__line {
|
|
134
|
+
animation: none;
|
|
135
|
+
opacity: 0.3;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
</style>
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { type Snippet } from 'svelte';
|
|
2
|
+
type $$ComponentProps = {
|
|
3
|
+
skipShimmer?: boolean;
|
|
4
|
+
shimmerMs?: number;
|
|
5
|
+
lines?: number;
|
|
6
|
+
class?: string;
|
|
7
|
+
children: Snippet;
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* HoverCard
|
|
11
|
+
*
|
|
12
|
+
* The chrome of a hover card: a fixed-width surface with a shimmer skeleton
|
|
13
|
+
* while its content is still resolving.
|
|
14
|
+
*
|
|
15
|
+
* It owns the *shell*, never the content — what goes inside is the host's
|
|
16
|
+
* snippet. It pairs with {@link ./anchor}, which decides where it goes, and the
|
|
17
|
+
* two are separate on purpose: placement is arithmetic a test can check, chrome
|
|
18
|
+
* is CSS a test cannot.
|
|
19
|
+
*
|
|
20
|
+
* ## Why the shimmer is here
|
|
21
|
+
* A hover card whose content arrives asynchronously flickers between empty and
|
|
22
|
+
* full. Showing a skeleton for a beat makes the transition deliberate, and doing
|
|
23
|
+
* it on *every* new target rather than only on slow ones keeps the rhythm
|
|
24
|
+
* even — a card that sometimes shimmers and sometimes does not reads as jank.
|
|
25
|
+
* Remount the component (a `{#key}` on the hovered target) and it shimmers
|
|
26
|
+
* again; pass `skipShimmer` for a clone that is animating out, which should fade
|
|
27
|
+
* real content rather than a fresh skeleton.
|
|
28
|
+
*
|
|
29
|
+
* ## Theming
|
|
30
|
+
* Presentation is CSS custom properties with plain fallbacks, so the card
|
|
31
|
+
* renders standalone and restyles without a CSS framework:
|
|
32
|
+
*
|
|
33
|
+
* | property | default |
|
|
34
|
+
* |----------------------------|-----------|
|
|
35
|
+
* | `--vit-card-font` | `inherit` |
|
|
36
|
+
* | `--vit-card-width` | `277px` |
|
|
37
|
+
* | `--vit-card-padding` | `10px` |
|
|
38
|
+
* | `--vit-card-gap` | `10px` |
|
|
39
|
+
* | `--vit-card-bg` | `#ffffff` |
|
|
40
|
+
* | `--vit-card-radius` | `0` |
|
|
41
|
+
* | `--vit-card-shadow` | `none` |
|
|
42
|
+
* | `--vit-card-shimmer-color` | `currentColor` |
|
|
43
|
+
*
|
|
44
|
+
* @property skipShimmer - Suppresses the mount shimmer, for a card fading out
|
|
45
|
+
* @property shimmerMs - How long the skeleton is held, in ms
|
|
46
|
+
* @property lines - Skeleton rows drawn under the heading block
|
|
47
|
+
* @property class - Extra classes appended to the card
|
|
48
|
+
* @property children - The card's content
|
|
49
|
+
*/
|
|
50
|
+
declare const HoverCard: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
51
|
+
type HoverCard = ReturnType<typeof HoverCard>;
|
|
52
|
+
export default HoverCard;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module components/overlay/anchor
|
|
3
|
+
* Where a floating card goes when it is attached to a point.
|
|
4
|
+
*
|
|
5
|
+
* "Put a card next to the pointer without letting it leave the container" is a
|
|
6
|
+
* rule every hover tooltip needs and nobody writes down. It gets re-derived at
|
|
7
|
+
* each call site, slightly differently each time, and because it only runs
|
|
8
|
+
* inside a live layout it is never tested — so the version that forgot to clamp
|
|
9
|
+
* vertically ships a card that falls off the bottom of a short viewport, and
|
|
10
|
+
* nobody notices until a phone does it.
|
|
11
|
+
*
|
|
12
|
+
* It is one function here, it takes measurements rather than elements, and it
|
|
13
|
+
* returns two numbers. No DOM, no framework, no CSS: the caller owns all three.
|
|
14
|
+
*/
|
|
15
|
+
/** The point the card is attached to, in container coordinates. */
|
|
16
|
+
export type AnchorPoint = {
|
|
17
|
+
/** Distance from the container's left edge, in px. */
|
|
18
|
+
x: number;
|
|
19
|
+
/** Distance from the container's top edge, in px. */
|
|
20
|
+
y: number;
|
|
21
|
+
};
|
|
22
|
+
/** A measured rectangle, in px. */
|
|
23
|
+
export type AnchorBox = {
|
|
24
|
+
/** Width in px. */
|
|
25
|
+
width: number;
|
|
26
|
+
/** Height in px. */
|
|
27
|
+
height: number;
|
|
28
|
+
};
|
|
29
|
+
/** Space to keep between the card and each container edge, in px. */
|
|
30
|
+
export type AnchorPadding = {
|
|
31
|
+
/** Minimum gap above the card. */
|
|
32
|
+
top?: number;
|
|
33
|
+
/** Minimum gap to the right of the card. */
|
|
34
|
+
right?: number;
|
|
35
|
+
/** Minimum gap below the card. */
|
|
36
|
+
bottom?: number;
|
|
37
|
+
/** Minimum gap to the left of the card. */
|
|
38
|
+
left?: number;
|
|
39
|
+
};
|
|
40
|
+
/** How the card is placed relative to the point, before clamping. */
|
|
41
|
+
export type AnchorOptions = {
|
|
42
|
+
/**
|
|
43
|
+
* Gap between the point and the card's near edge, in px. `x` is the
|
|
44
|
+
* horizontal offset to whichever side the card lands on; `y` shifts the
|
|
45
|
+
* card vertically and is only read when `align` is `'start'`.
|
|
46
|
+
*/
|
|
47
|
+
offset?: {
|
|
48
|
+
x?: number;
|
|
49
|
+
y?: number;
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* Vertical relationship to the point: `'center'` centres the card on it,
|
|
53
|
+
* `'start'` puts the card's top edge there (plus `offset.y`).
|
|
54
|
+
*/
|
|
55
|
+
align?: 'center' | 'start';
|
|
56
|
+
/** Minimum gaps from the container edges. */
|
|
57
|
+
padding?: AnchorPadding;
|
|
58
|
+
/**
|
|
59
|
+
* When the card does not fit on its preferred side, put it on the other one
|
|
60
|
+
* instead of letting the clamp slide it back over the point. Leave it off
|
|
61
|
+
* for a card that should simply stay put.
|
|
62
|
+
*/
|
|
63
|
+
flip?: boolean;
|
|
64
|
+
/** Preferred horizontal side. Default `'right'`. */
|
|
65
|
+
side?: 'right' | 'left';
|
|
66
|
+
};
|
|
67
|
+
/** The resolved position, ready to write to `left` / `top` in px. */
|
|
68
|
+
export type AnchorPosition = {
|
|
69
|
+
/** Distance from the container's left edge, in px. */
|
|
70
|
+
left: number;
|
|
71
|
+
/** Distance from the container's top edge, in px. */
|
|
72
|
+
top: number;
|
|
73
|
+
};
|
|
74
|
+
/**
|
|
75
|
+
* Places a card beside a point, kept inside its container.
|
|
76
|
+
*
|
|
77
|
+
* The card is laid out on its preferred side, optionally flipped to the other
|
|
78
|
+
* side when it would not fit, and then clamped on **both** axes so it can never
|
|
79
|
+
* cross the container's padding — including vertically, which is the half most
|
|
80
|
+
* hand-rolled versions leave out.
|
|
81
|
+
*
|
|
82
|
+
* A container with no measured size yet (width or height `0`) returns the raw
|
|
83
|
+
* point, so a first frame renders somewhere sensible rather than at `0,0`.
|
|
84
|
+
*
|
|
85
|
+
* @param point - Where the card is attached, in container coordinates.
|
|
86
|
+
* @param card - The card's measured size. Height may be an estimate on the
|
|
87
|
+
* first frame; re-running once it is measured is cheap and exact.
|
|
88
|
+
* @param container - The box the card must stay inside.
|
|
89
|
+
* @param options - Offset, alignment, padding, flipping and preferred side.
|
|
90
|
+
* @returns The `left` / `top` to position the card at, in px.
|
|
91
|
+
*/
|
|
92
|
+
export declare function anchor(point: AnchorPoint, card: AnchorBox, container: AnchorBox, options?: AnchorOptions): AnchorPosition;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module components/overlay/anchor
|
|
3
|
+
* Where a floating card goes when it is attached to a point.
|
|
4
|
+
*
|
|
5
|
+
* "Put a card next to the pointer without letting it leave the container" is a
|
|
6
|
+
* rule every hover tooltip needs and nobody writes down. It gets re-derived at
|
|
7
|
+
* each call site, slightly differently each time, and because it only runs
|
|
8
|
+
* inside a live layout it is never tested — so the version that forgot to clamp
|
|
9
|
+
* vertically ships a card that falls off the bottom of a short viewport, and
|
|
10
|
+
* nobody notices until a phone does it.
|
|
11
|
+
*
|
|
12
|
+
* It is one function here, it takes measurements rather than elements, and it
|
|
13
|
+
* returns two numbers. No DOM, no framework, no CSS: the caller owns all three.
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* Places a card beside a point, kept inside its container.
|
|
17
|
+
*
|
|
18
|
+
* The card is laid out on its preferred side, optionally flipped to the other
|
|
19
|
+
* side when it would not fit, and then clamped on **both** axes so it can never
|
|
20
|
+
* cross the container's padding — including vertically, which is the half most
|
|
21
|
+
* hand-rolled versions leave out.
|
|
22
|
+
*
|
|
23
|
+
* A container with no measured size yet (width or height `0`) returns the raw
|
|
24
|
+
* point, so a first frame renders somewhere sensible rather than at `0,0`.
|
|
25
|
+
*
|
|
26
|
+
* @param point - Where the card is attached, in container coordinates.
|
|
27
|
+
* @param card - The card's measured size. Height may be an estimate on the
|
|
28
|
+
* first frame; re-running once it is measured is cheap and exact.
|
|
29
|
+
* @param container - The box the card must stay inside.
|
|
30
|
+
* @param options - Offset, alignment, padding, flipping and preferred side.
|
|
31
|
+
* @returns The `left` / `top` to position the card at, in px.
|
|
32
|
+
*/
|
|
33
|
+
export function anchor(point, card, container, options = {}) {
|
|
34
|
+
if (!container.width || !container.height)
|
|
35
|
+
return { left: point.x, top: point.y };
|
|
36
|
+
const offsetX = options.offset?.x ?? 16;
|
|
37
|
+
const offsetY = options.offset?.y ?? 0;
|
|
38
|
+
const padTop = options.padding?.top ?? 0;
|
|
39
|
+
const padRight = options.padding?.right ?? 0;
|
|
40
|
+
const padBottom = options.padding?.bottom ?? 0;
|
|
41
|
+
const padLeft = options.padding?.left ?? 0;
|
|
42
|
+
const toLeftOf = point.x - offsetX - card.width;
|
|
43
|
+
const toRightOf = point.x + offsetX;
|
|
44
|
+
let left = options.side === 'left' ? toLeftOf : toRightOf;
|
|
45
|
+
if (options.flip) {
|
|
46
|
+
if (options.side === 'left') {
|
|
47
|
+
if (left < padLeft && toRightOf + card.width <= container.width - padRight)
|
|
48
|
+
left = toRightOf;
|
|
49
|
+
}
|
|
50
|
+
else if (left + card.width > container.width - padRight && toLeftOf >= padLeft) {
|
|
51
|
+
left = toLeftOf;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
let top = options.align === 'start' ? point.y + offsetY : point.y - card.height / 2;
|
|
55
|
+
// Clamp last, on both axes. `Math.max` after `Math.min` so a card taller or
|
|
56
|
+
// wider than the space it has left is pinned to the near edge rather than
|
|
57
|
+
// pushed off the far one.
|
|
58
|
+
left = Math.max(padLeft, Math.min(left, container.width - card.width - padRight));
|
|
59
|
+
top = Math.max(padTop, Math.min(top, container.height - card.height - padBottom));
|
|
60
|
+
return { left, top };
|
|
61
|
+
}
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -16,3 +16,4 @@ export * from './edit/index.js';
|
|
|
16
16
|
// server-side rules, so they go through ./contract instead — see the note in
|
|
17
17
|
// utils/paths.ts, which is where the export surface for all of them is decided.
|
|
18
18
|
export { buildQueryString } from './utils/paths.js';
|
|
19
|
+
export { anchor, HoverCard } from './overlay.js';
|
package/dist/madlib.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The madlib: a sentence with blanks, where each blank is a level of a tree
|
|
3
|
+
* and the value is the path of chosen ids. `<Madlib>` renders one; the tree
|
|
4
|
+
* helpers are the arithmetic it is a projection of, exported so a host can
|
|
5
|
+
* complete a deep link, enumerate every sentence, or drive a second control
|
|
6
|
+
* off the same tree. `<InlineSelect>` is the blank on its own, for a lone
|
|
7
|
+
* choice inside a heading.
|
|
8
|
+
*
|
|
9
|
+
* Domain-free: it knows ids, labels and connectors, and nothing about what a
|
|
10
|
+
* path means. Themed with `--vit-madlib-*` custom properties rather than
|
|
11
|
+
* utility classes, and not re-exported from the package root.
|
|
12
|
+
*/
|
|
13
|
+
export { default as Madlib } from './components/madlib/Madlib.svelte';
|
|
14
|
+
export type { MadlibControl, MadlibControls } from './components/madlib/Madlib.svelte';
|
|
15
|
+
export { default as InlineSelect } from './components/madlib/InlineSelect.svelte';
|
|
16
|
+
export type { InlineOption } from './components/madlib/InlineSelect.svelte';
|
|
17
|
+
export { completePath, defaultPath, leafPaths, replaceAt, walkTree, type SentenceLevel, type SentenceNode } from './components/madlib/sentenceTree.js';
|
package/dist/madlib.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The madlib: a sentence with blanks, where each blank is a level of a tree
|
|
3
|
+
* and the value is the path of chosen ids. `<Madlib>` renders one; the tree
|
|
4
|
+
* helpers are the arithmetic it is a projection of, exported so a host can
|
|
5
|
+
* complete a deep link, enumerate every sentence, or drive a second control
|
|
6
|
+
* off the same tree. `<InlineSelect>` is the blank on its own, for a lone
|
|
7
|
+
* choice inside a heading.
|
|
8
|
+
*
|
|
9
|
+
* Domain-free: it knows ids, labels and connectors, and nothing about what a
|
|
10
|
+
* path means. Themed with `--vit-madlib-*` custom properties rather than
|
|
11
|
+
* utility classes, and not re-exported from the package root.
|
|
12
|
+
*/
|
|
13
|
+
export { default as Madlib } from './components/madlib/Madlib.svelte';
|
|
14
|
+
export { default as InlineSelect } from './components/madlib/InlineSelect.svelte';
|
|
15
|
+
export { completePath, defaultPath, leafPaths, replaceAt, walkTree } from './components/madlib/sentenceTree.js';
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Floating-overlay primitives: where a card attached to a point goes, and the
|
|
3
|
+
* chrome it is drawn in.
|
|
4
|
+
*
|
|
5
|
+
* The two are separate because they fail differently. Placement is arithmetic —
|
|
6
|
+
* it can be wrong on a phone and right on a laptop, and it belongs in a test.
|
|
7
|
+
* Chrome is CSS, and belongs behind custom properties. Both were previously
|
|
8
|
+
* re-implemented per call site, which is how one copy ended up clamping
|
|
9
|
+
* horizontally but not vertically.
|
|
10
|
+
*
|
|
11
|
+
* Domain-free: it knows about points, boxes and padding, and nothing about maps,
|
|
12
|
+
* charts or what the card says.
|
|
13
|
+
*/
|
|
14
|
+
export { anchor, type AnchorBox, type AnchorOptions, type AnchorPadding, type AnchorPoint, type AnchorPosition } from './components/overlay/anchor.js';
|
|
15
|
+
export { default as HoverCard } from './components/overlay/HoverCard.svelte';
|
package/dist/overlay.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Floating-overlay primitives: where a card attached to a point goes, and the
|
|
3
|
+
* chrome it is drawn in.
|
|
4
|
+
*
|
|
5
|
+
* The two are separate because they fail differently. Placement is arithmetic —
|
|
6
|
+
* it can be wrong on a phone and right on a laptop, and it belongs in a test.
|
|
7
|
+
* Chrome is CSS, and belongs behind custom properties. Both were previously
|
|
8
|
+
* re-implemented per call site, which is how one copy ended up clamping
|
|
9
|
+
* horizontally but not vertically.
|
|
10
|
+
*
|
|
11
|
+
* Domain-free: it knows about points, boxes and padding, and nothing about maps,
|
|
12
|
+
* charts or what the card says.
|
|
13
|
+
*/
|
|
14
|
+
export { anchor } from './components/overlay/anchor.js';
|
|
15
|
+
export { default as HoverCard } from './components/overlay/HoverCard.svelte';
|
package/dist/styles/tokens.css
CHANGED
|
@@ -79,4 +79,40 @@
|
|
|
79
79
|
--video-badge-padding: var(--space-3);
|
|
80
80
|
--video-badge-color: var(--color-ink);
|
|
81
81
|
--video-badge-bg: rgb(255 255 255 / 50%);
|
|
82
|
+
|
|
83
|
+
/* Floating overlays (the `./overlay` subpath) */
|
|
84
|
+
/* The hover card's fixed width. Fixed rather than fluid because the card is
|
|
85
|
+
positioned by measuring it: a width that depends on its content makes the
|
|
86
|
+
placement arithmetic chase itself for a frame. */
|
|
87
|
+
--vit-card-width: 277px;
|
|
88
|
+
--vit-card-padding: var(--space-2, 10px);
|
|
89
|
+
--vit-card-gap: var(--space-2, 10px);
|
|
90
|
+
--vit-card-bg: #ffffff;
|
|
91
|
+
--vit-card-radius: 0;
|
|
92
|
+
--vit-card-shadow: none;
|
|
93
|
+
--vit-card-font: inherit;
|
|
94
|
+
--vit-card-shimmer-color: currentColor;
|
|
95
|
+
|
|
96
|
+
/* Madlib (`./madlib`): a sentence with blanks. The controls inherit the
|
|
97
|
+
font, size and weight of the text around them, so most of these default
|
|
98
|
+
to "whatever the sentence is"; the menu and the accents are the ones a
|
|
99
|
+
theme sets. `--vit-madlib-select-accent` follows `--vit-madlib-accent`
|
|
100
|
+
unless a theme wants the dropdown underline and the toggle underline to
|
|
101
|
+
differ, which the first host did. */
|
|
102
|
+
--vit-madlib-font: inherit;
|
|
103
|
+
--vit-madlib-color: inherit;
|
|
104
|
+
--vit-madlib-weight: inherit;
|
|
105
|
+
--vit-madlib-control-weight: 700;
|
|
106
|
+
--vit-madlib-lead-size: 1em;
|
|
107
|
+
--vit-madlib-lead-line-height: 1.4;
|
|
108
|
+
--vit-madlib-size: 1em;
|
|
109
|
+
--vit-madlib-line-height: 1.4;
|
|
110
|
+
--vit-madlib-muted-color: color-mix(in srgb, currentColor 35%, transparent);
|
|
111
|
+
--vit-madlib-accent: currentColor;
|
|
112
|
+
--vit-madlib-select-accent: var(--vit-madlib-accent);
|
|
113
|
+
--vit-madlib-menu-bg: var(--color-surface);
|
|
114
|
+
--vit-madlib-menu-hover: color-mix(in srgb, currentColor 10%, transparent);
|
|
115
|
+
--vit-madlib-menu-shadow: none;
|
|
116
|
+
--vit-madlib-menu-max-height: 15rem;
|
|
117
|
+
--vit-madlib-menu-z: 40;
|
|
82
118
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vit-foundation/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.32.0",
|
|
4
4
|
"scripts": {
|
|
5
5
|
"dev": "vite dev",
|
|
6
6
|
"build": "vite build && npm run prepack",
|
|
@@ -96,6 +96,16 @@
|
|
|
96
96
|
"types": "./dist/scrolly.d.ts",
|
|
97
97
|
"svelte": "./dist/scrolly.js",
|
|
98
98
|
"default": "./dist/scrolly.js"
|
|
99
|
+
},
|
|
100
|
+
"./overlay": {
|
|
101
|
+
"types": "./dist/overlay.d.ts",
|
|
102
|
+
"svelte": "./dist/overlay.js",
|
|
103
|
+
"default": "./dist/overlay.js"
|
|
104
|
+
},
|
|
105
|
+
"./madlib": {
|
|
106
|
+
"types": "./dist/madlib.d.ts",
|
|
107
|
+
"svelte": "./dist/madlib.js",
|
|
108
|
+
"default": "./dist/madlib.js"
|
|
99
109
|
}
|
|
100
110
|
},
|
|
101
111
|
"peerDependencies": {
|