@streamscloud/kit 0.35.0 → 0.36.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/dist/core/utils/dom-helper.d.ts +11 -0
- package/dist/core/utils/dom-helper.js +15 -0
- package/dist/ui/form-field/cmp.form-field-validatable.svelte +2 -2
- package/dist/ui/form-field/cmp.form-field-validatable.svelte.d.ts +2 -0
- package/dist/ui/form-field/cmp.form-field.svelte +39 -5
- package/dist/ui/form-field/cmp.form-field.svelte.d.ts +5 -0
- package/dist/ui/navigation/cmp.nav-menu-item.svelte.d.ts +1 -1
- package/dist/ui/popover/cmp.popover.svelte +6 -1
- package/dist/ui/popover/cmp.popover.svelte.d.ts +6 -0
- package/dist/ui/popover/validate-trigger.js +2 -2
- package/dist/ui/url-input/cmp.url-input.svelte +148 -0
- package/dist/ui/url-input/cmp.url-input.svelte.d.ts +40 -0
- package/dist/ui/url-input/index.d.ts +2 -0
- package/dist/ui/url-input/index.js +2 -0
- package/dist/ui/url-input/scheme.d.ts +10 -0
- package/dist/ui/url-input/scheme.js +20 -0
- package/dist/ui/url-input/url-input-localization.d.ts +4 -0
- package/dist/ui/url-input/url-input-localization.js +19 -0
- package/package.json +5 -1
|
@@ -1,4 +1,15 @@
|
|
|
1
1
|
export declare class DomHelper {
|
|
2
|
+
private static readonly _interactiveSelector;
|
|
3
|
+
/**
|
|
4
|
+
* First interactive descendant of `root`, or `null`. Use to reject interactive content from a
|
|
5
|
+
* container that must stay non-interactive (a `<button>` trigger, a `<label>`).
|
|
6
|
+
*/
|
|
7
|
+
static findInteractiveDescendant(root: Element): HTMLElement | null;
|
|
8
|
+
/**
|
|
9
|
+
* True when the target is, or sits inside, interactive content — i.e. something that owns its own
|
|
10
|
+
* click behaviour and whose default action a wrapper must not cancel.
|
|
11
|
+
*/
|
|
12
|
+
static isInsideInteractive(target: EventTarget | null): boolean;
|
|
2
13
|
/**
|
|
3
14
|
* Checks whether initial object is located inside specified boundaries
|
|
4
15
|
* @param {HTMLElement} element The initial element
|
|
@@ -1,4 +1,19 @@
|
|
|
1
1
|
export class DomHelper {
|
|
2
|
+
static _interactiveSelector = 'button, a[href], input:not([type="hidden"]), select, textarea, [role="button"], [role="link"], [role="menuitem"], [tabindex]:not([tabindex="-1"])';
|
|
3
|
+
/**
|
|
4
|
+
* First interactive descendant of `root`, or `null`. Use to reject interactive content from a
|
|
5
|
+
* container that must stay non-interactive (a `<button>` trigger, a `<label>`).
|
|
6
|
+
*/
|
|
7
|
+
static findInteractiveDescendant(root) {
|
|
8
|
+
return root.querySelector(DomHelper._interactiveSelector);
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* True when the target is, or sits inside, interactive content — i.e. something that owns its own
|
|
12
|
+
* click behaviour and whose default action a wrapper must not cancel.
|
|
13
|
+
*/
|
|
14
|
+
static isInsideInteractive(target) {
|
|
15
|
+
return target instanceof Element && target.closest(DomHelper._interactiveSelector) !== null;
|
|
16
|
+
}
|
|
2
17
|
/**
|
|
3
18
|
* Checks whether initial object is located inside specified boundaries
|
|
4
19
|
* @param {HTMLElement} element The initial element
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
<script lang="ts" generics="T extends Record<string, unknown>, K extends keyof T & string">import { Validatable } from '../validatable';
|
|
2
2
|
import { default as FormField } from './cmp.form-field.svelte';
|
|
3
|
-
let { handler, name, label, required = false, validateOn, debounceMs, disableTouchedTracking, reserveErrorSpace = false, on, children } = $props();
|
|
3
|
+
let { handler, name, label, hint, required = false, validateOn, debounceMs, disableTouchedTracking, reserveErrorSpace = false, on, children } = $props();
|
|
4
4
|
</script>
|
|
5
5
|
|
|
6
|
-
<FormField label={label} required={required}>
|
|
6
|
+
<FormField label={label} hint={hint} required={required}>
|
|
7
7
|
<Validatable
|
|
8
8
|
handler={handler}
|
|
9
9
|
name={name}
|
|
@@ -9,6 +9,8 @@ declare function $$render<T extends Record<string, unknown>, K extends keyof T &
|
|
|
9
9
|
name: K;
|
|
10
10
|
/** Label rendered above the input. String renders as text; Snippet allows extra content (e.g. tooltip icon) next to the text. */
|
|
11
11
|
label?: string | Snippet;
|
|
12
|
+
/** Affordance pinned to the trailing edge of the label row. Forwarded to `<FormField>`. */
|
|
13
|
+
hint?: string | Snippet;
|
|
12
14
|
/** Appends a danger-colored `*` to the label. */
|
|
13
15
|
required?: boolean;
|
|
14
16
|
/** Which events trigger validation. Forwarded to `<Validatable>`. @default ['input', 'change', 'blur'] */
|
|
@@ -1,17 +1,40 @@
|
|
|
1
|
-
<script lang="ts">
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
<script lang="ts">import { DomHelper } from '../../core/utils';
|
|
2
|
+
import { Icon } from '../icon';
|
|
3
|
+
import { Tooltip } from '../tooltip';
|
|
4
|
+
import IconInfo from '@fluentui/svg-icons/icons/info_20_regular.svg?raw';
|
|
5
|
+
let { label, hint, required = false, children } = $props();
|
|
6
|
+
// Unhandled, the click activates the labelled field (a checkbox would toggle); cancelling it on interactive content would kill a link's navigation.
|
|
7
|
+
const swallowClick = (event) => {
|
|
8
|
+
event.stopPropagation();
|
|
9
|
+
if (DomHelper.isInsideInteractive(event.target)) {
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
event.preventDefault();
|
|
13
|
+
};
|
|
4
14
|
</script>
|
|
5
15
|
|
|
6
16
|
<label class="form-field">
|
|
7
17
|
{#if label}
|
|
8
18
|
<span class="form-field__label">
|
|
9
|
-
{#if
|
|
19
|
+
{#if typeof label === 'string'}
|
|
10
20
|
{label}
|
|
11
21
|
{:else}
|
|
12
|
-
{@render
|
|
22
|
+
{@render label()}
|
|
13
23
|
{/if}
|
|
14
24
|
{#if required}<span class="form-field__required" aria-hidden="true">*</span>{/if}
|
|
25
|
+
{#if hint}
|
|
26
|
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
27
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
28
|
+
<span class="form-field__hint" onclick={swallowClick}>
|
|
29
|
+
{#if typeof hint === 'string'}
|
|
30
|
+
<Tooltip text={hint}>
|
|
31
|
+
<Icon src={IconInfo} size="sm" color="muted" />
|
|
32
|
+
</Tooltip>
|
|
33
|
+
{:else}
|
|
34
|
+
{@render hint()}
|
|
35
|
+
{/if}
|
|
36
|
+
</span>
|
|
37
|
+
{/if}
|
|
15
38
|
</span>
|
|
16
39
|
{/if}
|
|
17
40
|
|
|
@@ -22,10 +45,13 @@ export {};
|
|
|
22
45
|
@component
|
|
23
46
|
FormField — pure layout shell: optional label (string or snippet) above an input slot. The root is a `<label>` element, so the first labelable form control inside is automatically associated via HTML's implicit-association rule — no id wiring required. Does NOT render errors or helper text; pair with `Validatable` for that.
|
|
24
47
|
|
|
48
|
+
`hint` pins an affordance to the trailing edge of the label row — a string gives the standard info icon with a tooltip, a snippet takes over completely (button, link, badge). It rides on the label row, so it renders only when `label` is set. Clicks inside the hint are swallowed, so an interactive hint never activates the labelled field.
|
|
49
|
+
|
|
25
50
|
### CSS Custom Properties
|
|
26
51
|
| Property | Description | Default |
|
|
27
52
|
|---|---|---|
|
|
28
53
|
| `--sc-kit--form-field--gap` | Vertical gap between label and the input slot | `0.375rem` (6px) |
|
|
54
|
+
| `--sc-kit--form-field--hint--color` | Hint color (inherited by the default info icon) | `--sc-kit--color--text--muted` |
|
|
29
55
|
| `--sc-kit--form-field--label--color` | Label text color | `--sc-kit--color--text--primary` |
|
|
30
56
|
| `--sc-kit--form-field--label--font-size` | Label font size | `--sc-kit--font-size--sm` |
|
|
31
57
|
| `--sc-kit--form-field--label--font-weight` | Label font weight | `--sc-kit--font-weight--medium` |
|
|
@@ -38,6 +64,7 @@ FormField — pure layout shell: optional label (string or snippet) above an inp
|
|
|
38
64
|
--_ff--label-font-size: var(--sc-kit--form-field--label--font-size, var(--sc-kit--font-size--sm));
|
|
39
65
|
--_ff--label-font-weight: var(--sc-kit--form-field--label--font-weight, var(--sc-kit--font-weight--medium));
|
|
40
66
|
--_ff--required-color: var(--sc-kit--form-field--required--color, var(--sc-kit--color--danger));
|
|
67
|
+
--_ff--hint-color: var(--sc-kit--form-field--hint--color, var(--sc-kit--color--text--muted));
|
|
41
68
|
display: flex;
|
|
42
69
|
flex-direction: column;
|
|
43
70
|
gap: var(--_ff--gap);
|
|
@@ -52,4 +79,11 @@ FormField — pure layout shell: optional label (string or snippet) above an inp
|
|
|
52
79
|
}
|
|
53
80
|
.form-field__required {
|
|
54
81
|
color: var(--_ff--required-color);
|
|
82
|
+
}
|
|
83
|
+
.form-field__hint {
|
|
84
|
+
display: inline-flex;
|
|
85
|
+
align-items: center;
|
|
86
|
+
margin-inline-start: auto;
|
|
87
|
+
color: var(--_ff--hint-color);
|
|
88
|
+
font-weight: var(--sc-kit--font-weight--regular);
|
|
55
89
|
}</style>
|
|
@@ -2,6 +2,8 @@ import type { Snippet } from 'svelte';
|
|
|
2
2
|
type Props = {
|
|
3
3
|
/** Label rendered above the input. String renders as text; Snippet allows extra content (e.g. tooltip icon) next to the text. */
|
|
4
4
|
label?: string | Snippet;
|
|
5
|
+
/** Affordance pinned to the trailing edge of the label row — rendered only alongside a `label`. String renders the standard info icon with that text in a tooltip; Snippet renders anything (button, link, badge). Clicks inside never reach the field. */
|
|
6
|
+
hint?: string | Snippet;
|
|
5
7
|
/** Appends a danger-colored `*` to the label. Purely visual — does not enforce anything. */
|
|
6
8
|
required?: boolean;
|
|
7
9
|
/** Input slot. The wrapping `<label>` implicitly associates with the first labelable control inside (HTML standard), so no id wiring is needed. */
|
|
@@ -10,10 +12,13 @@ type Props = {
|
|
|
10
12
|
/**
|
|
11
13
|
* FormField — pure layout shell: optional label (string or snippet) above an input slot. The root is a `<label>` element, so the first labelable form control inside is automatically associated via HTML's implicit-association rule — no id wiring required. Does NOT render errors or helper text; pair with `Validatable` for that.
|
|
12
14
|
*
|
|
15
|
+
* `hint` pins an affordance to the trailing edge of the label row — a string gives the standard info icon with a tooltip, a snippet takes over completely (button, link, badge). It rides on the label row, so it renders only when `label` is set. Clicks inside the hint are swallowed, so an interactive hint never activates the labelled field.
|
|
16
|
+
*
|
|
13
17
|
* ### CSS Custom Properties
|
|
14
18
|
* | Property | Description | Default |
|
|
15
19
|
* |---|---|---|
|
|
16
20
|
* | `--sc-kit--form-field--gap` | Vertical gap between label and the input slot | `0.375rem` (6px) |
|
|
21
|
+
* | `--sc-kit--form-field--hint--color` | Hint color (inherited by the default info icon) | `--sc-kit--color--text--muted` |
|
|
17
22
|
* | `--sc-kit--form-field--label--color` | Label text color | `--sc-kit--color--text--primary` |
|
|
18
23
|
* | `--sc-kit--form-field--label--font-size` | Label font size | `--sc-kit--font-size--sm` |
|
|
19
24
|
* | `--sc-kit--form-field--label--font-weight` | Label font weight | `--sc-kit--font-weight--medium` |
|
|
@@ -7,7 +7,7 @@ type Props = {
|
|
|
7
7
|
href?: string;
|
|
8
8
|
/** Highlight as the current item. Consumer-controlled. @default false */
|
|
9
9
|
active?: boolean;
|
|
10
|
-
/** Active row visuals — `'plain'` uses color
|
|
10
|
+
/** Active row visuals — `'plain'` uses color plus a heavier label weight and keeps hover, `'filled'` paints the active background (no hover on it). @default 'plain' */
|
|
11
11
|
activeStyle?: 'filled' | 'plain';
|
|
12
12
|
disabled?: boolean;
|
|
13
13
|
/** Notification badge on the icon (top-right corner). Numbers > 99 render as `99+`; strings render as-is. */
|
|
@@ -4,7 +4,7 @@ import { validateTrigger } from './validate-trigger';
|
|
|
4
4
|
import { autoUpdate, computePosition, flip, offset as offsetMiddleware, shift, size } from '@floating-ui/dom';
|
|
5
5
|
import IconChevronDown from '@fluentui/svg-icons/icons/chevron_down_20_regular.svg?raw';
|
|
6
6
|
import { tick } from 'svelte';
|
|
7
|
-
let { position = 'bottom-start', disabled = false, fixedPosition = false, offset = 4, boundaryMargin = 8, backdrop = false, matchWidth = false, panel = true, keepOpen = false, fillContainer = false, on, children, trigger, 'aria-label': ariaLabel } = $props();
|
|
7
|
+
let { position = 'bottom-start', disabled = false, fixedPosition = false, offset = 4, boundaryMargin = 8, backdrop = false, matchWidth = false, panel = true, keepOpen = false, fillContainer = false, tabbable = true, on, children, trigger, 'aria-label': ariaLabel } = $props();
|
|
8
8
|
let opened = $state(false);
|
|
9
9
|
let triggerEl = $state.raw(undefined);
|
|
10
10
|
let contentEl = $state.raw(undefined);
|
|
@@ -170,6 +170,7 @@ const handleBackdropClick = (e) => {
|
|
|
170
170
|
class="popover__trigger"
|
|
171
171
|
class:popover__trigger--fill={fillContainer}
|
|
172
172
|
disabled={disabled}
|
|
173
|
+
tabindex={tabbable ? undefined : -1}
|
|
173
174
|
aria-label={ariaLabel}
|
|
174
175
|
aria-haspopup="true"
|
|
175
176
|
aria-expanded={opened}
|
|
@@ -208,6 +209,10 @@ text). For a button-looking trigger, pass `Button` with `type="presentational"`
|
|
|
208
209
|
— never an interactive element, which would nest inside the trigger `<button>`. A dev-only guard
|
|
209
210
|
warns and outlines the trigger in red if interactive content is detected.
|
|
210
211
|
|
|
212
|
+
`tabbable={false}` drops the trigger out of the tab order, leaving it mouse-only — legitimate only when
|
|
213
|
+
the same state can be reached by keyboard through another control (as in `UrlInput`, where typing a
|
|
214
|
+
protocol into the field does what the picker does). Never reach for it just to shorten a tab sequence.
|
|
215
|
+
|
|
211
216
|
Imperative control via `bind:this` — the component exports `open()`, `close()`, `toggle()`
|
|
212
217
|
methods. Import `PopoverInstance` type from the barrel for typing the ref.
|
|
213
218
|
|
|
@@ -20,6 +20,8 @@ type Props = {
|
|
|
20
20
|
keepOpen?: boolean;
|
|
21
21
|
/** Make the trigger fill its parent's box: `width:100%; height:100%`, centered, `border-radius: inherit`. Use when Popover sits inside a wrapper that owns the visible click surface (icon squares, list rows, etc.). */
|
|
22
22
|
fillContainer?: boolean;
|
|
23
|
+
/** Keep the trigger in the tab order. Turn off only when the same state is reachable by keyboard elsewhere — the popover becomes mouse-only. @default true */
|
|
24
|
+
tabbable?: boolean;
|
|
23
25
|
on?: {
|
|
24
26
|
opened?: () => void;
|
|
25
27
|
closed?: () => void;
|
|
@@ -44,6 +46,10 @@ type Props = {
|
|
|
44
46
|
* — never an interactive element, which would nest inside the trigger `<button>`. A dev-only guard
|
|
45
47
|
* warns and outlines the trigger in red if interactive content is detected.
|
|
46
48
|
*
|
|
49
|
+
* `tabbable={false}` drops the trigger out of the tab order, leaving it mouse-only — legitimate only when
|
|
50
|
+
* the same state can be reached by keyboard through another control (as in `UrlInput`, where typing a
|
|
51
|
+
* protocol into the field does what the picker does). Never reach for it just to shorten a tab sequence.
|
|
52
|
+
*
|
|
47
53
|
* Imperative control via `bind:this` — the component exports `open()`, `close()`, `toggle()`
|
|
48
54
|
* methods. Import `PopoverInstance` type from the barrel for typing the ref.
|
|
49
55
|
*
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
import { DomHelper } from '../../core/utils';
|
|
2
2
|
/**
|
|
3
3
|
* Dev-only guard for the Popover trigger. The trigger renders inside a `<button>`, so its content
|
|
4
4
|
* must be non-interactive (use `Button` with `type="presentational"` for a button-looking trigger).
|
|
@@ -10,7 +10,7 @@ export const validateTrigger = (node) => {
|
|
|
10
10
|
return;
|
|
11
11
|
}
|
|
12
12
|
const check = () => {
|
|
13
|
-
const offender =
|
|
13
|
+
const offender = DomHelper.findInteractiveDescendant(node);
|
|
14
14
|
if (offender) {
|
|
15
15
|
node.style.outline = '2px solid red';
|
|
16
16
|
node.style.outlineOffset = '1px';
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
<script lang="ts">import { Input } from '../input';
|
|
2
|
+
import { Popover, PopoverItem } from '../popover';
|
|
3
|
+
import { composeUrl, parseUrl } from './scheme';
|
|
4
|
+
import { UrlInputLocalization } from './url-input-localization';
|
|
5
|
+
const DEFAULT_SCHEMES = ['https', 'http'];
|
|
6
|
+
let { value, size = 'md', disabled = false, readonly = false, inert = false, schemes = DEFAULT_SCHEMES, scheme, on, ...rest } = $props();
|
|
7
|
+
const localization = new UrlInputLocalization();
|
|
8
|
+
let pickedScheme = $state.raw(null);
|
|
9
|
+
const parsed = $derived(parseUrl(value ?? '', schemes));
|
|
10
|
+
const displayValue = $derived(parsed.rest);
|
|
11
|
+
// A pick outlives the set it came from when `schemes` narrows, and would keep feeding a no-longer-offered protocol into the value.
|
|
12
|
+
const offeredPick = $derived(pickedScheme && schemes.includes(pickedScheme) ? pickedScheme : null);
|
|
13
|
+
const activeScheme = $derived(parsed.scheme ?? offeredPick ?? scheme ?? schemes[0]);
|
|
14
|
+
const pickable = $derived(schemes.length > 1 && !parsed.foreign && !disabled && !readonly && !inert);
|
|
15
|
+
const toValue = (text) => {
|
|
16
|
+
const typed = parseUrl(text, schemes);
|
|
17
|
+
return typed.foreign ? text : composeUrl(typed.rest, typed.scheme ?? activeScheme);
|
|
18
|
+
};
|
|
19
|
+
const handleInput = (v) => on?.input?.(toValue(v));
|
|
20
|
+
const handleChange = (v) => on?.change?.(toValue(v));
|
|
21
|
+
const pickScheme = (next) => {
|
|
22
|
+
pickedScheme = next;
|
|
23
|
+
const emitted = composeUrl(displayValue, next);
|
|
24
|
+
on?.schemeChange?.(next);
|
|
25
|
+
on?.input?.(emitted);
|
|
26
|
+
on?.change?.(emitted);
|
|
27
|
+
};
|
|
28
|
+
// The whole Input surface focuses the field on click — without this the picker would open and immediately lose focus.
|
|
29
|
+
const swallowClick = (event) => event.stopPropagation();
|
|
30
|
+
</script>
|
|
31
|
+
|
|
32
|
+
{#snippet schemeBody()}
|
|
33
|
+
<span class="url-input__body url-input__body--{size}">{activeScheme}://</span>
|
|
34
|
+
{/snippet}
|
|
35
|
+
|
|
36
|
+
{#snippet schemeSegment()}
|
|
37
|
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
38
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
39
|
+
<span
|
|
40
|
+
class="url-input__scheme"
|
|
41
|
+
class:url-input__scheme--pickable={pickable}
|
|
42
|
+
class:url-input__scheme--inactive={parsed.foreign}
|
|
43
|
+
title={parsed.foreign ? localization.schemeIgnored : undefined}
|
|
44
|
+
onclick={pickable ? swallowClick : undefined}>
|
|
45
|
+
{#if pickable}
|
|
46
|
+
<Popover position="bottom-start" fillContainer={true} fixedPosition={true} tabbable={false} aria-label={localization.chooseScheme}>
|
|
47
|
+
{#snippet trigger()}
|
|
48
|
+
{@render schemeBody()}
|
|
49
|
+
{/snippet}
|
|
50
|
+
{#each schemes as option (option)}
|
|
51
|
+
<PopoverItem on={{ click: () => pickScheme(option) }}>{option}://</PopoverItem>
|
|
52
|
+
{/each}
|
|
53
|
+
</Popover>
|
|
54
|
+
{:else}
|
|
55
|
+
{@render schemeBody()}
|
|
56
|
+
{/if}
|
|
57
|
+
</span>
|
|
58
|
+
{/snippet}
|
|
59
|
+
|
|
60
|
+
<Input
|
|
61
|
+
{...rest}
|
|
62
|
+
value={displayValue}
|
|
63
|
+
size={size}
|
|
64
|
+
disabled={disabled}
|
|
65
|
+
readonly={readonly}
|
|
66
|
+
inert={inert}
|
|
67
|
+
autocomplete="url"
|
|
68
|
+
autocapitalize="none"
|
|
69
|
+
inputmode="url"
|
|
70
|
+
spellcheck={false}
|
|
71
|
+
prefix={schemeSegment}
|
|
72
|
+
on={{
|
|
73
|
+
input: handleInput,
|
|
74
|
+
change: handleChange,
|
|
75
|
+
focus: () => on?.focus?.(),
|
|
76
|
+
blur: () => on?.blur?.(),
|
|
77
|
+
keydown: (event) => on?.keydown?.(event),
|
|
78
|
+
mounted: (data) => on?.mounted?.(data)
|
|
79
|
+
}} />
|
|
80
|
+
|
|
81
|
+
<!--
|
|
82
|
+
@component
|
|
83
|
+
A URL input whose protocol lives in a tonally distinct leading segment instead of the text. The bound
|
|
84
|
+
`value` is always the full URL or `''`; the user types only host and path.
|
|
85
|
+
|
|
86
|
+
The segment reflects the value rather than a wish. A protocol from `schemes` found at the head of the
|
|
87
|
+
text — typed, pasted or arriving as `value` — moves into the segment and is stripped from the field, so
|
|
88
|
+
nothing is silently upgraded: turning `http` into `https` takes picking it. A protocol *outside*
|
|
89
|
+
`schemes` (`mailto:`, `ftp://`) is left in the text and emitted verbatim, and the segment dims and stops
|
|
90
|
+
accepting picks — judging that address belongs to the consumer's validation, not to the field.
|
|
91
|
+
|
|
92
|
+
With two or more `schemes` the segment is a popover picker — no chevron, the hover shift carries the
|
|
93
|
+
affordance; with one it is static text. The picker floats with `position: fixed`, since `Input` clips
|
|
94
|
+
its own box, and sits outside the tab order: Tab reaches the text field only, where typing or pasting
|
|
95
|
+
`http://…` moves the protocol into the segment anyway. Keyboard users lose no capability, and one
|
|
96
|
+
field costs one tab stop.
|
|
97
|
+
|
|
98
|
+
### CSS Custom Properties
|
|
99
|
+
| Property | Description | Default |
|
|
100
|
+
|---|---|---|
|
|
101
|
+
| `--sc-kit--url-input--scheme--background` | Segment background | `--sc-kit--color--bg--field-alt` |
|
|
102
|
+
| `--sc-kit--url-input--scheme--background--hover` | Segment background while pickable and hovered | `--sc-kit--color--bg--hover` |
|
|
103
|
+
| `--sc-kit--url-input--scheme--color` | Segment text color | `--sc-kit--color--text--muted` |
|
|
104
|
+
| `--sc-kit--url-input--scheme--border-color` | Vertical separator color | `--sc-kit--color--border--field` |
|
|
105
|
+
|
|
106
|
+
Plus every public CSS variable exposed by `<Input>`.
|
|
107
|
+
-->
|
|
108
|
+
|
|
109
|
+
<style>.url-input__scheme {
|
|
110
|
+
--_ui--scheme-background: var(--sc-kit--url-input--scheme--background, var(--sc-kit--color--bg--field-alt));
|
|
111
|
+
--_ui--scheme-background-hover: var(--sc-kit--url-input--scheme--background--hover, var(--sc-kit--color--bg--hover));
|
|
112
|
+
--_ui--scheme-color: var(--sc-kit--url-input--scheme--color, var(--sc-kit--color--text--muted));
|
|
113
|
+
--_ui--scheme-border-color: var(--sc-kit--url-input--scheme--border-color, var(--sc-kit--color--border--field));
|
|
114
|
+
--sc-kit--popover--content--min-width: 7rem;
|
|
115
|
+
display: inline-flex;
|
|
116
|
+
align-items: stretch;
|
|
117
|
+
flex: 1;
|
|
118
|
+
background: var(--_ui--scheme-background);
|
|
119
|
+
color: var(--_ui--scheme-color);
|
|
120
|
+
border-inline-end: 1px solid var(--_ui--scheme-border-color);
|
|
121
|
+
line-height: var(--sc-kit--leading--tight);
|
|
122
|
+
user-select: none;
|
|
123
|
+
white-space: nowrap;
|
|
124
|
+
transition: background-color var(--sc-kit--duration--base) var(--sc-kit--ease--default);
|
|
125
|
+
}
|
|
126
|
+
.url-input__scheme--pickable {
|
|
127
|
+
cursor: pointer;
|
|
128
|
+
}
|
|
129
|
+
.url-input__scheme--pickable:hover {
|
|
130
|
+
background: var(--_ui--scheme-background-hover);
|
|
131
|
+
}
|
|
132
|
+
.url-input__scheme--inactive {
|
|
133
|
+
opacity: 0.5;
|
|
134
|
+
}
|
|
135
|
+
.url-input__body {
|
|
136
|
+
display: inline-flex;
|
|
137
|
+
align-items: center;
|
|
138
|
+
block-size: 100%;
|
|
139
|
+
}
|
|
140
|
+
.url-input__body--sm {
|
|
141
|
+
padding-inline: var(--sc-kit--field--padding-inline--sm);
|
|
142
|
+
}
|
|
143
|
+
.url-input__body--md {
|
|
144
|
+
padding-inline: var(--sc-kit--field--padding-inline--md);
|
|
145
|
+
}
|
|
146
|
+
.url-input__body--lg {
|
|
147
|
+
padding-inline: var(--sc-kit--field--padding-inline--lg);
|
|
148
|
+
}</style>
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { InputForwardedProps } from '../input';
|
|
2
|
+
type Props = Omit<InputForwardedProps, 'autocomplete' | 'autocapitalize' | 'inputmode' | 'spellcheck' | 'on'> & {
|
|
3
|
+
/** Protocols offered in the leading segment. A single entry renders it as static text. @default ['https', 'http'] */
|
|
4
|
+
schemes?: string[];
|
|
5
|
+
/** Which of `schemes` applies to text that carries none. A value outside `schemes` is emitted as given, unvalidated. @default schemes[0] */
|
|
6
|
+
scheme?: string;
|
|
7
|
+
on?: NonNullable<InputForwardedProps['on']> & {
|
|
8
|
+
/** Fires when a protocol is picked in the leading segment — not when one is detected in the text. */
|
|
9
|
+
schemeChange?: (scheme: string) => void;
|
|
10
|
+
};
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* A URL input whose protocol lives in a tonally distinct leading segment instead of the text. The bound
|
|
14
|
+
* `value` is always the full URL or `''`; the user types only host and path.
|
|
15
|
+
*
|
|
16
|
+
* The segment reflects the value rather than a wish. A protocol from `schemes` found at the head of the
|
|
17
|
+
* text — typed, pasted or arriving as `value` — moves into the segment and is stripped from the field, so
|
|
18
|
+
* nothing is silently upgraded: turning `http` into `https` takes picking it. A protocol *outside*
|
|
19
|
+
* `schemes` (`mailto:`, `ftp://`) is left in the text and emitted verbatim, and the segment dims and stops
|
|
20
|
+
* accepting picks — judging that address belongs to the consumer's validation, not to the field.
|
|
21
|
+
*
|
|
22
|
+
* With two or more `schemes` the segment is a popover picker — no chevron, the hover shift carries the
|
|
23
|
+
* affordance; with one it is static text. The picker floats with `position: fixed`, since `Input` clips
|
|
24
|
+
* its own box, and sits outside the tab order: Tab reaches the text field only, where typing or pasting
|
|
25
|
+
* `http://…` moves the protocol into the segment anyway. Keyboard users lose no capability, and one
|
|
26
|
+
* field costs one tab stop.
|
|
27
|
+
*
|
|
28
|
+
* ### CSS Custom Properties
|
|
29
|
+
* | Property | Description | Default |
|
|
30
|
+
* |---|---|---|
|
|
31
|
+
* | `--sc-kit--url-input--scheme--background` | Segment background | `--sc-kit--color--bg--field-alt` |
|
|
32
|
+
* | `--sc-kit--url-input--scheme--background--hover` | Segment background while pickable and hovered | `--sc-kit--color--bg--hover` |
|
|
33
|
+
* | `--sc-kit--url-input--scheme--color` | Segment text color | `--sc-kit--color--text--muted` |
|
|
34
|
+
* | `--sc-kit--url-input--scheme--border-color` | Vertical separator color | `--sc-kit--color--border--field` |
|
|
35
|
+
*
|
|
36
|
+
* Plus every public CSS variable exposed by `<Input>`.
|
|
37
|
+
*/
|
|
38
|
+
declare const Cmp: import("svelte").Component<Props, {}, "">;
|
|
39
|
+
type Cmp = ReturnType<typeof Cmp>;
|
|
40
|
+
export default Cmp;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export type ParsedUrl = {
|
|
2
|
+
/** Scheme from the allowed set found at the head of the text, lowercased. */
|
|
3
|
+
scheme: string | null;
|
|
4
|
+
/** The text with a recognized `scheme://` removed. */
|
|
5
|
+
rest: string;
|
|
6
|
+
/** The text carries a scheme outside the allowed set — nothing may be prepended to it. */
|
|
7
|
+
foreign: boolean;
|
|
8
|
+
};
|
|
9
|
+
export declare const parseUrl: (raw: string, schemes: string[]) => ParsedUrl;
|
|
10
|
+
export declare const composeUrl: (rest: string, scheme: string) => string;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
const SCHEME_PATTERN = /^([a-z][a-z0-9+.-]*):(\/\/)?/i;
|
|
2
|
+
export const parseUrl = (raw, schemes) => {
|
|
3
|
+
const match = SCHEME_PATTERN.exec(raw);
|
|
4
|
+
if (!match) {
|
|
5
|
+
return { scheme: null, rest: raw, foreign: false };
|
|
6
|
+
}
|
|
7
|
+
const found = match[1].toLowerCase();
|
|
8
|
+
const tail = raw.slice(match[0].length);
|
|
9
|
+
if (match[2]) {
|
|
10
|
+
return schemes.some((scheme) => scheme.toLowerCase() === found)
|
|
11
|
+
? { scheme: found, rest: tail, foreign: false }
|
|
12
|
+
: { scheme: null, rest: raw, foreign: true };
|
|
13
|
+
}
|
|
14
|
+
// A numeric tail is a port (`localhost:3000`), not a scheme — treating it as one strips the protocol from the value.
|
|
15
|
+
if (/^\d/.test(tail)) {
|
|
16
|
+
return { scheme: null, rest: raw, foreign: false };
|
|
17
|
+
}
|
|
18
|
+
return { scheme: null, rest: raw, foreign: true };
|
|
19
|
+
};
|
|
20
|
+
export const composeUrl = (rest, scheme) => (rest.trim() === '' ? '' : `${scheme}://${rest}`);
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { AppLocale } from '../../core/locale';
|
|
2
|
+
const loc = {
|
|
3
|
+
chooseScheme: {
|
|
4
|
+
en: 'Choose URL protocol',
|
|
5
|
+
no: 'Velg URL-protokoll'
|
|
6
|
+
},
|
|
7
|
+
schemeIgnored: {
|
|
8
|
+
en: 'The address carries its own protocol, so this one does not apply',
|
|
9
|
+
no: 'Adressen har sin egen protokoll, så denne gjelder ikke'
|
|
10
|
+
}
|
|
11
|
+
};
|
|
12
|
+
export class UrlInputLocalization {
|
|
13
|
+
get chooseScheme() {
|
|
14
|
+
return loc.chooseScheme[AppLocale.current];
|
|
15
|
+
}
|
|
16
|
+
get schemeIgnored() {
|
|
17
|
+
return loc.schemeIgnored[AppLocale.current];
|
|
18
|
+
}
|
|
19
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@streamscloud/kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.36.0",
|
|
4
4
|
"author": "StreamsCloud",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -431,6 +431,10 @@
|
|
|
431
431
|
"types": "./dist/ui/typography/index.d.ts",
|
|
432
432
|
"svelte": "./dist/ui/typography/index.js"
|
|
433
433
|
},
|
|
434
|
+
"./ui/url-input": {
|
|
435
|
+
"types": "./dist/ui/url-input/index.d.ts",
|
|
436
|
+
"svelte": "./dist/ui/url-input/index.js"
|
|
437
|
+
},
|
|
434
438
|
"./ui/usage": {
|
|
435
439
|
"types": "./dist/ui/usage/index.d.ts",
|
|
436
440
|
"svelte": "./dist/ui/usage/index.js"
|