@vit-foundation/ui 0.31.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 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,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';
@@ -92,4 +92,27 @@
92
92
  --vit-card-shadow: none;
93
93
  --vit-card-font: inherit;
94
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;
95
118
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vit-foundation/ui",
3
- "version": "0.31.0",
3
+ "version": "0.32.0",
4
4
  "scripts": {
5
5
  "dev": "vite dev",
6
6
  "build": "vite build && npm run prepack",
@@ -101,6 +101,11 @@
101
101
  "types": "./dist/overlay.d.ts",
102
102
  "svelte": "./dist/overlay.js",
103
103
  "default": "./dist/overlay.js"
104
+ },
105
+ "./madlib": {
106
+ "types": "./dist/madlib.d.ts",
107
+ "svelte": "./dist/madlib.js",
108
+ "default": "./dist/madlib.js"
104
109
  }
105
110
  },
106
111
  "peerDependencies": {