@mystravil/hardpoint 0.2.0 → 0.3.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/CHANGELOG.md +51 -38
- package/LICENSE +25 -25
- package/README.md +67 -64
- package/dist/base.css +625 -625
- package/dist/components/AppShell.svelte +698 -698
- package/dist/components/Button.svelte +221 -174
- package/dist/components/Button.svelte.d.ts +18 -2
- package/dist/components/Checkbox.svelte +235 -235
- package/dist/components/CheckboxGroup.svelte +207 -207
- package/dist/components/Combobox.svelte +901 -901
- package/dist/components/Dialog.svelte +359 -359
- package/dist/components/Disclosure.svelte +201 -201
- package/dist/components/EmptyState.svelte +88 -88
- package/dist/components/ErrorState.svelte +135 -135
- package/dist/components/ImageViewer.svelte +764 -764
- package/dist/components/KeyValueList.svelte +114 -114
- package/dist/components/ListDetail.svelte +218 -218
- package/dist/components/LoadRegion.svelte +219 -219
- package/dist/components/MediaFrame.svelte +110 -110
- package/dist/components/MediaImage.svelte +256 -256
- package/dist/components/Menu.svelte +275 -258
- package/dist/components/MenuCheckboxItem.svelte +119 -119
- package/dist/components/MenuGroup.svelte +50 -50
- package/dist/components/MenuItem.svelte +94 -94
- package/dist/components/MenuRadioGroup.svelte +62 -62
- package/dist/components/MenuRadioItem.svelte +110 -110
- package/dist/components/MenuSeparator.svelte +28 -28
- package/dist/components/Meter.svelte +283 -283
- package/dist/components/NativeSelect.svelte +135 -135
- package/dist/components/Notice.svelte +262 -262
- package/dist/components/Popover.svelte +222 -222
- package/dist/components/Progress.svelte +157 -157
- package/dist/components/RadioGroup.svelte +440 -440
- package/dist/components/Select.svelte +471 -471
- package/dist/components/Skeleton.svelte +149 -149
- package/dist/components/Spinner.svelte +118 -118
- package/dist/components/Stat.svelte +253 -253
- package/dist/components/Steps.svelte +189 -189
- package/dist/components/Switch.svelte +222 -222
- package/dist/components/Table.svelte +504 -504
- package/dist/components/Tabs.svelte +246 -246
- package/dist/components/Tag.svelte +40 -40
- package/dist/components/TextArea.svelte +272 -272
- package/dist/components/TextField.svelte +396 -256
- package/dist/components/TextField.svelte.d.ts +16 -1
- package/dist/components/Timeline.svelte +156 -156
- package/dist/components/Toaster.svelte +411 -411
- package/dist/components/Tooltip.svelte +200 -200
- package/dist/components/TooltipGroup.svelte +46 -46
- package/dist/components/internal/Scrim.svelte +52 -52
- package/dist/components/internal/Sheet.svelte +263 -263
- package/dist/fonts.css +71 -71
- package/dist/tokens.css +283 -283
- package/package.json +81 -76
|
@@ -1,901 +1,901 @@
|
|
|
1
|
-
<script lang="ts" module>
|
|
2
|
-
import type { Snippet } from 'svelte';
|
|
3
|
-
import type { HTMLAttributes } from 'svelte/elements';
|
|
4
|
-
|
|
5
|
-
export type ComboboxItem = {
|
|
6
|
-
value: string;
|
|
7
|
-
/** The option's text: what the default filter matches, and the field's text once chosen. */
|
|
8
|
-
label: string;
|
|
9
|
-
/** A second line in `--muted`. */
|
|
10
|
-
description?: string;
|
|
11
|
-
/**
|
|
12
|
-
* The heading the option sits under, as written; the default filter
|
|
13
|
-
* matches it too. Options with the same group share one heading, in the
|
|
14
|
-
* order each group first appears; options without one come first.
|
|
15
|
-
*/
|
|
16
|
-
group?: string;
|
|
17
|
-
disabled?: boolean;
|
|
18
|
-
};
|
|
19
|
-
|
|
20
|
-
export type ComboboxItemSnippetProps = {
|
|
21
|
-
item: ComboboxItem;
|
|
22
|
-
selected: boolean;
|
|
23
|
-
highlighted: boolean;
|
|
24
|
-
};
|
|
25
|
-
|
|
26
|
-
export type ComboboxLabelBeside = boolean | 'medium' | 'expanded' | 'large';
|
|
27
|
-
|
|
28
|
-
/** Whether an item stays in the list for the typed text (trimmed, never empty). */
|
|
29
|
-
export type ComboboxFilter = (item: ComboboxItem, query: string) => boolean;
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* Attributes and handlers not listed here go to the field control: the
|
|
33
|
-
* text input from 600px, the button that opens the sheet below.
|
|
34
|
-
*/
|
|
35
|
-
export type ComboboxProps = Omit<HTMLAttributes<HTMLElement>, 'children' | 'id'> & {
|
|
36
|
-
items: ComboboxItem[];
|
|
37
|
-
/** The chosen item's `value`; `''` for none. It changes only by choosing from the list. */
|
|
38
|
-
value?: string;
|
|
39
|
-
open?: boolean;
|
|
40
|
-
/** The caps field label above the field, and the sheet's title on compact widths. */
|
|
41
|
-
label: string;
|
|
42
|
-
/** Shown in `--muted` in the empty field and in the sheet's search input. Never the only label. */
|
|
43
|
-
placeholder?: string;
|
|
44
|
-
/** Guidance under the field in `--muted`, linked by `aria-describedby`. */
|
|
45
|
-
hint?: string;
|
|
46
|
-
/**
|
|
47
|
-
* The error to show under the field, with an icon, linked by
|
|
48
|
-
* `aria-describedby`; it sets `aria-invalid`. Say how to fix it.
|
|
49
|
-
*/
|
|
50
|
-
error?: string;
|
|
51
|
-
disabled?: boolean;
|
|
52
|
-
/** Submits the value with a form, through a hidden input. */
|
|
53
|
-
name?: string;
|
|
54
|
-
/**
|
|
55
|
-
* The label beside the field instead of above it: always (`true`), or
|
|
56
|
-
* from a breakpoint up (`medium` 600px, `expanded` 900px, `large` 1200px).
|
|
57
|
-
* The hint and the error stay under the field.
|
|
58
|
-
*/
|
|
59
|
-
labelBeside?: ComboboxLabelBeside;
|
|
60
|
-
/**
|
|
61
|
-
* Replaces the default filter: the label or the group contains the text,
|
|
62
|
-
* ignoring case and accents.
|
|
63
|
-
*/
|
|
64
|
-
filter?: ComboboxFilter;
|
|
65
|
-
/** Shown in place of the options when nothing matches. */
|
|
66
|
-
emptyText?: string;
|
|
67
|
-
/** The sheet's close button name. */
|
|
68
|
-
closeLabel?: string;
|
|
69
|
-
/** The field control's id; the label points at it. */
|
|
70
|
-
id?: string;
|
|
71
|
-
/** The field control: the text input from 600px, the button below. */
|
|
72
|
-
ref?: HTMLElement | null;
|
|
73
|
-
/** Renders an option's content in place of its label and description. */
|
|
74
|
-
item?: Snippet<[ComboboxItemSnippetProps]>;
|
|
75
|
-
};
|
|
76
|
-
</script>
|
|
77
|
-
|
|
78
|
-
<script lang="ts">
|
|
79
|
-
import { Combobox as BitsCombobox } from 'bits-ui';
|
|
80
|
-
import { untrack } from 'svelte';
|
|
81
|
-
import { MediaQuery } from 'svelte/reactivity';
|
|
82
|
-
import { pop } from '../motion/index.js';
|
|
83
|
-
import { fadeIn } from './internal/fade-in.js';
|
|
84
|
-
import { groupOptions } from './internal/groups.js';
|
|
85
|
-
import Sheet from './internal/Sheet.svelte';
|
|
86
|
-
|
|
87
|
-
const uid = $props.id();
|
|
88
|
-
|
|
89
|
-
let {
|
|
90
|
-
items,
|
|
91
|
-
value = $bindable(''),
|
|
92
|
-
open = $bindable(false),
|
|
93
|
-
label,
|
|
94
|
-
placeholder = '',
|
|
95
|
-
hint,
|
|
96
|
-
error,
|
|
97
|
-
disabled = false,
|
|
98
|
-
name,
|
|
99
|
-
labelBeside = false,
|
|
100
|
-
filter,
|
|
101
|
-
emptyText = 'No matches',
|
|
102
|
-
closeLabel = 'Close',
|
|
103
|
-
id = `${uid}-field`,
|
|
104
|
-
ref = $bindable(null),
|
|
105
|
-
item: itemContent,
|
|
106
|
-
class: className,
|
|
107
|
-
onclick: onclickProp,
|
|
108
|
-
oninput: oninputProp,
|
|
109
|
-
'aria-describedby': describedBy,
|
|
110
|
-
'aria-invalid': ariaInvalid,
|
|
111
|
-
...rest
|
|
112
|
-
}: ComboboxProps = $props();
|
|
113
|
-
|
|
114
|
-
const labelId = `${uid}-label`;
|
|
115
|
-
const sheetId = `${uid}-sheet`;
|
|
116
|
-
// The listbox, in the popover or the sheet; the input that drives it
|
|
117
|
-
// says so with aria-controls, which Bits UI leaves out.
|
|
118
|
-
const listId = `${uid}-list`;
|
|
119
|
-
const sheetTitleId = `${uid}-sheet-title`;
|
|
120
|
-
const hintId = $derived(`${id}-hint`);
|
|
121
|
-
const errorId = $derived(`${id}-error`);
|
|
122
|
-
// The error first, so a screen reader reads it before the hint.
|
|
123
|
-
const describedByIds = $derived(
|
|
124
|
-
[error ? errorId : undefined, hint ? hintId : undefined, describedBy].filter(Boolean).join(' ') ||
|
|
125
|
-
undefined
|
|
126
|
-
);
|
|
127
|
-
const invalid = $derived(error ? true : ariaInvalid);
|
|
128
|
-
|
|
129
|
-
// Short motion (BRIEF, --dur-message), as TextField: an error that
|
|
130
|
-
// appears after the first render fades in from 0 and the field's edge
|
|
131
|
-
// fades to --danger with it; one that clears goes at once. A field that
|
|
132
|
-
// renders or mounts with an error shows it at once. 0ms is a snap.
|
|
133
|
-
let root: HTMLDivElement | null = $state(null);
|
|
134
|
-
let errorShown = untrack(() => !!error);
|
|
135
|
-
$effect(() => {
|
|
136
|
-
const shown = !!error;
|
|
137
|
-
untrack(() => {
|
|
138
|
-
const appeared = shown && !errorShown;
|
|
139
|
-
errorShown = shown;
|
|
140
|
-
const message = appeared ? root?.querySelector(':scope > .hp-combobox-messages > .hp-combobox-error') : null;
|
|
141
|
-
if (message) fadeIn(message, '--dur-message');
|
|
142
|
-
});
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
// Compact (< 600px) opens a bottom sheet with the search input inside;
|
|
146
|
-
// medium and up type in the field and list under it (BRIEF, Apps and
|
|
147
|
-
// phones: Overlays and Choices).
|
|
148
|
-
const wide = new MediaQuery('min-width: 600px', true);
|
|
149
|
-
|
|
150
|
-
const chosen = $derived(items.find((i) => i.value === value));
|
|
151
|
-
// A string, so a fresh `items` array with the same choice changes nothing.
|
|
152
|
-
const chosenLabel = $derived(chosen?.label ?? '');
|
|
153
|
-
|
|
154
|
-
// The input's text. It shows the chosen label while the list is closed;
|
|
155
|
-
// typing overrides it, and closing puts the label back, so the value
|
|
156
|
-
// only ever comes from the list.
|
|
157
|
-
let text = $derived(chosenLabel);
|
|
158
|
-
// What the list is filtered by: only what was typed since it opened, so
|
|
159
|
-
// it opens with every option even when the field shows a label.
|
|
160
|
-
let query = $state('');
|
|
161
|
-
|
|
162
|
-
const fold = (s: string) => s.normalize('NFD').replace(/\p{Diacritic}/gu, '').toLowerCase();
|
|
163
|
-
const byLabelOrGroup: ComboboxFilter = (entry, q) => {
|
|
164
|
-
const needle = fold(q);
|
|
165
|
-
return fold(entry.label).includes(needle) || (!!entry.group && fold(entry.group).includes(needle));
|
|
166
|
-
};
|
|
167
|
-
const matches = $derived.by(() => {
|
|
168
|
-
const q = query.trim();
|
|
169
|
-
if (!q) return items;
|
|
170
|
-
const keep = filter ?? byLabelOrGroup;
|
|
171
|
-
return items.filter((entry) => keep(entry, q));
|
|
172
|
-
});
|
|
173
|
-
// Options without a group, then each group that still has a match; a
|
|
174
|
-
// group whose options all filter out drops its heading too.
|
|
175
|
-
const grouped = $derived(groupOptions(matches));
|
|
176
|
-
|
|
177
|
-
// The highlight moved by keys gets the focus contour; a pointer's gets the
|
|
178
|
-
// hover tint alone. Focus itself stays in the input.
|
|
179
|
-
let keyboard = $state(false);
|
|
180
|
-
|
|
181
|
-
// Whether Ctrl or Meta was held at the last key press in the field. Bits
|
|
182
|
-
// UI opens the list on any key, Ctrl and Meta included, so a shortcut
|
|
183
|
-
// such as Ctrl+C opens it too; that open keeps the field's selection, so
|
|
184
|
-
// the shortcut copies what the person selected, not the whole label.
|
|
185
|
-
let shortcut = false;
|
|
186
|
-
|
|
187
|
-
let panelNode = $state<HTMLElement | null>(null);
|
|
188
|
-
let sheetNode = $state<HTMLElement | null>(null);
|
|
189
|
-
|
|
190
|
-
// Every open and close passes here once, whoever caused it: Bits UI
|
|
191
|
-
// (typing, Escape, an outside press, a choice, Tab), the field, the
|
|
192
|
-
// sheet's close button and swipe, or the app through `bind:open`.
|
|
193
|
-
let settled = false;
|
|
194
|
-
function openChanged(next: boolean) {
|
|
195
|
-
if (next === settled) return;
|
|
196
|
-
settled = next;
|
|
197
|
-
query = '';
|
|
198
|
-
if (next) {
|
|
199
|
-
if (!wide.current) {
|
|
200
|
-
// The sheet's search input starts empty.
|
|
201
|
-
text = '';
|
|
202
|
-
} else if (!shortcut && ref instanceof HTMLInputElement && document.activeElement === ref) {
|
|
203
|
-
// Opened in the field, by a press or a key: the text is selected
|
|
204
|
-
// (before a typed character lands), so typing replaces the label
|
|
205
|
-
// and filters on what was typed alone.
|
|
206
|
-
ref.select();
|
|
207
|
-
}
|
|
208
|
-
return;
|
|
209
|
-
}
|
|
210
|
-
text = chosenLabel;
|
|
211
|
-
// The sheet held focus; give it back to the field.
|
|
212
|
-
if (!wide.current) {
|
|
213
|
-
const active = document.activeElement;
|
|
214
|
-
if (!active || active === document.body || sheetNode?.contains(active)) ref?.focus();
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
$effect.pre(() => {
|
|
218
|
-
const next = open;
|
|
219
|
-
untrack(() => openChanged(next));
|
|
220
|
-
});
|
|
221
|
-
|
|
222
|
-
function setOpen(next: boolean) {
|
|
223
|
-
open = next;
|
|
224
|
-
openChanged(next);
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
function onInput(e: Event & { currentTarget: EventTarget & HTMLInputElement }) {
|
|
228
|
-
oninputProp?.(e);
|
|
229
|
-
query = e.currentTarget.value;
|
|
230
|
-
text = query;
|
|
231
|
-
// The matches show from the top, so Bits UI's highlight, which goes to
|
|
232
|
-
// the first option in view, lands on the first match, and no match
|
|
233
|
-
// waits above the fold or under a group heading.
|
|
234
|
-
const viewport = document.getElementById(listId)?.querySelector('[data-combobox-viewport]');
|
|
235
|
-
if (viewport) viewport.scrollTop = 0;
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
// A press on the field opens the list (openChanged selects its text).
|
|
239
|
-
// While open, a press on the chevron end closes it; one on the text
|
|
240
|
-
// places the caret. A label's click (detail 0) only focuses.
|
|
241
|
-
function onFieldClick(e: MouseEvent & { currentTarget: EventTarget & HTMLInputElement }) {
|
|
242
|
-
onclickProp?.(e);
|
|
243
|
-
if (disabled || e.detail === 0) return;
|
|
244
|
-
const input = e.currentTarget;
|
|
245
|
-
if (!open) {
|
|
246
|
-
setOpen(true);
|
|
247
|
-
return;
|
|
248
|
-
}
|
|
249
|
-
const box = input.getBoundingClientRect();
|
|
250
|
-
if (e.clientX >= box.right - parseFloat(getComputedStyle(input).paddingRight)) setOpen(false);
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
function onTriggerClick(e: MouseEvent & { currentTarget: EventTarget & HTMLButtonElement }) {
|
|
254
|
-
onclickProp?.(e);
|
|
255
|
-
if (!disabled) setOpen(true);
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
// As on Select's trigger: the arrow keys open the sheet too.
|
|
259
|
-
function onTriggerKeydown(e: KeyboardEvent & { currentTarget: EventTarget & HTMLButtonElement }) {
|
|
260
|
-
rest.onkeydown?.(e);
|
|
261
|
-
if (disabled || e.defaultPrevented) return;
|
|
262
|
-
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
|
263
|
-
e.preventDefault();
|
|
264
|
-
setOpen(true);
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
function close() {
|
|
269
|
-
setOpen(false);
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
// The panel's empty line and the sheet's title row sit outside the
|
|
273
|
-
// listbox; pressing them is not an outside press.
|
|
274
|
-
function insidePanel(e: PointerEvent) {
|
|
275
|
-
if (panelNode && e.target instanceof Node && panelNode.contains(e.target)) e.preventDefault();
|
|
276
|
-
}
|
|
277
|
-
function insideSheet(e: PointerEvent) {
|
|
278
|
-
if (sheetNode && e.target instanceof Node && sheetNode.contains(e.target)) e.preventDefault();
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
// Presses on the overlay's chrome keep focus in the input; a press on the
|
|
282
|
-
// sheet's search input itself still focuses it.
|
|
283
|
-
function keepFocus(e: PointerEvent) {
|
|
284
|
-
if (!(e.target instanceof HTMLInputElement)) e.preventDefault();
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
function focusOnMount(node: HTMLInputElement) {
|
|
288
|
-
node.focus({ preventScroll: true });
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
// In the sheet, Bits UI never learns that its content is placed:
|
|
292
|
-
// ContentStatic with forceMount reports placement once, at mount, while
|
|
293
|
-
// still closed. So Bits neither opens it at the chosen option nor scrolls
|
|
294
|
-
// to the option the keys move to; this does both, nearest edge first, as
|
|
295
|
-
// Bits does in the popover (Select does the same). The viewport's
|
|
296
|
-
// scroll-padding keeps an option clear of a group heading stuck at the top.
|
|
297
|
-
function followHighlight(viewport: HTMLElement) {
|
|
298
|
-
const reveal = (option: Element | null) => {
|
|
299
|
-
if (!(option instanceof HTMLElement)) return;
|
|
300
|
-
const top = option.offsetTop - (parseFloat(getComputedStyle(viewport).scrollPaddingTop) || 0);
|
|
301
|
-
const bottom = option.offsetTop + option.offsetHeight;
|
|
302
|
-
if (top < viewport.scrollTop) viewport.scrollTop = top;
|
|
303
|
-
else if (bottom > viewport.scrollTop + viewport.clientHeight) {
|
|
304
|
-
viewport.scrollTop = bottom - viewport.clientHeight;
|
|
305
|
-
}
|
|
306
|
-
};
|
|
307
|
-
reveal(viewport.querySelector('[data-selected]'));
|
|
308
|
-
const observer = new MutationObserver((records) => {
|
|
309
|
-
if (!keyboard) return;
|
|
310
|
-
for (const { target } of records) {
|
|
311
|
-
if (target instanceof Element && target.hasAttribute('data-highlighted')) reveal(target);
|
|
312
|
-
}
|
|
313
|
-
});
|
|
314
|
-
observer.observe(viewport, { subtree: true, attributes: true, attributeFilter: ['data-highlighted'] });
|
|
315
|
-
return () => observer.disconnect();
|
|
316
|
-
}
|
|
317
|
-
</script>
|
|
318
|
-
|
|
319
|
-
<div
|
|
320
|
-
bind:this={root}
|
|
321
|
-
class={['hp-combobox', className]}
|
|
322
|
-
data-state={open ? 'open' : 'closed'}
|
|
323
|
-
data-disabled={disabled || undefined}
|
|
324
|
-
data-placeholder={chosen ? undefined : ''}
|
|
325
|
-
data-invalid={error ? '' : undefined}
|
|
326
|
-
data-label-beside={labelBeside === true ? 'always' : labelBeside || undefined}
|
|
327
|
-
onkeydowncapture={(e) => {
|
|
328
|
-
keyboard = true;
|
|
329
|
-
// Chrome on Windows reports AltGr as Ctrl+Alt; AltGr types a character.
|
|
330
|
-
shortcut = (e.ctrlKey || e.metaKey) && !e.altKey;
|
|
331
|
-
}}
|
|
332
|
-
onpointerdowncapture={() => {
|
|
333
|
-
keyboard = false;
|
|
334
|
-
shortcut = false;
|
|
335
|
-
}}
|
|
336
|
-
>
|
|
337
|
-
<label class="hp-combobox-label hp-label" id={labelId} for={id}>{label}</label>
|
|
338
|
-
|
|
339
|
-
<BitsCombobox.Root
|
|
340
|
-
type="single"
|
|
341
|
-
bind:value
|
|
342
|
-
bind:open
|
|
343
|
-
{items}
|
|
344
|
-
{disabled}
|
|
345
|
-
{name}
|
|
346
|
-
allowDeselect={false}
|
|
347
|
-
inputValue={text}
|
|
348
|
-
onOpenChange={openChanged}
|
|
349
|
-
>
|
|
350
|
-
{#if wide.current}
|
|
351
|
-
<BitsCombobox.Input
|
|
352
|
-
{...rest}
|
|
353
|
-
bind:ref
|
|
354
|
-
{id}
|
|
355
|
-
{placeholder}
|
|
356
|
-
autocomplete="off"
|
|
357
|
-
spellcheck={false}
|
|
358
|
-
aria-describedby={describedByIds}
|
|
359
|
-
aria-invalid={invalid}
|
|
360
|
-
oninput={onInput}
|
|
361
|
-
aria-controls={open ? listId : undefined}
|
|
362
|
-
onclick={onFieldClick}
|
|
363
|
-
>
|
|
364
|
-
{#snippet child({ props })}
|
|
365
|
-
<span class="hp-combobox-field hp-well-chevron">
|
|
366
|
-
<input {...props} class="hp-combobox-input hp-well" />
|
|
367
|
-
</span>
|
|
368
|
-
{/snippet}
|
|
369
|
-
</BitsCombobox.Input>
|
|
370
|
-
|
|
371
|
-
<BitsCombobox.Portal>
|
|
372
|
-
<BitsCombobox.Content
|
|
373
|
-
id={listId}
|
|
374
|
-
forceMount
|
|
375
|
-
side="bottom"
|
|
376
|
-
align="start"
|
|
377
|
-
sideOffset={8}
|
|
378
|
-
collisionPadding={8}
|
|
379
|
-
aria-labelledby={labelId}
|
|
380
|
-
onInteractOutside={insidePanel}
|
|
381
|
-
>
|
|
382
|
-
{#snippet child({ wrapperProps, props, open: shown })}
|
|
383
|
-
{#if shown}
|
|
384
|
-
<div {...wrapperProps}>
|
|
385
|
-
<div
|
|
386
|
-
class="hp-combobox-panel"
|
|
387
|
-
data-form="popover"
|
|
388
|
-
data-side={props['data-side'] as string | undefined}
|
|
389
|
-
bind:this={panelNode}
|
|
390
|
-
role="presentation"
|
|
391
|
-
onpointerdown={keepFocus}
|
|
392
|
-
transition:pop
|
|
393
|
-
>
|
|
394
|
-
{@render empty(false)}
|
|
395
|
-
<div
|
|
396
|
-
{...props}
|
|
397
|
-
class="hp-combobox-list"
|
|
398
|
-
data-form="popover"
|
|
399
|
-
data-empty={matches.length ? undefined : ''}
|
|
400
|
-
data-keyboard={keyboard || undefined}
|
|
401
|
-
onpointermovecapture={() => (keyboard = false)}
|
|
402
|
-
>
|
|
403
|
-
{@render options(false)}
|
|
404
|
-
</div>
|
|
405
|
-
</div>
|
|
406
|
-
</div>
|
|
407
|
-
{/if}
|
|
408
|
-
{/snippet}
|
|
409
|
-
</BitsCombobox.Content>
|
|
410
|
-
</BitsCombobox.Portal>
|
|
411
|
-
{:else}
|
|
412
|
-
<span class="hp-combobox-field hp-well-chevron">
|
|
413
|
-
<button
|
|
414
|
-
{...rest}
|
|
415
|
-
bind:this={ref}
|
|
416
|
-
type="button"
|
|
417
|
-
{id}
|
|
418
|
-
class="hp-combobox-trigger hp-well"
|
|
419
|
-
role="combobox"
|
|
420
|
-
aria-haspopup="dialog"
|
|
421
|
-
aria-expanded={open}
|
|
422
|
-
aria-controls={open ? sheetId : undefined}
|
|
423
|
-
aria-labelledby={labelId}
|
|
424
|
-
aria-describedby={describedByIds}
|
|
425
|
-
aria-invalid={invalid}
|
|
426
|
-
data-placeholder={chosen ? undefined : ''}
|
|
427
|
-
{disabled}
|
|
428
|
-
onclick={onTriggerClick}
|
|
429
|
-
onkeydown={onTriggerKeydown}
|
|
430
|
-
>
|
|
431
|
-
<span class="hp-combobox-value">{chosen?.label ?? placeholder}</span>
|
|
432
|
-
</button>
|
|
433
|
-
</span>
|
|
434
|
-
|
|
435
|
-
<BitsCombobox.Portal>
|
|
436
|
-
<BitsCombobox.ContentStatic id={listId} forceMount preventScroll onInteractOutside={insideSheet}>
|
|
437
|
-
{#snippet child({ props, open: shown })}
|
|
438
|
-
<Sheet
|
|
439
|
-
open={shown}
|
|
440
|
-
title={label}
|
|
441
|
-
titleId={sheetTitleId}
|
|
442
|
-
layer="popover"
|
|
443
|
-
onclose={close}
|
|
444
|
-
{closeLabel}
|
|
445
|
-
bind:ref={sheetNode}
|
|
446
|
-
id={sheetId}
|
|
447
|
-
role="dialog"
|
|
448
|
-
aria-modal="true"
|
|
449
|
-
aria-labelledby={sheetTitleId}
|
|
450
|
-
onpointerdown={keepFocus}
|
|
451
|
-
>
|
|
452
|
-
<div class="hp-combobox-search" onkeydowncapture={() => (keyboard = true)}>
|
|
453
|
-
<BitsCombobox.Input
|
|
454
|
-
{placeholder}
|
|
455
|
-
autocomplete="off"
|
|
456
|
-
spellcheck={false}
|
|
457
|
-
aria-labelledby={sheetTitleId}
|
|
458
|
-
aria-describedby={describedByIds}
|
|
459
|
-
aria-controls={open ? listId : undefined}
|
|
460
|
-
oninput={onInput}
|
|
461
|
-
>
|
|
462
|
-
{#snippet child({ props: inputProps })}
|
|
463
|
-
<input {...inputProps} class="hp-combobox-input hp-well" {@attach focusOnMount} />
|
|
464
|
-
{/snippet}
|
|
465
|
-
</BitsCombobox.Input>
|
|
466
|
-
</div>
|
|
467
|
-
{@render empty(true)}
|
|
468
|
-
<div
|
|
469
|
-
{...props}
|
|
470
|
-
class="hp-combobox-list"
|
|
471
|
-
data-form="sheet"
|
|
472
|
-
data-empty={matches.length ? undefined : ''}
|
|
473
|
-
aria-labelledby={sheetTitleId}
|
|
474
|
-
data-keyboard={keyboard || undefined}
|
|
475
|
-
onpointermovecapture={() => (keyboard = false)}
|
|
476
|
-
>
|
|
477
|
-
{@render options(true)}
|
|
478
|
-
</div>
|
|
479
|
-
</Sheet>
|
|
480
|
-
{/snippet}
|
|
481
|
-
</BitsCombobox.ContentStatic>
|
|
482
|
-
</BitsCombobox.Portal>
|
|
483
|
-
{/if}
|
|
484
|
-
</BitsCombobox.Root>
|
|
485
|
-
|
|
486
|
-
{#if hint || error}
|
|
487
|
-
<div class="hp-combobox-messages">
|
|
488
|
-
{#if hint}
|
|
489
|
-
<p class="hp-combobox-hint hp-caption" id={hintId}>{hint}</p>
|
|
490
|
-
{/if}
|
|
491
|
-
{#if error}
|
|
492
|
-
<p class="hp-combobox-error hp-caption" id={errorId}>
|
|
493
|
-
<span class="hp-combobox-error-icon" aria-hidden="true">
|
|
494
|
-
<svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor">
|
|
495
|
-
<path d="M1.5 1.5h13v13h-13z" />
|
|
496
|
-
<path d="M8 4v4M8 10v2" stroke-width="2" />
|
|
497
|
-
</svg>
|
|
498
|
-
</span>
|
|
499
|
-
<span>{error}</span>
|
|
500
|
-
</p>
|
|
501
|
-
{/if}
|
|
502
|
-
</div>
|
|
503
|
-
{/if}
|
|
504
|
-
</div>
|
|
505
|
-
|
|
506
|
-
{#snippet options(inSheet: boolean)}
|
|
507
|
-
<BitsCombobox.Viewport>
|
|
508
|
-
{#snippet child({ props: viewportProps })}
|
|
509
|
-
<div
|
|
510
|
-
{...viewportProps}
|
|
511
|
-
class="hp-combobox-viewport"
|
|
512
|
-
data-grouped={grouped.groups.length ? '' : undefined}
|
|
513
|
-
{@attach inSheet && followHighlight}
|
|
514
|
-
>
|
|
515
|
-
{#each grouped.loose as entry (entry.value)}
|
|
516
|
-
{@render option(entry)}
|
|
517
|
-
{/each}
|
|
518
|
-
{#each grouped.groups as group (group.name)}
|
|
519
|
-
<!-- role="group", named by its heading, which is no option: keys
|
|
520
|
-
skip it. -->
|
|
521
|
-
<BitsCombobox.Group>
|
|
522
|
-
{#snippet child({ props: groupProps })}
|
|
523
|
-
<div {...groupProps} class="hp-combobox-group">
|
|
524
|
-
<BitsCombobox.GroupHeading>
|
|
525
|
-
{#snippet child({ props: headingProps })}
|
|
526
|
-
<div {...headingProps} class="hp-combobox-group-heading">{group.name}</div>
|
|
527
|
-
{/snippet}
|
|
528
|
-
</BitsCombobox.GroupHeading>
|
|
529
|
-
{#each group.items as entry (entry.value)}
|
|
530
|
-
{@render option(entry)}
|
|
531
|
-
{/each}
|
|
532
|
-
</div>
|
|
533
|
-
{/snippet}
|
|
534
|
-
</BitsCombobox.Group>
|
|
535
|
-
{/each}
|
|
536
|
-
</div>
|
|
537
|
-
{/snippet}
|
|
538
|
-
</BitsCombobox.Viewport>
|
|
539
|
-
{/snippet}
|
|
540
|
-
|
|
541
|
-
{#snippet option(entry: ComboboxItem)}
|
|
542
|
-
<BitsCombobox.Item value={entry.value} label={entry.label} disabled={entry.disabled}>
|
|
543
|
-
{#snippet child({ props: optionProps, selected, highlighted })}
|
|
544
|
-
<div
|
|
545
|
-
{...optionProps}
|
|
546
|
-
class="hp-combobox-option"
|
|
547
|
-
data-described={entry.description ? '' : undefined}
|
|
548
|
-
aria-disabled={entry.disabled ? 'true' : undefined}
|
|
549
|
-
>
|
|
550
|
-
{#if itemContent}
|
|
551
|
-
{@render itemContent({ item: entry, selected, highlighted })}
|
|
552
|
-
{:else}
|
|
553
|
-
<span class="hp-combobox-option-label">{entry.label}</span>
|
|
554
|
-
{#if entry.description}
|
|
555
|
-
<span class="hp-combobox-option-description">{entry.description}</span>
|
|
556
|
-
{/if}
|
|
557
|
-
{/if}
|
|
558
|
-
</div>
|
|
559
|
-
{/snippet}
|
|
560
|
-
</BitsCombobox.Item>
|
|
561
|
-
{/snippet}
|
|
562
|
-
|
|
563
|
-
<!-- Outside the listbox, which may hold options and groups only. Mounted
|
|
564
|
-
while the list is open, so the text it gains when nothing matches is
|
|
565
|
-
announced. -->
|
|
566
|
-
{#snippet empty(inSheet: boolean)}
|
|
567
|
-
<p class="hp-combobox-empty" data-form={inSheet ? 'sheet' : 'popover'} role="status">
|
|
568
|
-
{#if !matches.length}{emptyText}{/if}
|
|
569
|
-
</p>
|
|
570
|
-
{/snippet}
|
|
571
|
-
|
|
572
|
-
<style>
|
|
573
|
-
/* ---- Field: caps label over a black well --------------------------- */
|
|
574
|
-
|
|
575
|
-
.hp-combobox {
|
|
576
|
-
/* Bits UI's hidden form input is absolutely placed in here. */
|
|
577
|
-
position: relative;
|
|
578
|
-
display: grid;
|
|
579
|
-
gap: var(--space-1);
|
|
580
|
-
min-width: 0;
|
|
581
|
-
}
|
|
582
|
-
|
|
583
|
-
.hp-combobox-label {
|
|
584
|
-
color: var(--muted);
|
|
585
|
-
}
|
|
586
|
-
|
|
587
|
-
/* The input or the trigger is base.css's well; its wrapper draws the
|
|
588
|
-
chevron, like a select's. The wrapper is the grid item, so it holds
|
|
589
|
-
the width and the minimum. */
|
|
590
|
-
.hp-combobox-field {
|
|
591
|
-
width: 100%;
|
|
592
|
-
min-width: 136px;
|
|
593
|
-
}
|
|
594
|
-
.hp-combobox-input {
|
|
595
|
-
display: block;
|
|
596
|
-
width: 100%;
|
|
597
|
-
min-width: 0;
|
|
598
|
-
}
|
|
599
|
-
.hp-combobox-input::placeholder {
|
|
600
|
-
color: var(--muted);
|
|
601
|
-
opacity: 1;
|
|
602
|
-
}
|
|
603
|
-
.hp-combobox-trigger {
|
|
604
|
-
display: flex;
|
|
605
|
-
align-items: center;
|
|
606
|
-
width: 100%;
|
|
607
|
-
text-align: start;
|
|
608
|
-
cursor: pointer;
|
|
609
|
-
-webkit-tap-highlight-color: transparent;
|
|
610
|
-
}
|
|
611
|
-
.hp-combobox-trigger:disabled {
|
|
612
|
-
cursor: not-allowed;
|
|
613
|
-
}
|
|
614
|
-
.hp-combobox-trigger[data-placeholder] {
|
|
615
|
-
color: var(--muted);
|
|
616
|
-
}
|
|
617
|
-
.hp-combobox-value {
|
|
618
|
-
min-width: 0;
|
|
619
|
-
overflow: hidden;
|
|
620
|
-
text-overflow: ellipsis;
|
|
621
|
-
white-space: nowrap;
|
|
622
|
-
}
|
|
623
|
-
|
|
624
|
-
/* Invalid: the boundary turns --danger, never alone: the icon and the
|
|
625
|
-
message say what is wrong (as TextField). It fades to --danger over
|
|
626
|
-
--dur-message with the message and goes back at once when the error
|
|
627
|
-
clears, because the transition belongs to this rule. The hover fill
|
|
628
|
-
keeps base.css's timing. */
|
|
629
|
-
.hp-combobox[data-invalid] .hp-well {
|
|
630
|
-
border-color: var(--danger);
|
|
631
|
-
transition:
|
|
632
|
-
background-color var(--dur-hover) var(--settle),
|
|
633
|
-
border-color var(--dur-message) var(--settle);
|
|
634
|
-
}
|
|
635
|
-
.hp-combobox[data-invalid] .hp-well:disabled {
|
|
636
|
-
border-color: var(--border);
|
|
637
|
-
}
|
|
638
|
-
|
|
639
|
-
/* ---- Hint and error, as TextField's -------------------------------- */
|
|
640
|
-
|
|
641
|
-
/* The first message starts 8px under the well, clear of the focus
|
|
642
|
-
contour (2px, drawn 4px outside the well); 4px between messages. */
|
|
643
|
-
.hp-combobox-messages {
|
|
644
|
-
display: grid;
|
|
645
|
-
gap: var(--space-1);
|
|
646
|
-
margin-top: var(--space-1);
|
|
647
|
-
}
|
|
648
|
-
.hp-combobox-hint {
|
|
649
|
-
margin: 0;
|
|
650
|
-
color: var(--muted);
|
|
651
|
-
}
|
|
652
|
-
.hp-combobox-error {
|
|
653
|
-
display: flex;
|
|
654
|
-
gap: var(--space-2);
|
|
655
|
-
margin: 0;
|
|
656
|
-
color: var(--danger);
|
|
657
|
-
}
|
|
658
|
-
/* One line box high, so the icon centres on the first line of the
|
|
659
|
-
message however it wraps. */
|
|
660
|
-
.hp-combobox-error-icon {
|
|
661
|
-
display: flex;
|
|
662
|
-
flex: none;
|
|
663
|
-
align-items: center;
|
|
664
|
-
height: 1lh;
|
|
665
|
-
}
|
|
666
|
-
.hp-combobox-error-icon svg {
|
|
667
|
-
display: block;
|
|
668
|
-
}
|
|
669
|
-
|
|
670
|
-
/* ---- Label beside the field: always, or from a breakpoint up ------- */
|
|
671
|
-
|
|
672
|
-
/* The label keeps its one line in the first column; the field and its
|
|
673
|
-
messages share the second. */
|
|
674
|
-
.hp-combobox[data-label-beside='always'] {
|
|
675
|
-
grid-template-columns: auto minmax(0, 1fr);
|
|
676
|
-
column-gap: var(--space-2);
|
|
677
|
-
}
|
|
678
|
-
.hp-combobox[data-label-beside='always'] .hp-combobox-label {
|
|
679
|
-
grid-row: 1;
|
|
680
|
-
align-self: center;
|
|
681
|
-
white-space: nowrap;
|
|
682
|
-
}
|
|
683
|
-
.hp-combobox[data-label-beside='always'] .hp-combobox-field,
|
|
684
|
-
.hp-combobox[data-label-beside='always'] .hp-combobox-messages {
|
|
685
|
-
grid-column: 2;
|
|
686
|
-
}
|
|
687
|
-
@media (min-width: 600px) {
|
|
688
|
-
.hp-combobox[data-label-beside='medium'] {
|
|
689
|
-
grid-template-columns: auto minmax(0, 1fr);
|
|
690
|
-
column-gap: var(--space-2);
|
|
691
|
-
}
|
|
692
|
-
.hp-combobox[data-label-beside='medium'] .hp-combobox-label {
|
|
693
|
-
grid-row: 1;
|
|
694
|
-
align-self: center;
|
|
695
|
-
white-space: nowrap;
|
|
696
|
-
}
|
|
697
|
-
.hp-combobox[data-label-beside='medium'] .hp-combobox-field,
|
|
698
|
-
.hp-combobox[data-label-beside='medium'] .hp-combobox-messages {
|
|
699
|
-
grid-column: 2;
|
|
700
|
-
}
|
|
701
|
-
}
|
|
702
|
-
@media (min-width: 900px) {
|
|
703
|
-
.hp-combobox[data-label-beside='expanded'] {
|
|
704
|
-
grid-template-columns: auto minmax(0, 1fr);
|
|
705
|
-
column-gap: var(--space-2);
|
|
706
|
-
}
|
|
707
|
-
.hp-combobox[data-label-beside='expanded'] .hp-combobox-label {
|
|
708
|
-
grid-row: 1;
|
|
709
|
-
align-self: center;
|
|
710
|
-
white-space: nowrap;
|
|
711
|
-
}
|
|
712
|
-
.hp-combobox[data-label-beside='expanded'] .hp-combobox-field,
|
|
713
|
-
.hp-combobox[data-label-beside='expanded'] .hp-combobox-messages {
|
|
714
|
-
grid-column: 2;
|
|
715
|
-
}
|
|
716
|
-
}
|
|
717
|
-
@media (min-width: 1200px) {
|
|
718
|
-
.hp-combobox[data-label-beside='large'] {
|
|
719
|
-
grid-template-columns: auto minmax(0, 1fr);
|
|
720
|
-
column-gap: var(--space-2);
|
|
721
|
-
}
|
|
722
|
-
.hp-combobox[data-label-beside='large'] .hp-combobox-label {
|
|
723
|
-
grid-row: 1;
|
|
724
|
-
align-self: center;
|
|
725
|
-
white-space: nowrap;
|
|
726
|
-
}
|
|
727
|
-
.hp-combobox[data-label-beside='large'] .hp-combobox-field,
|
|
728
|
-
.hp-combobox[data-label-beside='large'] .hp-combobox-messages {
|
|
729
|
-
grid-column: 2;
|
|
730
|
-
}
|
|
731
|
-
}
|
|
732
|
-
|
|
733
|
-
/* ---- The list: a popover panel on medium and up, the sheet's body on
|
|
734
|
-
compact ---------------------------------------------------------- */
|
|
735
|
-
|
|
736
|
-
/* The overlay parts render in a portal at the end of <body>, outside
|
|
737
|
-
.hp-combobox, so they carry this themselves. */
|
|
738
|
-
.hp-combobox-panel,
|
|
739
|
-
.hp-combobox-list,
|
|
740
|
-
.hp-combobox-empty {
|
|
741
|
-
/* Option text lines up with the field's text (popover) or the
|
|
742
|
-
sheet's title (sheet). */
|
|
743
|
-
--option-inset: var(--space-3);
|
|
744
|
-
/* The popover list's cap: eight and a half 36px rows, so a cut row
|
|
745
|
-
shows there is more. */
|
|
746
|
-
--combobox-list-height: 320px;
|
|
747
|
-
}
|
|
748
|
-
|
|
749
|
-
/* The panel draws the frame round the listbox and the empty line, on the
|
|
750
|
-
popover layer (tokens.css), over the shell and an open dialog. */
|
|
751
|
-
.hp-combobox-panel {
|
|
752
|
-
position: relative;
|
|
753
|
-
z-index: var(--layer-popover);
|
|
754
|
-
display: flex;
|
|
755
|
-
flex-direction: column;
|
|
756
|
-
box-sizing: border-box;
|
|
757
|
-
border: 1px solid var(--border-control);
|
|
758
|
-
background: var(--canvas);
|
|
759
|
-
color: var(--text);
|
|
760
|
-
font-family: var(--sans);
|
|
761
|
-
}
|
|
762
|
-
|
|
763
|
-
.hp-combobox-list {
|
|
764
|
-
box-sizing: border-box;
|
|
765
|
-
background: var(--canvas);
|
|
766
|
-
color: var(--text);
|
|
767
|
-
font-family: var(--sans);
|
|
768
|
-
}
|
|
769
|
-
/* As wide as the field at least, inside the panel's two 1px borders. */
|
|
770
|
-
.hp-combobox-list[data-form='popover'] {
|
|
771
|
-
min-width: calc(var(--bits-combobox-anchor-width) - 2px);
|
|
772
|
-
max-width: max(
|
|
773
|
-
calc(var(--bits-combobox-anchor-width) - 2px),
|
|
774
|
-
min(calc(var(--bits-combobox-content-available-width) - 2px), 480px)
|
|
775
|
-
);
|
|
776
|
-
max-height: min(var(--combobox-list-height), calc(var(--bits-combobox-content-available-height) - 2px));
|
|
777
|
-
}
|
|
778
|
-
/* The sheet keeps the height it opened with while the list filters, so
|
|
779
|
-
the search input never moves under the typing thumb: the list asks for
|
|
780
|
-
the sheet's whole cap (--sheet-max-height from internal/Sheet.svelte:
|
|
781
|
-
half the screen, or the room above the on-screen keyboard) and shrinks
|
|
782
|
-
into what the title and search rows leave. */
|
|
783
|
-
.hp-combobox-list[data-form='sheet'] {
|
|
784
|
-
--option-inset: var(--gutter);
|
|
785
|
-
flex: 1 1 auto;
|
|
786
|
-
height: var(--sheet-max-height);
|
|
787
|
-
min-height: 0;
|
|
788
|
-
}
|
|
789
|
-
|
|
790
|
-
/* Bits UI hides the viewport's scrollbar; a long list shows where it is. */
|
|
791
|
-
.hp-combobox-viewport[data-combobox-viewport] {
|
|
792
|
-
scrollbar-width: thin !important;
|
|
793
|
-
overscroll-behavior: contain;
|
|
794
|
-
padding-block: var(--space-1);
|
|
795
|
-
}
|
|
796
|
-
.hp-combobox-list[data-empty] .hp-combobox-viewport[data-combobox-viewport] {
|
|
797
|
-
padding-block: 0;
|
|
798
|
-
}
|
|
799
|
-
/* An option the keys or the chosen value scroll to stays clear of the
|
|
800
|
-
group heading stuck at the top: one heading line high. */
|
|
801
|
-
.hp-combobox-viewport[data-grouped] {
|
|
802
|
-
scroll-padding-top: calc(var(--space-2) + 16px + var(--space-1));
|
|
803
|
-
}
|
|
804
|
-
|
|
805
|
-
/* ---- Group headings: as Select's ------------------------------------ */
|
|
806
|
-
|
|
807
|
-
/* MenuGroup's heading: muted, on the options' text edge, 8px above and
|
|
808
|
-
4px below. The text stays as written, because a group name may be the
|
|
809
|
-
app's data (BRIEF, style rule 4). It stays at the top of the list while
|
|
810
|
-
its options scroll under it, until the next group pushes it up; that is
|
|
811
|
-
scrolling, not motion. It sticks to the viewport's edge, over its 4px
|
|
812
|
-
padding, so nothing shows above it. */
|
|
813
|
-
.hp-combobox-group-heading {
|
|
814
|
-
position: sticky;
|
|
815
|
-
top: calc(-1 * var(--space-1));
|
|
816
|
-
z-index: 1;
|
|
817
|
-
padding: var(--space-2) var(--option-inset) var(--space-1);
|
|
818
|
-
background: var(--canvas);
|
|
819
|
-
color: var(--muted);
|
|
820
|
-
font-size: var(--type-label);
|
|
821
|
-
font-weight: 500;
|
|
822
|
-
line-height: 16px;
|
|
823
|
-
overflow-wrap: anywhere;
|
|
824
|
-
user-select: none;
|
|
825
|
-
}
|
|
826
|
-
|
|
827
|
-
/* ---- Options: Geist list items, sentence case ----------------------- */
|
|
828
|
-
|
|
829
|
-
.hp-combobox-option {
|
|
830
|
-
display: flex;
|
|
831
|
-
flex-direction: column;
|
|
832
|
-
justify-content: center;
|
|
833
|
-
box-sizing: border-box;
|
|
834
|
-
/* 36px rows on fine pointers, 44px on coarse. */
|
|
835
|
-
min-height: var(--control-height);
|
|
836
|
-
padding: var(--space-1) var(--option-inset);
|
|
837
|
-
font-size: var(--type-body);
|
|
838
|
-
line-height: 24px;
|
|
839
|
-
overflow-wrap: anywhere;
|
|
840
|
-
cursor: pointer;
|
|
841
|
-
user-select: none;
|
|
842
|
-
-webkit-tap-highlight-color: transparent;
|
|
843
|
-
}
|
|
844
|
-
.hp-combobox-option[data-described] {
|
|
845
|
-
padding-block: var(--space-2);
|
|
846
|
-
}
|
|
847
|
-
.hp-combobox-option-description {
|
|
848
|
-
color: var(--muted);
|
|
849
|
-
font-size: var(--type-caption);
|
|
850
|
-
line-height: 18px;
|
|
851
|
-
}
|
|
852
|
-
|
|
853
|
-
/* Highlight, by pointer or keys: the hover tint. Selection snaps and
|
|
854
|
-
filtering never animates, so nothing here transitions (BRIEF, motion
|
|
855
|
-
rule 3 and the frequency check). */
|
|
856
|
-
.hp-combobox-option[data-highlighted],
|
|
857
|
-
.hp-combobox-option:active {
|
|
858
|
-
background: var(--surface-hover);
|
|
859
|
-
}
|
|
860
|
-
/* Selected: a 3px titanium edge and the tint, text unchanged. Hover and
|
|
861
|
-
press never darken it. */
|
|
862
|
-
.hp-combobox-option[data-selected] {
|
|
863
|
-
background: var(--surface-selected);
|
|
864
|
-
box-shadow: inset 3px 0 0 var(--accent);
|
|
865
|
-
}
|
|
866
|
-
/* Keys move a virtual focus (focus stays in the input), so the
|
|
867
|
-
highlight they move also draws the focus contour, inside the row. */
|
|
868
|
-
.hp-combobox-list[data-keyboard] .hp-combobox-option[data-highlighted] {
|
|
869
|
-
outline: 2px solid var(--focus);
|
|
870
|
-
outline-offset: -2px;
|
|
871
|
-
}
|
|
872
|
-
.hp-combobox-option[data-disabled] {
|
|
873
|
-
color: var(--muted);
|
|
874
|
-
cursor: not-allowed;
|
|
875
|
-
}
|
|
876
|
-
|
|
877
|
-
/* Nothing matches: one muted line where the options were. */
|
|
878
|
-
.hp-combobox-empty {
|
|
879
|
-
margin: 0;
|
|
880
|
-
padding: var(--space-2) var(--option-inset);
|
|
881
|
-
color: var(--muted);
|
|
882
|
-
font-size: var(--type-body);
|
|
883
|
-
line-height: 24px;
|
|
884
|
-
}
|
|
885
|
-
.hp-combobox-empty:empty {
|
|
886
|
-
padding: 0;
|
|
887
|
-
}
|
|
888
|
-
|
|
889
|
-
/* ---- Sheet (compact): the search input over the list ---------------- */
|
|
890
|
-
|
|
891
|
-
.hp-combobox-search {
|
|
892
|
-
flex: none;
|
|
893
|
-
/* 8px above the well: its focus contour (2px, 4px out) stays inside
|
|
894
|
-
the sheet body, which clips. */
|
|
895
|
-
padding: var(--space-2) var(--gutter);
|
|
896
|
-
}
|
|
897
|
-
/* In the sheet the empty line keeps the gutter inset, as the options do. */
|
|
898
|
-
.hp-combobox-empty[data-form='sheet'] {
|
|
899
|
-
--option-inset: var(--gutter);
|
|
900
|
-
}
|
|
901
|
-
</style>
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
import type { HTMLAttributes } from 'svelte/elements';
|
|
4
|
+
|
|
5
|
+
export type ComboboxItem = {
|
|
6
|
+
value: string;
|
|
7
|
+
/** The option's text: what the default filter matches, and the field's text once chosen. */
|
|
8
|
+
label: string;
|
|
9
|
+
/** A second line in `--muted`. */
|
|
10
|
+
description?: string;
|
|
11
|
+
/**
|
|
12
|
+
* The heading the option sits under, as written; the default filter
|
|
13
|
+
* matches it too. Options with the same group share one heading, in the
|
|
14
|
+
* order each group first appears; options without one come first.
|
|
15
|
+
*/
|
|
16
|
+
group?: string;
|
|
17
|
+
disabled?: boolean;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type ComboboxItemSnippetProps = {
|
|
21
|
+
item: ComboboxItem;
|
|
22
|
+
selected: boolean;
|
|
23
|
+
highlighted: boolean;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type ComboboxLabelBeside = boolean | 'medium' | 'expanded' | 'large';
|
|
27
|
+
|
|
28
|
+
/** Whether an item stays in the list for the typed text (trimmed, never empty). */
|
|
29
|
+
export type ComboboxFilter = (item: ComboboxItem, query: string) => boolean;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Attributes and handlers not listed here go to the field control: the
|
|
33
|
+
* text input from 600px, the button that opens the sheet below.
|
|
34
|
+
*/
|
|
35
|
+
export type ComboboxProps = Omit<HTMLAttributes<HTMLElement>, 'children' | 'id'> & {
|
|
36
|
+
items: ComboboxItem[];
|
|
37
|
+
/** The chosen item's `value`; `''` for none. It changes only by choosing from the list. */
|
|
38
|
+
value?: string;
|
|
39
|
+
open?: boolean;
|
|
40
|
+
/** The caps field label above the field, and the sheet's title on compact widths. */
|
|
41
|
+
label: string;
|
|
42
|
+
/** Shown in `--muted` in the empty field and in the sheet's search input. Never the only label. */
|
|
43
|
+
placeholder?: string;
|
|
44
|
+
/** Guidance under the field in `--muted`, linked by `aria-describedby`. */
|
|
45
|
+
hint?: string;
|
|
46
|
+
/**
|
|
47
|
+
* The error to show under the field, with an icon, linked by
|
|
48
|
+
* `aria-describedby`; it sets `aria-invalid`. Say how to fix it.
|
|
49
|
+
*/
|
|
50
|
+
error?: string;
|
|
51
|
+
disabled?: boolean;
|
|
52
|
+
/** Submits the value with a form, through a hidden input. */
|
|
53
|
+
name?: string;
|
|
54
|
+
/**
|
|
55
|
+
* The label beside the field instead of above it: always (`true`), or
|
|
56
|
+
* from a breakpoint up (`medium` 600px, `expanded` 900px, `large` 1200px).
|
|
57
|
+
* The hint and the error stay under the field.
|
|
58
|
+
*/
|
|
59
|
+
labelBeside?: ComboboxLabelBeside;
|
|
60
|
+
/**
|
|
61
|
+
* Replaces the default filter: the label or the group contains the text,
|
|
62
|
+
* ignoring case and accents.
|
|
63
|
+
*/
|
|
64
|
+
filter?: ComboboxFilter;
|
|
65
|
+
/** Shown in place of the options when nothing matches. */
|
|
66
|
+
emptyText?: string;
|
|
67
|
+
/** The sheet's close button name. */
|
|
68
|
+
closeLabel?: string;
|
|
69
|
+
/** The field control's id; the label points at it. */
|
|
70
|
+
id?: string;
|
|
71
|
+
/** The field control: the text input from 600px, the button below. */
|
|
72
|
+
ref?: HTMLElement | null;
|
|
73
|
+
/** Renders an option's content in place of its label and description. */
|
|
74
|
+
item?: Snippet<[ComboboxItemSnippetProps]>;
|
|
75
|
+
};
|
|
76
|
+
</script>
|
|
77
|
+
|
|
78
|
+
<script lang="ts">
|
|
79
|
+
import { Combobox as BitsCombobox } from 'bits-ui';
|
|
80
|
+
import { untrack } from 'svelte';
|
|
81
|
+
import { MediaQuery } from 'svelte/reactivity';
|
|
82
|
+
import { pop } from '../motion/index.js';
|
|
83
|
+
import { fadeIn } from './internal/fade-in.js';
|
|
84
|
+
import { groupOptions } from './internal/groups.js';
|
|
85
|
+
import Sheet from './internal/Sheet.svelte';
|
|
86
|
+
|
|
87
|
+
const uid = $props.id();
|
|
88
|
+
|
|
89
|
+
let {
|
|
90
|
+
items,
|
|
91
|
+
value = $bindable(''),
|
|
92
|
+
open = $bindable(false),
|
|
93
|
+
label,
|
|
94
|
+
placeholder = '',
|
|
95
|
+
hint,
|
|
96
|
+
error,
|
|
97
|
+
disabled = false,
|
|
98
|
+
name,
|
|
99
|
+
labelBeside = false,
|
|
100
|
+
filter,
|
|
101
|
+
emptyText = 'No matches',
|
|
102
|
+
closeLabel = 'Close',
|
|
103
|
+
id = `${uid}-field`,
|
|
104
|
+
ref = $bindable(null),
|
|
105
|
+
item: itemContent,
|
|
106
|
+
class: className,
|
|
107
|
+
onclick: onclickProp,
|
|
108
|
+
oninput: oninputProp,
|
|
109
|
+
'aria-describedby': describedBy,
|
|
110
|
+
'aria-invalid': ariaInvalid,
|
|
111
|
+
...rest
|
|
112
|
+
}: ComboboxProps = $props();
|
|
113
|
+
|
|
114
|
+
const labelId = `${uid}-label`;
|
|
115
|
+
const sheetId = `${uid}-sheet`;
|
|
116
|
+
// The listbox, in the popover or the sheet; the input that drives it
|
|
117
|
+
// says so with aria-controls, which Bits UI leaves out.
|
|
118
|
+
const listId = `${uid}-list`;
|
|
119
|
+
const sheetTitleId = `${uid}-sheet-title`;
|
|
120
|
+
const hintId = $derived(`${id}-hint`);
|
|
121
|
+
const errorId = $derived(`${id}-error`);
|
|
122
|
+
// The error first, so a screen reader reads it before the hint.
|
|
123
|
+
const describedByIds = $derived(
|
|
124
|
+
[error ? errorId : undefined, hint ? hintId : undefined, describedBy].filter(Boolean).join(' ') ||
|
|
125
|
+
undefined
|
|
126
|
+
);
|
|
127
|
+
const invalid = $derived(error ? true : ariaInvalid);
|
|
128
|
+
|
|
129
|
+
// Short motion (BRIEF, --dur-message), as TextField: an error that
|
|
130
|
+
// appears after the first render fades in from 0 and the field's edge
|
|
131
|
+
// fades to --danger with it; one that clears goes at once. A field that
|
|
132
|
+
// renders or mounts with an error shows it at once. 0ms is a snap.
|
|
133
|
+
let root: HTMLDivElement | null = $state(null);
|
|
134
|
+
let errorShown = untrack(() => !!error);
|
|
135
|
+
$effect(() => {
|
|
136
|
+
const shown = !!error;
|
|
137
|
+
untrack(() => {
|
|
138
|
+
const appeared = shown && !errorShown;
|
|
139
|
+
errorShown = shown;
|
|
140
|
+
const message = appeared ? root?.querySelector(':scope > .hp-combobox-messages > .hp-combobox-error') : null;
|
|
141
|
+
if (message) fadeIn(message, '--dur-message');
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// Compact (< 600px) opens a bottom sheet with the search input inside;
|
|
146
|
+
// medium and up type in the field and list under it (BRIEF, Apps and
|
|
147
|
+
// phones: Overlays and Choices).
|
|
148
|
+
const wide = new MediaQuery('min-width: 600px', true);
|
|
149
|
+
|
|
150
|
+
const chosen = $derived(items.find((i) => i.value === value));
|
|
151
|
+
// A string, so a fresh `items` array with the same choice changes nothing.
|
|
152
|
+
const chosenLabel = $derived(chosen?.label ?? '');
|
|
153
|
+
|
|
154
|
+
// The input's text. It shows the chosen label while the list is closed;
|
|
155
|
+
// typing overrides it, and closing puts the label back, so the value
|
|
156
|
+
// only ever comes from the list.
|
|
157
|
+
let text = $derived(chosenLabel);
|
|
158
|
+
// What the list is filtered by: only what was typed since it opened, so
|
|
159
|
+
// it opens with every option even when the field shows a label.
|
|
160
|
+
let query = $state('');
|
|
161
|
+
|
|
162
|
+
const fold = (s: string) => s.normalize('NFD').replace(/\p{Diacritic}/gu, '').toLowerCase();
|
|
163
|
+
const byLabelOrGroup: ComboboxFilter = (entry, q) => {
|
|
164
|
+
const needle = fold(q);
|
|
165
|
+
return fold(entry.label).includes(needle) || (!!entry.group && fold(entry.group).includes(needle));
|
|
166
|
+
};
|
|
167
|
+
const matches = $derived.by(() => {
|
|
168
|
+
const q = query.trim();
|
|
169
|
+
if (!q) return items;
|
|
170
|
+
const keep = filter ?? byLabelOrGroup;
|
|
171
|
+
return items.filter((entry) => keep(entry, q));
|
|
172
|
+
});
|
|
173
|
+
// Options without a group, then each group that still has a match; a
|
|
174
|
+
// group whose options all filter out drops its heading too.
|
|
175
|
+
const grouped = $derived(groupOptions(matches));
|
|
176
|
+
|
|
177
|
+
// The highlight moved by keys gets the focus contour; a pointer's gets the
|
|
178
|
+
// hover tint alone. Focus itself stays in the input.
|
|
179
|
+
let keyboard = $state(false);
|
|
180
|
+
|
|
181
|
+
// Whether Ctrl or Meta was held at the last key press in the field. Bits
|
|
182
|
+
// UI opens the list on any key, Ctrl and Meta included, so a shortcut
|
|
183
|
+
// such as Ctrl+C opens it too; that open keeps the field's selection, so
|
|
184
|
+
// the shortcut copies what the person selected, not the whole label.
|
|
185
|
+
let shortcut = false;
|
|
186
|
+
|
|
187
|
+
let panelNode = $state<HTMLElement | null>(null);
|
|
188
|
+
let sheetNode = $state<HTMLElement | null>(null);
|
|
189
|
+
|
|
190
|
+
// Every open and close passes here once, whoever caused it: Bits UI
|
|
191
|
+
// (typing, Escape, an outside press, a choice, Tab), the field, the
|
|
192
|
+
// sheet's close button and swipe, or the app through `bind:open`.
|
|
193
|
+
let settled = false;
|
|
194
|
+
function openChanged(next: boolean) {
|
|
195
|
+
if (next === settled) return;
|
|
196
|
+
settled = next;
|
|
197
|
+
query = '';
|
|
198
|
+
if (next) {
|
|
199
|
+
if (!wide.current) {
|
|
200
|
+
// The sheet's search input starts empty.
|
|
201
|
+
text = '';
|
|
202
|
+
} else if (!shortcut && ref instanceof HTMLInputElement && document.activeElement === ref) {
|
|
203
|
+
// Opened in the field, by a press or a key: the text is selected
|
|
204
|
+
// (before a typed character lands), so typing replaces the label
|
|
205
|
+
// and filters on what was typed alone.
|
|
206
|
+
ref.select();
|
|
207
|
+
}
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
text = chosenLabel;
|
|
211
|
+
// The sheet held focus; give it back to the field.
|
|
212
|
+
if (!wide.current) {
|
|
213
|
+
const active = document.activeElement;
|
|
214
|
+
if (!active || active === document.body || sheetNode?.contains(active)) ref?.focus();
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
$effect.pre(() => {
|
|
218
|
+
const next = open;
|
|
219
|
+
untrack(() => openChanged(next));
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
function setOpen(next: boolean) {
|
|
223
|
+
open = next;
|
|
224
|
+
openChanged(next);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function onInput(e: Event & { currentTarget: EventTarget & HTMLInputElement }) {
|
|
228
|
+
oninputProp?.(e);
|
|
229
|
+
query = e.currentTarget.value;
|
|
230
|
+
text = query;
|
|
231
|
+
// The matches show from the top, so Bits UI's highlight, which goes to
|
|
232
|
+
// the first option in view, lands on the first match, and no match
|
|
233
|
+
// waits above the fold or under a group heading.
|
|
234
|
+
const viewport = document.getElementById(listId)?.querySelector('[data-combobox-viewport]');
|
|
235
|
+
if (viewport) viewport.scrollTop = 0;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// A press on the field opens the list (openChanged selects its text).
|
|
239
|
+
// While open, a press on the chevron end closes it; one on the text
|
|
240
|
+
// places the caret. A label's click (detail 0) only focuses.
|
|
241
|
+
function onFieldClick(e: MouseEvent & { currentTarget: EventTarget & HTMLInputElement }) {
|
|
242
|
+
onclickProp?.(e);
|
|
243
|
+
if (disabled || e.detail === 0) return;
|
|
244
|
+
const input = e.currentTarget;
|
|
245
|
+
if (!open) {
|
|
246
|
+
setOpen(true);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
const box = input.getBoundingClientRect();
|
|
250
|
+
if (e.clientX >= box.right - parseFloat(getComputedStyle(input).paddingRight)) setOpen(false);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function onTriggerClick(e: MouseEvent & { currentTarget: EventTarget & HTMLButtonElement }) {
|
|
254
|
+
onclickProp?.(e);
|
|
255
|
+
if (!disabled) setOpen(true);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// As on Select's trigger: the arrow keys open the sheet too.
|
|
259
|
+
function onTriggerKeydown(e: KeyboardEvent & { currentTarget: EventTarget & HTMLButtonElement }) {
|
|
260
|
+
rest.onkeydown?.(e);
|
|
261
|
+
if (disabled || e.defaultPrevented) return;
|
|
262
|
+
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
|
263
|
+
e.preventDefault();
|
|
264
|
+
setOpen(true);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function close() {
|
|
269
|
+
setOpen(false);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// The panel's empty line and the sheet's title row sit outside the
|
|
273
|
+
// listbox; pressing them is not an outside press.
|
|
274
|
+
function insidePanel(e: PointerEvent) {
|
|
275
|
+
if (panelNode && e.target instanceof Node && panelNode.contains(e.target)) e.preventDefault();
|
|
276
|
+
}
|
|
277
|
+
function insideSheet(e: PointerEvent) {
|
|
278
|
+
if (sheetNode && e.target instanceof Node && sheetNode.contains(e.target)) e.preventDefault();
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Presses on the overlay's chrome keep focus in the input; a press on the
|
|
282
|
+
// sheet's search input itself still focuses it.
|
|
283
|
+
function keepFocus(e: PointerEvent) {
|
|
284
|
+
if (!(e.target instanceof HTMLInputElement)) e.preventDefault();
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function focusOnMount(node: HTMLInputElement) {
|
|
288
|
+
node.focus({ preventScroll: true });
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// In the sheet, Bits UI never learns that its content is placed:
|
|
292
|
+
// ContentStatic with forceMount reports placement once, at mount, while
|
|
293
|
+
// still closed. So Bits neither opens it at the chosen option nor scrolls
|
|
294
|
+
// to the option the keys move to; this does both, nearest edge first, as
|
|
295
|
+
// Bits does in the popover (Select does the same). The viewport's
|
|
296
|
+
// scroll-padding keeps an option clear of a group heading stuck at the top.
|
|
297
|
+
function followHighlight(viewport: HTMLElement) {
|
|
298
|
+
const reveal = (option: Element | null) => {
|
|
299
|
+
if (!(option instanceof HTMLElement)) return;
|
|
300
|
+
const top = option.offsetTop - (parseFloat(getComputedStyle(viewport).scrollPaddingTop) || 0);
|
|
301
|
+
const bottom = option.offsetTop + option.offsetHeight;
|
|
302
|
+
if (top < viewport.scrollTop) viewport.scrollTop = top;
|
|
303
|
+
else if (bottom > viewport.scrollTop + viewport.clientHeight) {
|
|
304
|
+
viewport.scrollTop = bottom - viewport.clientHeight;
|
|
305
|
+
}
|
|
306
|
+
};
|
|
307
|
+
reveal(viewport.querySelector('[data-selected]'));
|
|
308
|
+
const observer = new MutationObserver((records) => {
|
|
309
|
+
if (!keyboard) return;
|
|
310
|
+
for (const { target } of records) {
|
|
311
|
+
if (target instanceof Element && target.hasAttribute('data-highlighted')) reveal(target);
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
observer.observe(viewport, { subtree: true, attributes: true, attributeFilter: ['data-highlighted'] });
|
|
315
|
+
return () => observer.disconnect();
|
|
316
|
+
}
|
|
317
|
+
</script>
|
|
318
|
+
|
|
319
|
+
<div
|
|
320
|
+
bind:this={root}
|
|
321
|
+
class={['hp-combobox', className]}
|
|
322
|
+
data-state={open ? 'open' : 'closed'}
|
|
323
|
+
data-disabled={disabled || undefined}
|
|
324
|
+
data-placeholder={chosen ? undefined : ''}
|
|
325
|
+
data-invalid={error ? '' : undefined}
|
|
326
|
+
data-label-beside={labelBeside === true ? 'always' : labelBeside || undefined}
|
|
327
|
+
onkeydowncapture={(e) => {
|
|
328
|
+
keyboard = true;
|
|
329
|
+
// Chrome on Windows reports AltGr as Ctrl+Alt; AltGr types a character.
|
|
330
|
+
shortcut = (e.ctrlKey || e.metaKey) && !e.altKey;
|
|
331
|
+
}}
|
|
332
|
+
onpointerdowncapture={() => {
|
|
333
|
+
keyboard = false;
|
|
334
|
+
shortcut = false;
|
|
335
|
+
}}
|
|
336
|
+
>
|
|
337
|
+
<label class="hp-combobox-label hp-label" id={labelId} for={id}>{label}</label>
|
|
338
|
+
|
|
339
|
+
<BitsCombobox.Root
|
|
340
|
+
type="single"
|
|
341
|
+
bind:value
|
|
342
|
+
bind:open
|
|
343
|
+
{items}
|
|
344
|
+
{disabled}
|
|
345
|
+
{name}
|
|
346
|
+
allowDeselect={false}
|
|
347
|
+
inputValue={text}
|
|
348
|
+
onOpenChange={openChanged}
|
|
349
|
+
>
|
|
350
|
+
{#if wide.current}
|
|
351
|
+
<BitsCombobox.Input
|
|
352
|
+
{...rest}
|
|
353
|
+
bind:ref
|
|
354
|
+
{id}
|
|
355
|
+
{placeholder}
|
|
356
|
+
autocomplete="off"
|
|
357
|
+
spellcheck={false}
|
|
358
|
+
aria-describedby={describedByIds}
|
|
359
|
+
aria-invalid={invalid}
|
|
360
|
+
oninput={onInput}
|
|
361
|
+
aria-controls={open ? listId : undefined}
|
|
362
|
+
onclick={onFieldClick}
|
|
363
|
+
>
|
|
364
|
+
{#snippet child({ props })}
|
|
365
|
+
<span class="hp-combobox-field hp-well-chevron">
|
|
366
|
+
<input {...props} class="hp-combobox-input hp-well" />
|
|
367
|
+
</span>
|
|
368
|
+
{/snippet}
|
|
369
|
+
</BitsCombobox.Input>
|
|
370
|
+
|
|
371
|
+
<BitsCombobox.Portal>
|
|
372
|
+
<BitsCombobox.Content
|
|
373
|
+
id={listId}
|
|
374
|
+
forceMount
|
|
375
|
+
side="bottom"
|
|
376
|
+
align="start"
|
|
377
|
+
sideOffset={8}
|
|
378
|
+
collisionPadding={8}
|
|
379
|
+
aria-labelledby={labelId}
|
|
380
|
+
onInteractOutside={insidePanel}
|
|
381
|
+
>
|
|
382
|
+
{#snippet child({ wrapperProps, props, open: shown })}
|
|
383
|
+
{#if shown}
|
|
384
|
+
<div {...wrapperProps}>
|
|
385
|
+
<div
|
|
386
|
+
class="hp-combobox-panel"
|
|
387
|
+
data-form="popover"
|
|
388
|
+
data-side={props['data-side'] as string | undefined}
|
|
389
|
+
bind:this={panelNode}
|
|
390
|
+
role="presentation"
|
|
391
|
+
onpointerdown={keepFocus}
|
|
392
|
+
transition:pop
|
|
393
|
+
>
|
|
394
|
+
{@render empty(false)}
|
|
395
|
+
<div
|
|
396
|
+
{...props}
|
|
397
|
+
class="hp-combobox-list"
|
|
398
|
+
data-form="popover"
|
|
399
|
+
data-empty={matches.length ? undefined : ''}
|
|
400
|
+
data-keyboard={keyboard || undefined}
|
|
401
|
+
onpointermovecapture={() => (keyboard = false)}
|
|
402
|
+
>
|
|
403
|
+
{@render options(false)}
|
|
404
|
+
</div>
|
|
405
|
+
</div>
|
|
406
|
+
</div>
|
|
407
|
+
{/if}
|
|
408
|
+
{/snippet}
|
|
409
|
+
</BitsCombobox.Content>
|
|
410
|
+
</BitsCombobox.Portal>
|
|
411
|
+
{:else}
|
|
412
|
+
<span class="hp-combobox-field hp-well-chevron">
|
|
413
|
+
<button
|
|
414
|
+
{...rest}
|
|
415
|
+
bind:this={ref}
|
|
416
|
+
type="button"
|
|
417
|
+
{id}
|
|
418
|
+
class="hp-combobox-trigger hp-well"
|
|
419
|
+
role="combobox"
|
|
420
|
+
aria-haspopup="dialog"
|
|
421
|
+
aria-expanded={open}
|
|
422
|
+
aria-controls={open ? sheetId : undefined}
|
|
423
|
+
aria-labelledby={labelId}
|
|
424
|
+
aria-describedby={describedByIds}
|
|
425
|
+
aria-invalid={invalid}
|
|
426
|
+
data-placeholder={chosen ? undefined : ''}
|
|
427
|
+
{disabled}
|
|
428
|
+
onclick={onTriggerClick}
|
|
429
|
+
onkeydown={onTriggerKeydown}
|
|
430
|
+
>
|
|
431
|
+
<span class="hp-combobox-value">{chosen?.label ?? placeholder}</span>
|
|
432
|
+
</button>
|
|
433
|
+
</span>
|
|
434
|
+
|
|
435
|
+
<BitsCombobox.Portal>
|
|
436
|
+
<BitsCombobox.ContentStatic id={listId} forceMount preventScroll onInteractOutside={insideSheet}>
|
|
437
|
+
{#snippet child({ props, open: shown })}
|
|
438
|
+
<Sheet
|
|
439
|
+
open={shown}
|
|
440
|
+
title={label}
|
|
441
|
+
titleId={sheetTitleId}
|
|
442
|
+
layer="popover"
|
|
443
|
+
onclose={close}
|
|
444
|
+
{closeLabel}
|
|
445
|
+
bind:ref={sheetNode}
|
|
446
|
+
id={sheetId}
|
|
447
|
+
role="dialog"
|
|
448
|
+
aria-modal="true"
|
|
449
|
+
aria-labelledby={sheetTitleId}
|
|
450
|
+
onpointerdown={keepFocus}
|
|
451
|
+
>
|
|
452
|
+
<div class="hp-combobox-search" onkeydowncapture={() => (keyboard = true)}>
|
|
453
|
+
<BitsCombobox.Input
|
|
454
|
+
{placeholder}
|
|
455
|
+
autocomplete="off"
|
|
456
|
+
spellcheck={false}
|
|
457
|
+
aria-labelledby={sheetTitleId}
|
|
458
|
+
aria-describedby={describedByIds}
|
|
459
|
+
aria-controls={open ? listId : undefined}
|
|
460
|
+
oninput={onInput}
|
|
461
|
+
>
|
|
462
|
+
{#snippet child({ props: inputProps })}
|
|
463
|
+
<input {...inputProps} class="hp-combobox-input hp-well" {@attach focusOnMount} />
|
|
464
|
+
{/snippet}
|
|
465
|
+
</BitsCombobox.Input>
|
|
466
|
+
</div>
|
|
467
|
+
{@render empty(true)}
|
|
468
|
+
<div
|
|
469
|
+
{...props}
|
|
470
|
+
class="hp-combobox-list"
|
|
471
|
+
data-form="sheet"
|
|
472
|
+
data-empty={matches.length ? undefined : ''}
|
|
473
|
+
aria-labelledby={sheetTitleId}
|
|
474
|
+
data-keyboard={keyboard || undefined}
|
|
475
|
+
onpointermovecapture={() => (keyboard = false)}
|
|
476
|
+
>
|
|
477
|
+
{@render options(true)}
|
|
478
|
+
</div>
|
|
479
|
+
</Sheet>
|
|
480
|
+
{/snippet}
|
|
481
|
+
</BitsCombobox.ContentStatic>
|
|
482
|
+
</BitsCombobox.Portal>
|
|
483
|
+
{/if}
|
|
484
|
+
</BitsCombobox.Root>
|
|
485
|
+
|
|
486
|
+
{#if hint || error}
|
|
487
|
+
<div class="hp-combobox-messages">
|
|
488
|
+
{#if hint}
|
|
489
|
+
<p class="hp-combobox-hint hp-caption" id={hintId}>{hint}</p>
|
|
490
|
+
{/if}
|
|
491
|
+
{#if error}
|
|
492
|
+
<p class="hp-combobox-error hp-caption" id={errorId}>
|
|
493
|
+
<span class="hp-combobox-error-icon" aria-hidden="true">
|
|
494
|
+
<svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor">
|
|
495
|
+
<path d="M1.5 1.5h13v13h-13z" />
|
|
496
|
+
<path d="M8 4v4M8 10v2" stroke-width="2" />
|
|
497
|
+
</svg>
|
|
498
|
+
</span>
|
|
499
|
+
<span>{error}</span>
|
|
500
|
+
</p>
|
|
501
|
+
{/if}
|
|
502
|
+
</div>
|
|
503
|
+
{/if}
|
|
504
|
+
</div>
|
|
505
|
+
|
|
506
|
+
{#snippet options(inSheet: boolean)}
|
|
507
|
+
<BitsCombobox.Viewport>
|
|
508
|
+
{#snippet child({ props: viewportProps })}
|
|
509
|
+
<div
|
|
510
|
+
{...viewportProps}
|
|
511
|
+
class="hp-combobox-viewport"
|
|
512
|
+
data-grouped={grouped.groups.length ? '' : undefined}
|
|
513
|
+
{@attach inSheet && followHighlight}
|
|
514
|
+
>
|
|
515
|
+
{#each grouped.loose as entry (entry.value)}
|
|
516
|
+
{@render option(entry)}
|
|
517
|
+
{/each}
|
|
518
|
+
{#each grouped.groups as group (group.name)}
|
|
519
|
+
<!-- role="group", named by its heading, which is no option: keys
|
|
520
|
+
skip it. -->
|
|
521
|
+
<BitsCombobox.Group>
|
|
522
|
+
{#snippet child({ props: groupProps })}
|
|
523
|
+
<div {...groupProps} class="hp-combobox-group">
|
|
524
|
+
<BitsCombobox.GroupHeading>
|
|
525
|
+
{#snippet child({ props: headingProps })}
|
|
526
|
+
<div {...headingProps} class="hp-combobox-group-heading">{group.name}</div>
|
|
527
|
+
{/snippet}
|
|
528
|
+
</BitsCombobox.GroupHeading>
|
|
529
|
+
{#each group.items as entry (entry.value)}
|
|
530
|
+
{@render option(entry)}
|
|
531
|
+
{/each}
|
|
532
|
+
</div>
|
|
533
|
+
{/snippet}
|
|
534
|
+
</BitsCombobox.Group>
|
|
535
|
+
{/each}
|
|
536
|
+
</div>
|
|
537
|
+
{/snippet}
|
|
538
|
+
</BitsCombobox.Viewport>
|
|
539
|
+
{/snippet}
|
|
540
|
+
|
|
541
|
+
{#snippet option(entry: ComboboxItem)}
|
|
542
|
+
<BitsCombobox.Item value={entry.value} label={entry.label} disabled={entry.disabled}>
|
|
543
|
+
{#snippet child({ props: optionProps, selected, highlighted })}
|
|
544
|
+
<div
|
|
545
|
+
{...optionProps}
|
|
546
|
+
class="hp-combobox-option"
|
|
547
|
+
data-described={entry.description ? '' : undefined}
|
|
548
|
+
aria-disabled={entry.disabled ? 'true' : undefined}
|
|
549
|
+
>
|
|
550
|
+
{#if itemContent}
|
|
551
|
+
{@render itemContent({ item: entry, selected, highlighted })}
|
|
552
|
+
{:else}
|
|
553
|
+
<span class="hp-combobox-option-label">{entry.label}</span>
|
|
554
|
+
{#if entry.description}
|
|
555
|
+
<span class="hp-combobox-option-description">{entry.description}</span>
|
|
556
|
+
{/if}
|
|
557
|
+
{/if}
|
|
558
|
+
</div>
|
|
559
|
+
{/snippet}
|
|
560
|
+
</BitsCombobox.Item>
|
|
561
|
+
{/snippet}
|
|
562
|
+
|
|
563
|
+
<!-- Outside the listbox, which may hold options and groups only. Mounted
|
|
564
|
+
while the list is open, so the text it gains when nothing matches is
|
|
565
|
+
announced. -->
|
|
566
|
+
{#snippet empty(inSheet: boolean)}
|
|
567
|
+
<p class="hp-combobox-empty" data-form={inSheet ? 'sheet' : 'popover'} role="status">
|
|
568
|
+
{#if !matches.length}{emptyText}{/if}
|
|
569
|
+
</p>
|
|
570
|
+
{/snippet}
|
|
571
|
+
|
|
572
|
+
<style>
|
|
573
|
+
/* ---- Field: caps label over a black well --------------------------- */
|
|
574
|
+
|
|
575
|
+
.hp-combobox {
|
|
576
|
+
/* Bits UI's hidden form input is absolutely placed in here. */
|
|
577
|
+
position: relative;
|
|
578
|
+
display: grid;
|
|
579
|
+
gap: var(--space-1);
|
|
580
|
+
min-width: 0;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
.hp-combobox-label {
|
|
584
|
+
color: var(--muted);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/* The input or the trigger is base.css's well; its wrapper draws the
|
|
588
|
+
chevron, like a select's. The wrapper is the grid item, so it holds
|
|
589
|
+
the width and the minimum. */
|
|
590
|
+
.hp-combobox-field {
|
|
591
|
+
width: 100%;
|
|
592
|
+
min-width: 136px;
|
|
593
|
+
}
|
|
594
|
+
.hp-combobox-input {
|
|
595
|
+
display: block;
|
|
596
|
+
width: 100%;
|
|
597
|
+
min-width: 0;
|
|
598
|
+
}
|
|
599
|
+
.hp-combobox-input::placeholder {
|
|
600
|
+
color: var(--muted);
|
|
601
|
+
opacity: 1;
|
|
602
|
+
}
|
|
603
|
+
.hp-combobox-trigger {
|
|
604
|
+
display: flex;
|
|
605
|
+
align-items: center;
|
|
606
|
+
width: 100%;
|
|
607
|
+
text-align: start;
|
|
608
|
+
cursor: pointer;
|
|
609
|
+
-webkit-tap-highlight-color: transparent;
|
|
610
|
+
}
|
|
611
|
+
.hp-combobox-trigger:disabled {
|
|
612
|
+
cursor: not-allowed;
|
|
613
|
+
}
|
|
614
|
+
.hp-combobox-trigger[data-placeholder] {
|
|
615
|
+
color: var(--muted);
|
|
616
|
+
}
|
|
617
|
+
.hp-combobox-value {
|
|
618
|
+
min-width: 0;
|
|
619
|
+
overflow: hidden;
|
|
620
|
+
text-overflow: ellipsis;
|
|
621
|
+
white-space: nowrap;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/* Invalid: the boundary turns --danger, never alone: the icon and the
|
|
625
|
+
message say what is wrong (as TextField). It fades to --danger over
|
|
626
|
+
--dur-message with the message and goes back at once when the error
|
|
627
|
+
clears, because the transition belongs to this rule. The hover fill
|
|
628
|
+
keeps base.css's timing. */
|
|
629
|
+
.hp-combobox[data-invalid] .hp-well {
|
|
630
|
+
border-color: var(--danger);
|
|
631
|
+
transition:
|
|
632
|
+
background-color var(--dur-hover) var(--settle),
|
|
633
|
+
border-color var(--dur-message) var(--settle);
|
|
634
|
+
}
|
|
635
|
+
.hp-combobox[data-invalid] .hp-well:disabled {
|
|
636
|
+
border-color: var(--border);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/* ---- Hint and error, as TextField's -------------------------------- */
|
|
640
|
+
|
|
641
|
+
/* The first message starts 8px under the well, clear of the focus
|
|
642
|
+
contour (2px, drawn 4px outside the well); 4px between messages. */
|
|
643
|
+
.hp-combobox-messages {
|
|
644
|
+
display: grid;
|
|
645
|
+
gap: var(--space-1);
|
|
646
|
+
margin-top: var(--space-1);
|
|
647
|
+
}
|
|
648
|
+
.hp-combobox-hint {
|
|
649
|
+
margin: 0;
|
|
650
|
+
color: var(--muted);
|
|
651
|
+
}
|
|
652
|
+
.hp-combobox-error {
|
|
653
|
+
display: flex;
|
|
654
|
+
gap: var(--space-2);
|
|
655
|
+
margin: 0;
|
|
656
|
+
color: var(--danger);
|
|
657
|
+
}
|
|
658
|
+
/* One line box high, so the icon centres on the first line of the
|
|
659
|
+
message however it wraps. */
|
|
660
|
+
.hp-combobox-error-icon {
|
|
661
|
+
display: flex;
|
|
662
|
+
flex: none;
|
|
663
|
+
align-items: center;
|
|
664
|
+
height: 1lh;
|
|
665
|
+
}
|
|
666
|
+
.hp-combobox-error-icon svg {
|
|
667
|
+
display: block;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/* ---- Label beside the field: always, or from a breakpoint up ------- */
|
|
671
|
+
|
|
672
|
+
/* The label keeps its one line in the first column; the field and its
|
|
673
|
+
messages share the second. */
|
|
674
|
+
.hp-combobox[data-label-beside='always'] {
|
|
675
|
+
grid-template-columns: auto minmax(0, 1fr);
|
|
676
|
+
column-gap: var(--space-2);
|
|
677
|
+
}
|
|
678
|
+
.hp-combobox[data-label-beside='always'] .hp-combobox-label {
|
|
679
|
+
grid-row: 1;
|
|
680
|
+
align-self: center;
|
|
681
|
+
white-space: nowrap;
|
|
682
|
+
}
|
|
683
|
+
.hp-combobox[data-label-beside='always'] .hp-combobox-field,
|
|
684
|
+
.hp-combobox[data-label-beside='always'] .hp-combobox-messages {
|
|
685
|
+
grid-column: 2;
|
|
686
|
+
}
|
|
687
|
+
@media (min-width: 600px) {
|
|
688
|
+
.hp-combobox[data-label-beside='medium'] {
|
|
689
|
+
grid-template-columns: auto minmax(0, 1fr);
|
|
690
|
+
column-gap: var(--space-2);
|
|
691
|
+
}
|
|
692
|
+
.hp-combobox[data-label-beside='medium'] .hp-combobox-label {
|
|
693
|
+
grid-row: 1;
|
|
694
|
+
align-self: center;
|
|
695
|
+
white-space: nowrap;
|
|
696
|
+
}
|
|
697
|
+
.hp-combobox[data-label-beside='medium'] .hp-combobox-field,
|
|
698
|
+
.hp-combobox[data-label-beside='medium'] .hp-combobox-messages {
|
|
699
|
+
grid-column: 2;
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
@media (min-width: 900px) {
|
|
703
|
+
.hp-combobox[data-label-beside='expanded'] {
|
|
704
|
+
grid-template-columns: auto minmax(0, 1fr);
|
|
705
|
+
column-gap: var(--space-2);
|
|
706
|
+
}
|
|
707
|
+
.hp-combobox[data-label-beside='expanded'] .hp-combobox-label {
|
|
708
|
+
grid-row: 1;
|
|
709
|
+
align-self: center;
|
|
710
|
+
white-space: nowrap;
|
|
711
|
+
}
|
|
712
|
+
.hp-combobox[data-label-beside='expanded'] .hp-combobox-field,
|
|
713
|
+
.hp-combobox[data-label-beside='expanded'] .hp-combobox-messages {
|
|
714
|
+
grid-column: 2;
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
@media (min-width: 1200px) {
|
|
718
|
+
.hp-combobox[data-label-beside='large'] {
|
|
719
|
+
grid-template-columns: auto minmax(0, 1fr);
|
|
720
|
+
column-gap: var(--space-2);
|
|
721
|
+
}
|
|
722
|
+
.hp-combobox[data-label-beside='large'] .hp-combobox-label {
|
|
723
|
+
grid-row: 1;
|
|
724
|
+
align-self: center;
|
|
725
|
+
white-space: nowrap;
|
|
726
|
+
}
|
|
727
|
+
.hp-combobox[data-label-beside='large'] .hp-combobox-field,
|
|
728
|
+
.hp-combobox[data-label-beside='large'] .hp-combobox-messages {
|
|
729
|
+
grid-column: 2;
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/* ---- The list: a popover panel on medium and up, the sheet's body on
|
|
734
|
+
compact ---------------------------------------------------------- */
|
|
735
|
+
|
|
736
|
+
/* The overlay parts render in a portal at the end of <body>, outside
|
|
737
|
+
.hp-combobox, so they carry this themselves. */
|
|
738
|
+
.hp-combobox-panel,
|
|
739
|
+
.hp-combobox-list,
|
|
740
|
+
.hp-combobox-empty {
|
|
741
|
+
/* Option text lines up with the field's text (popover) or the
|
|
742
|
+
sheet's title (sheet). */
|
|
743
|
+
--option-inset: var(--space-3);
|
|
744
|
+
/* The popover list's cap: eight and a half 36px rows, so a cut row
|
|
745
|
+
shows there is more. */
|
|
746
|
+
--combobox-list-height: 320px;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/* The panel draws the frame round the listbox and the empty line, on the
|
|
750
|
+
popover layer (tokens.css), over the shell and an open dialog. */
|
|
751
|
+
.hp-combobox-panel {
|
|
752
|
+
position: relative;
|
|
753
|
+
z-index: var(--layer-popover);
|
|
754
|
+
display: flex;
|
|
755
|
+
flex-direction: column;
|
|
756
|
+
box-sizing: border-box;
|
|
757
|
+
border: 1px solid var(--border-control);
|
|
758
|
+
background: var(--canvas);
|
|
759
|
+
color: var(--text);
|
|
760
|
+
font-family: var(--sans);
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
.hp-combobox-list {
|
|
764
|
+
box-sizing: border-box;
|
|
765
|
+
background: var(--canvas);
|
|
766
|
+
color: var(--text);
|
|
767
|
+
font-family: var(--sans);
|
|
768
|
+
}
|
|
769
|
+
/* As wide as the field at least, inside the panel's two 1px borders. */
|
|
770
|
+
.hp-combobox-list[data-form='popover'] {
|
|
771
|
+
min-width: calc(var(--bits-combobox-anchor-width) - 2px);
|
|
772
|
+
max-width: max(
|
|
773
|
+
calc(var(--bits-combobox-anchor-width) - 2px),
|
|
774
|
+
min(calc(var(--bits-combobox-content-available-width) - 2px), 480px)
|
|
775
|
+
);
|
|
776
|
+
max-height: min(var(--combobox-list-height), calc(var(--bits-combobox-content-available-height) - 2px));
|
|
777
|
+
}
|
|
778
|
+
/* The sheet keeps the height it opened with while the list filters, so
|
|
779
|
+
the search input never moves under the typing thumb: the list asks for
|
|
780
|
+
the sheet's whole cap (--sheet-max-height from internal/Sheet.svelte:
|
|
781
|
+
half the screen, or the room above the on-screen keyboard) and shrinks
|
|
782
|
+
into what the title and search rows leave. */
|
|
783
|
+
.hp-combobox-list[data-form='sheet'] {
|
|
784
|
+
--option-inset: var(--gutter);
|
|
785
|
+
flex: 1 1 auto;
|
|
786
|
+
height: var(--sheet-max-height);
|
|
787
|
+
min-height: 0;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/* Bits UI hides the viewport's scrollbar; a long list shows where it is. */
|
|
791
|
+
.hp-combobox-viewport[data-combobox-viewport] {
|
|
792
|
+
scrollbar-width: thin !important;
|
|
793
|
+
overscroll-behavior: contain;
|
|
794
|
+
padding-block: var(--space-1);
|
|
795
|
+
}
|
|
796
|
+
.hp-combobox-list[data-empty] .hp-combobox-viewport[data-combobox-viewport] {
|
|
797
|
+
padding-block: 0;
|
|
798
|
+
}
|
|
799
|
+
/* An option the keys or the chosen value scroll to stays clear of the
|
|
800
|
+
group heading stuck at the top: one heading line high. */
|
|
801
|
+
.hp-combobox-viewport[data-grouped] {
|
|
802
|
+
scroll-padding-top: calc(var(--space-2) + 16px + var(--space-1));
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/* ---- Group headings: as Select's ------------------------------------ */
|
|
806
|
+
|
|
807
|
+
/* MenuGroup's heading: muted, on the options' text edge, 8px above and
|
|
808
|
+
4px below. The text stays as written, because a group name may be the
|
|
809
|
+
app's data (BRIEF, style rule 4). It stays at the top of the list while
|
|
810
|
+
its options scroll under it, until the next group pushes it up; that is
|
|
811
|
+
scrolling, not motion. It sticks to the viewport's edge, over its 4px
|
|
812
|
+
padding, so nothing shows above it. */
|
|
813
|
+
.hp-combobox-group-heading {
|
|
814
|
+
position: sticky;
|
|
815
|
+
top: calc(-1 * var(--space-1));
|
|
816
|
+
z-index: 1;
|
|
817
|
+
padding: var(--space-2) var(--option-inset) var(--space-1);
|
|
818
|
+
background: var(--canvas);
|
|
819
|
+
color: var(--muted);
|
|
820
|
+
font-size: var(--type-label);
|
|
821
|
+
font-weight: 500;
|
|
822
|
+
line-height: 16px;
|
|
823
|
+
overflow-wrap: anywhere;
|
|
824
|
+
user-select: none;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/* ---- Options: Geist list items, sentence case ----------------------- */
|
|
828
|
+
|
|
829
|
+
.hp-combobox-option {
|
|
830
|
+
display: flex;
|
|
831
|
+
flex-direction: column;
|
|
832
|
+
justify-content: center;
|
|
833
|
+
box-sizing: border-box;
|
|
834
|
+
/* 36px rows on fine pointers, 44px on coarse. */
|
|
835
|
+
min-height: var(--control-height);
|
|
836
|
+
padding: var(--space-1) var(--option-inset);
|
|
837
|
+
font-size: var(--type-body);
|
|
838
|
+
line-height: 24px;
|
|
839
|
+
overflow-wrap: anywhere;
|
|
840
|
+
cursor: pointer;
|
|
841
|
+
user-select: none;
|
|
842
|
+
-webkit-tap-highlight-color: transparent;
|
|
843
|
+
}
|
|
844
|
+
.hp-combobox-option[data-described] {
|
|
845
|
+
padding-block: var(--space-2);
|
|
846
|
+
}
|
|
847
|
+
.hp-combobox-option-description {
|
|
848
|
+
color: var(--muted);
|
|
849
|
+
font-size: var(--type-caption);
|
|
850
|
+
line-height: 18px;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/* Highlight, by pointer or keys: the hover tint. Selection snaps and
|
|
854
|
+
filtering never animates, so nothing here transitions (BRIEF, motion
|
|
855
|
+
rule 3 and the frequency check). */
|
|
856
|
+
.hp-combobox-option[data-highlighted],
|
|
857
|
+
.hp-combobox-option:active {
|
|
858
|
+
background: var(--surface-hover);
|
|
859
|
+
}
|
|
860
|
+
/* Selected: a 3px titanium edge and the tint, text unchanged. Hover and
|
|
861
|
+
press never darken it. */
|
|
862
|
+
.hp-combobox-option[data-selected] {
|
|
863
|
+
background: var(--surface-selected);
|
|
864
|
+
box-shadow: inset 3px 0 0 var(--accent);
|
|
865
|
+
}
|
|
866
|
+
/* Keys move a virtual focus (focus stays in the input), so the
|
|
867
|
+
highlight they move also draws the focus contour, inside the row. */
|
|
868
|
+
.hp-combobox-list[data-keyboard] .hp-combobox-option[data-highlighted] {
|
|
869
|
+
outline: 2px solid var(--focus);
|
|
870
|
+
outline-offset: -2px;
|
|
871
|
+
}
|
|
872
|
+
.hp-combobox-option[data-disabled] {
|
|
873
|
+
color: var(--muted);
|
|
874
|
+
cursor: not-allowed;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
/* Nothing matches: one muted line where the options were. */
|
|
878
|
+
.hp-combobox-empty {
|
|
879
|
+
margin: 0;
|
|
880
|
+
padding: var(--space-2) var(--option-inset);
|
|
881
|
+
color: var(--muted);
|
|
882
|
+
font-size: var(--type-body);
|
|
883
|
+
line-height: 24px;
|
|
884
|
+
}
|
|
885
|
+
.hp-combobox-empty:empty {
|
|
886
|
+
padding: 0;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/* ---- Sheet (compact): the search input over the list ---------------- */
|
|
890
|
+
|
|
891
|
+
.hp-combobox-search {
|
|
892
|
+
flex: none;
|
|
893
|
+
/* 8px above the well: its focus contour (2px, 4px out) stays inside
|
|
894
|
+
the sheet body, which clips. */
|
|
895
|
+
padding: var(--space-2) var(--gutter);
|
|
896
|
+
}
|
|
897
|
+
/* In the sheet the empty line keeps the gutter inset, as the options do. */
|
|
898
|
+
.hp-combobox-empty[data-form='sheet'] {
|
|
899
|
+
--option-inset: var(--gutter);
|
|
900
|
+
}
|
|
901
|
+
</style>
|