@sentropic/design-system-svelte 0.14.0 → 0.16.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/ForceGraph.svelte +239 -2
- package/dist/ForceGraph.svelte.d.ts +32 -0
- package/dist/ForceGraph.svelte.d.ts.map +1 -1
- package/dist/Popper.svelte +317 -0
- package/dist/Popper.svelte.d.ts +70 -0
- package/dist/Popper.svelte.d.ts.map +1 -0
- package/dist/Portal.svelte +80 -0
- package/dist/Portal.svelte.d.ts +22 -0
- package/dist/Portal.svelte.d.ts.map +1 -0
- package/dist/SelectableList.svelte +186 -0
- package/dist/SelectableList.svelte.d.ts +30 -0
- package/dist/SelectableList.svelte.d.ts.map +1 -0
- package/dist/SelectableRow.svelte +291 -0
- package/dist/SelectableRow.svelte.d.ts +63 -0
- package/dist/SelectableRow.svelte.d.ts.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/package.json +1 -1
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { Snippet } from "svelte";
|
|
2
|
+
export type PopperStrategy = "absolute" | "fixed";
|
|
3
|
+
export type PopperPlacement = "top" | "bottom" | "left" | "right" | "top-start" | "top-end" | "bottom-start" | "bottom-end" | "left-start" | "left-end" | "right-start" | "right-end";
|
|
4
|
+
export type PopperSide = "top" | "bottom" | "left" | "right";
|
|
5
|
+
export type PopperAlign = "start" | "center" | "end";
|
|
6
|
+
export type PopperProps = {
|
|
7
|
+
/** Reference element the panel is positioned against. */
|
|
8
|
+
anchor: HTMLElement | null;
|
|
9
|
+
/** Controlled open state. When false (or no anchor) nothing renders. */
|
|
10
|
+
open?: boolean;
|
|
11
|
+
/** Wanted placement of the panel relative to the anchor. */
|
|
12
|
+
placement?: PopperPlacement;
|
|
13
|
+
/** Main-axis distance (px) between the anchor and the panel. */
|
|
14
|
+
offset?: number;
|
|
15
|
+
/** Flip to the opposite side when the panel would overflow the viewport. */
|
|
16
|
+
flip?: boolean;
|
|
17
|
+
/** Shift along the cross axis to keep the panel within the viewport. */
|
|
18
|
+
shift?: boolean;
|
|
19
|
+
/** Expose a positioned arrow element. */
|
|
20
|
+
arrow?: boolean;
|
|
21
|
+
/** CSS positioning strategy. */
|
|
22
|
+
strategy?: PopperStrategy;
|
|
23
|
+
/** Render the panel into `document.body` via a Portal. */
|
|
24
|
+
portal?: boolean;
|
|
25
|
+
/** Optional class applied to the floating panel. */
|
|
26
|
+
class?: string;
|
|
27
|
+
/** Notified whenever the resolved placement changes (after flip). */
|
|
28
|
+
onPlacementChange?: (placement: PopperPlacement) => void;
|
|
29
|
+
children?: Snippet;
|
|
30
|
+
};
|
|
31
|
+
/** Split a placement into its side and (optional) alignment. */
|
|
32
|
+
export declare function splitPlacement(placement: PopperPlacement): {
|
|
33
|
+
side: PopperSide;
|
|
34
|
+
align: PopperAlign;
|
|
35
|
+
};
|
|
36
|
+
/** Recompose a side + alignment into a placement string. */
|
|
37
|
+
export declare function joinPlacement(side: PopperSide, align: PopperAlign): PopperPlacement;
|
|
38
|
+
export type Rect = {
|
|
39
|
+
top: number;
|
|
40
|
+
left: number;
|
|
41
|
+
right: number;
|
|
42
|
+
bottom: number;
|
|
43
|
+
width: number;
|
|
44
|
+
height: number;
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* Pure geometry: compute the panel coordinates (in the chosen strategy's
|
|
48
|
+
* coordinate space) given the anchor rect, the panel size, and options.
|
|
49
|
+
* Returns the resolved placement (after flip) and the top/left coordinates,
|
|
50
|
+
* plus the arrow offset along the main edge.
|
|
51
|
+
*
|
|
52
|
+
* Coordinates are viewport-relative; callers add scroll offsets for the
|
|
53
|
+
* `absolute` strategy. No DOM access here — safe to unit test.
|
|
54
|
+
*/
|
|
55
|
+
export declare function computePosition(anchorRect: Rect, panelWidth: number, panelHeight: number, options: {
|
|
56
|
+
placement: PopperPlacement;
|
|
57
|
+
offset: number;
|
|
58
|
+
flip: boolean;
|
|
59
|
+
shift: boolean;
|
|
60
|
+
viewportWidth: number;
|
|
61
|
+
viewportHeight: number;
|
|
62
|
+
}): {
|
|
63
|
+
placement: PopperPlacement;
|
|
64
|
+
top: number;
|
|
65
|
+
left: number;
|
|
66
|
+
};
|
|
67
|
+
declare const Popper: import("svelte").Component<PopperProps, {}, "">;
|
|
68
|
+
type Popper = ReturnType<typeof Popper>;
|
|
69
|
+
export default Popper;
|
|
70
|
+
//# sourceMappingURL=Popper.svelte.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"Popper.svelte.d.ts","sourceRoot":"","sources":["../src/lib/Popper.svelte.ts"],"names":[],"mappings":"AAGE,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AAGtC,MAAM,MAAM,cAAc,GAAG,UAAU,GAAG,OAAO,CAAC;AAElD,MAAM,MAAM,eAAe,GACvB,KAAK,GACL,QAAQ,GACR,MAAM,GACN,OAAO,GACP,WAAW,GACX,SAAS,GACT,cAAc,GACd,YAAY,GACZ,YAAY,GACZ,UAAU,GACV,aAAa,GACb,WAAW,CAAC;AAEhB,MAAM,MAAM,UAAU,GAAG,KAAK,GAAG,QAAQ,GAAG,MAAM,GAAG,OAAO,CAAC;AAC7D,MAAM,MAAM,WAAW,GAAG,OAAO,GAAG,QAAQ,GAAG,KAAK,CAAC;AAErD,MAAM,MAAM,WAAW,GAAG;IACxB,yDAAyD;IACzD,MAAM,EAAE,WAAW,GAAG,IAAI,CAAC;IAC3B,wEAAwE;IACxE,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,4DAA4D;IAC5D,SAAS,CAAC,EAAE,eAAe,CAAC;IAC5B,gEAAgE;IAChE,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,4EAA4E;IAC5E,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,wEAAwE;IACxE,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,yCAAyC;IACzC,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,gCAAgC;IAChC,QAAQ,CAAC,EAAE,cAAc,CAAC;IAC1B,0DAA0D;IAC1D,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,oDAAoD;IACpD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,qEAAqE;IACrE,iBAAiB,CAAC,EAAE,CAAC,SAAS,EAAE,eAAe,KAAK,IAAI,CAAC;IACzD,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB,CAAC;AAEF,gEAAgE;AAChE,wBAAgB,cAAc,CAAC,SAAS,EAAE,eAAe,GAAG;IAC1D,IAAI,EAAE,UAAU,CAAC;IACjB,KAAK,EAAE,WAAW,CAAC;CACpB,CAGA;AAED,4DAA4D;AAC5D,wBAAgB,aAAa,CAAC,IAAI,EAAE,UAAU,EAAE,KAAK,EAAE,WAAW,GAAG,eAAe,CAEnF;AASD,MAAM,MAAM,IAAI,GAAG;IACjB,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;CAChB,CAAC;AAEF;;;;;;;;GAQG;AACH,wBAAgB,eAAe,CAC7B,UAAU,EAAE,IAAI,EAChB,UAAU,EAAE,MAAM,EAClB,WAAW,EAAE,MAAM,EACnB,OAAO,EAAE;IACP,SAAS,EAAE,eAAe,CAAC;IAC3B,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,OAAO,CAAC;IACd,KAAK,EAAE,OAAO,CAAC;IACf,aAAa,EAAE,MAAM,CAAC;IACtB,cAAc,EAAE,MAAM,CAAC;CACxB,GACA;IAAE,SAAS,EAAE,eAAe,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAwD3D;AAsHH,QAAA,MAAM,MAAM,iDAAwC,CAAC;AACrD,KAAK,MAAM,GAAG,UAAU,CAAC,OAAO,MAAM,CAAC,CAAC;AACxC,eAAe,MAAM,CAAC"}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
import type { Snippet } from "svelte";
|
|
3
|
+
|
|
4
|
+
export type PortalProps = {
|
|
5
|
+
/**
|
|
6
|
+
* Where to teleport the children. A CSS selector string or an actual
|
|
7
|
+
* `HTMLElement`. Defaults to the document `<body>`.
|
|
8
|
+
*/
|
|
9
|
+
target?: string | HTMLElement;
|
|
10
|
+
/** When `true`, render inline in place (no teleportation). */
|
|
11
|
+
disabled?: boolean;
|
|
12
|
+
/** Optional class applied to the portal container element. */
|
|
13
|
+
class?: string;
|
|
14
|
+
children?: Snippet;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Resolve a target prop to an `HTMLElement`. Returns `null` when it cannot be
|
|
19
|
+
* resolved (SSR, missing selector, etc.).
|
|
20
|
+
*/
|
|
21
|
+
export function resolvePortalTarget(
|
|
22
|
+
target: string | HTMLElement | undefined
|
|
23
|
+
): HTMLElement | null {
|
|
24
|
+
if (typeof document === "undefined") return null;
|
|
25
|
+
if (target == null) return document.body;
|
|
26
|
+
if (typeof target === "string") {
|
|
27
|
+
return document.querySelector<HTMLElement>(target) ?? document.body;
|
|
28
|
+
}
|
|
29
|
+
return target;
|
|
30
|
+
}
|
|
31
|
+
</script>
|
|
32
|
+
|
|
33
|
+
<script lang="ts">
|
|
34
|
+
let {
|
|
35
|
+
target = "body",
|
|
36
|
+
disabled = false,
|
|
37
|
+
class: className,
|
|
38
|
+
children
|
|
39
|
+
}: PortalProps = $props();
|
|
40
|
+
|
|
41
|
+
// The container that actually holds the children. We render it inline first
|
|
42
|
+
// (so SSR produces markup in place) and move it into the target on mount.
|
|
43
|
+
let container = $state<HTMLDivElement | undefined>();
|
|
44
|
+
|
|
45
|
+
$effect(() => {
|
|
46
|
+
// Client-only: never touch the DOM during SSR or before mount.
|
|
47
|
+
if (disabled || !container) return;
|
|
48
|
+
if (typeof document === "undefined") return;
|
|
49
|
+
|
|
50
|
+
const destination = resolvePortalTarget(target);
|
|
51
|
+
if (!destination) return;
|
|
52
|
+
|
|
53
|
+
destination.appendChild(container);
|
|
54
|
+
|
|
55
|
+
return () => {
|
|
56
|
+
// Clean up on unmount / target change: remove the container from the DOM.
|
|
57
|
+
container?.remove();
|
|
58
|
+
};
|
|
59
|
+
});
|
|
60
|
+
</script>
|
|
61
|
+
|
|
62
|
+
{#if disabled}
|
|
63
|
+
<div class={className ? `st-portal ${className}` : "st-portal"} data-st-portal="inline">
|
|
64
|
+
{@render children?.()}
|
|
65
|
+
</div>
|
|
66
|
+
{:else}
|
|
67
|
+
<div
|
|
68
|
+
bind:this={container}
|
|
69
|
+
class={className ? `st-portal ${className}` : "st-portal"}
|
|
70
|
+
data-st-portal="teleported"
|
|
71
|
+
>
|
|
72
|
+
{@render children?.()}
|
|
73
|
+
</div>
|
|
74
|
+
{/if}
|
|
75
|
+
|
|
76
|
+
<style>
|
|
77
|
+
.st-portal {
|
|
78
|
+
display: contents;
|
|
79
|
+
}
|
|
80
|
+
</style>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Snippet } from "svelte";
|
|
2
|
+
export type PortalProps = {
|
|
3
|
+
/**
|
|
4
|
+
* Where to teleport the children. A CSS selector string or an actual
|
|
5
|
+
* `HTMLElement`. Defaults to the document `<body>`.
|
|
6
|
+
*/
|
|
7
|
+
target?: string | HTMLElement;
|
|
8
|
+
/** When `true`, render inline in place (no teleportation). */
|
|
9
|
+
disabled?: boolean;
|
|
10
|
+
/** Optional class applied to the portal container element. */
|
|
11
|
+
class?: string;
|
|
12
|
+
children?: Snippet;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Resolve a target prop to an `HTMLElement`. Returns `null` when it cannot be
|
|
16
|
+
* resolved (SSR, missing selector, etc.).
|
|
17
|
+
*/
|
|
18
|
+
export declare function resolvePortalTarget(target: string | HTMLElement | undefined): HTMLElement | null;
|
|
19
|
+
declare const Portal: import("svelte").Component<PortalProps, {}, "">;
|
|
20
|
+
type Portal = ReturnType<typeof Portal>;
|
|
21
|
+
export default Portal;
|
|
22
|
+
//# sourceMappingURL=Portal.svelte.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"Portal.svelte.d.ts","sourceRoot":"","sources":["../src/lib/Portal.svelte.ts"],"names":[],"mappings":"AAGE,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AAEtC,MAAM,MAAM,WAAW,GAAG;IACxB;;;OAGG;IACH,MAAM,CAAC,EAAE,MAAM,GAAG,WAAW,CAAC;IAC9B,8DAA8D;IAC9D,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,8DAA8D;IAC9D,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB,CAAC;AAEF;;;GAGG;AACH,wBAAgB,mBAAmB,CACjC,MAAM,EAAE,MAAM,GAAG,WAAW,GAAG,SAAS,GACvC,WAAW,GAAG,IAAI,CAOpB;AA+CH,QAAA,MAAM,MAAM,iDAAwC,CAAC;AACrD,KAAK,MAAM,GAAG,UAAU,CAAC,OAAO,MAAM,CAAC,CAAC;AACxC,eAAe,MAAM,CAAC"}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
import type { Snippet } from "svelte";
|
|
3
|
+
|
|
4
|
+
export type SelectableListProps = {
|
|
5
|
+
/** Accessible name for the listbox (required for SR users). */
|
|
6
|
+
label?: string;
|
|
7
|
+
/** References the id of an external visible label (alternative to `label`). */
|
|
8
|
+
labelledby?: string;
|
|
9
|
+
/**
|
|
10
|
+
* Allow more than one selected row. Adds aria-multiselectable and toggles
|
|
11
|
+
* each row independently. Defaults to false (single-select).
|
|
12
|
+
*/
|
|
13
|
+
multiple?: boolean;
|
|
14
|
+
/**
|
|
15
|
+
* Selected value(s). Controlled when provided. For single-select pass a
|
|
16
|
+
* string (or null); for multiple pass a string[]. When omitted the list is
|
|
17
|
+
* uncontrolled and keeps its own internal selection.
|
|
18
|
+
*/
|
|
19
|
+
value?: string | string[] | null;
|
|
20
|
+
/**
|
|
21
|
+
* Fired with the new selection on every change. Receives a string|null for
|
|
22
|
+
* single-select and a string[] for multiple. Required for the controlled
|
|
23
|
+
* pattern; also fires for uncontrolled lists.
|
|
24
|
+
*/
|
|
25
|
+
onchange?: (value: string | string[] | null) => void;
|
|
26
|
+
class?: string;
|
|
27
|
+
children?: Snippet;
|
|
28
|
+
};
|
|
29
|
+
</script>
|
|
30
|
+
|
|
31
|
+
<script lang="ts">
|
|
32
|
+
import { setContext, untrack } from "svelte";
|
|
33
|
+
import {
|
|
34
|
+
SELECTABLE_LIST_KEY,
|
|
35
|
+
type SelectableListContext
|
|
36
|
+
} from "./SelectableRow.svelte";
|
|
37
|
+
|
|
38
|
+
let {
|
|
39
|
+
label,
|
|
40
|
+
labelledby,
|
|
41
|
+
multiple = false,
|
|
42
|
+
value,
|
|
43
|
+
onchange,
|
|
44
|
+
class: className,
|
|
45
|
+
children
|
|
46
|
+
}: SelectableListProps = $props();
|
|
47
|
+
|
|
48
|
+
// Controlled when the consumer passes `value` (including null). Otherwise the
|
|
49
|
+
// list owns an internal selection set.
|
|
50
|
+
const controlled = $derived(value !== undefined);
|
|
51
|
+
|
|
52
|
+
function toSet(v: string | string[] | null | undefined): Set<string> {
|
|
53
|
+
if (v == null) return new Set();
|
|
54
|
+
return new Set(Array.isArray(v) ? v : [v]);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Internal selection for the uncontrolled case.
|
|
58
|
+
let internal = $state<Set<string>>(new Set());
|
|
59
|
+
const selectedValues = $derived(controlled ? toSet(value) : internal);
|
|
60
|
+
|
|
61
|
+
// --- Row registry: ordered by DOM position so arrow nav matches the visual
|
|
62
|
+
// order regardless of registration timing. -------------------------------
|
|
63
|
+
type Entry = { el: HTMLElement; value: string | undefined };
|
|
64
|
+
let entries = $state<Entry[]>([]);
|
|
65
|
+
|
|
66
|
+
// The element that currently holds the roving tab stop (tabindex 0). Null until
|
|
67
|
+
// a row is focused; until then the FIRST enabled row is the default stop.
|
|
68
|
+
let tabStopEl = $state<HTMLElement | null>(null);
|
|
69
|
+
|
|
70
|
+
function sortByDom(list: Entry[]): Entry[] {
|
|
71
|
+
return [...list].sort((a, b) => {
|
|
72
|
+
const pos = a.el.compareDocumentPosition(b.el);
|
|
73
|
+
if (pos & Node.DOCUMENT_POSITION_FOLLOWING) return -1;
|
|
74
|
+
if (pos & Node.DOCUMENT_POSITION_PRECEDING) return 1;
|
|
75
|
+
return 0;
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// register/unregister are called from each row's $effect. They read AND write
|
|
80
|
+
// `entries`, so the read must be untracked — otherwise the calling effect would
|
|
81
|
+
// subscribe to `entries`, and writing it would re-run the effect forever.
|
|
82
|
+
function register(el: HTMLElement, rowValue: string | undefined): () => void {
|
|
83
|
+
untrack(() => {
|
|
84
|
+
entries = sortByDom([...entries.filter((e) => e.el !== el), { el, value: rowValue }]);
|
|
85
|
+
});
|
|
86
|
+
return () => {
|
|
87
|
+
untrack(() => {
|
|
88
|
+
entries = entries.filter((e) => e.el !== el);
|
|
89
|
+
if (tabStopEl === el) tabStopEl = null;
|
|
90
|
+
});
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Default roving stop = first registered (DOM-ordered) row when none focused.
|
|
95
|
+
const effectiveTabStop = $derived(tabStopEl ?? entries[0]?.el ?? null);
|
|
96
|
+
|
|
97
|
+
function valueOf(el: HTMLElement): string | undefined {
|
|
98
|
+
return entries.find((e) => e.el === el)?.value;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function isSelected(el: HTMLElement): boolean {
|
|
102
|
+
const v = valueOf(el);
|
|
103
|
+
return v !== undefined && selectedValues.has(v);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function isTabStop(el: HTMLElement): boolean {
|
|
107
|
+
return el === effectiveTabStop;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function emit(next: Set<string>) {
|
|
111
|
+
if (!controlled) internal = next;
|
|
112
|
+
if (multiple) onchange?.([...next]);
|
|
113
|
+
else onchange?.(next.size ? [...next][0] : null);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function activate(el: HTMLElement) {
|
|
117
|
+
const v = valueOf(el);
|
|
118
|
+
if (v === undefined) return;
|
|
119
|
+
const current = selectedValues;
|
|
120
|
+
let next: Set<string>;
|
|
121
|
+
if (multiple) {
|
|
122
|
+
next = new Set(current);
|
|
123
|
+
if (next.has(v)) next.delete(v);
|
|
124
|
+
else next.add(v);
|
|
125
|
+
} else {
|
|
126
|
+
// Single-select toggles off when re-activating the selected row.
|
|
127
|
+
next = current.has(v) && current.size === 1 ? new Set() : new Set([v]);
|
|
128
|
+
}
|
|
129
|
+
emit(next);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function focusRow(el: HTMLElement) {
|
|
133
|
+
tabStopEl = el;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function navigate(el: HTMLElement, key: string) {
|
|
137
|
+
if (entries.length === 0) return;
|
|
138
|
+
const idx = entries.findIndex((e) => e.el === el);
|
|
139
|
+
if (idx === -1) return;
|
|
140
|
+
let targetIdx = idx;
|
|
141
|
+
if (key === "ArrowDown" || key === "ArrowRight") targetIdx = idx + 1;
|
|
142
|
+
else if (key === "ArrowUp" || key === "ArrowLeft") targetIdx = idx - 1;
|
|
143
|
+
else if (key === "Home") targetIdx = 0;
|
|
144
|
+
else if (key === "End") targetIdx = entries.length - 1;
|
|
145
|
+
// Clamp (no wrap) so Home/End and arrows stay within bounds.
|
|
146
|
+
targetIdx = Math.max(0, Math.min(entries.length - 1, targetIdx));
|
|
147
|
+
const target = entries[targetIdx]?.el;
|
|
148
|
+
if (target) {
|
|
149
|
+
tabStopEl = target;
|
|
150
|
+
target.focus();
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const context: SelectableListContext = {
|
|
155
|
+
managed: true,
|
|
156
|
+
itemRole: "option",
|
|
157
|
+
register,
|
|
158
|
+
isSelected,
|
|
159
|
+
isTabStop,
|
|
160
|
+
activate,
|
|
161
|
+
focusRow,
|
|
162
|
+
navigate
|
|
163
|
+
};
|
|
164
|
+
setContext(SELECTABLE_LIST_KEY, context);
|
|
165
|
+
|
|
166
|
+
const classes = $derived(["st-selectableList", className].filter(Boolean).join(" "));
|
|
167
|
+
</script>
|
|
168
|
+
|
|
169
|
+
<div
|
|
170
|
+
class={classes}
|
|
171
|
+
role="listbox"
|
|
172
|
+
aria-label={labelledby ? undefined : label}
|
|
173
|
+
aria-labelledby={labelledby}
|
|
174
|
+
aria-multiselectable={multiple ? "true" : undefined}
|
|
175
|
+
>
|
|
176
|
+
{@render children?.()}
|
|
177
|
+
</div>
|
|
178
|
+
|
|
179
|
+
<style>
|
|
180
|
+
.st-selectableList {
|
|
181
|
+
display: flex;
|
|
182
|
+
flex-direction: column;
|
|
183
|
+
gap: var(--st-spacing-1, 0.25rem);
|
|
184
|
+
width: 100%;
|
|
185
|
+
}
|
|
186
|
+
</style>
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Snippet } from "svelte";
|
|
2
|
+
export type SelectableListProps = {
|
|
3
|
+
/** Accessible name for the listbox (required for SR users). */
|
|
4
|
+
label?: string;
|
|
5
|
+
/** References the id of an external visible label (alternative to `label`). */
|
|
6
|
+
labelledby?: string;
|
|
7
|
+
/**
|
|
8
|
+
* Allow more than one selected row. Adds aria-multiselectable and toggles
|
|
9
|
+
* each row independently. Defaults to false (single-select).
|
|
10
|
+
*/
|
|
11
|
+
multiple?: boolean;
|
|
12
|
+
/**
|
|
13
|
+
* Selected value(s). Controlled when provided. For single-select pass a
|
|
14
|
+
* string (or null); for multiple pass a string[]. When omitted the list is
|
|
15
|
+
* uncontrolled and keeps its own internal selection.
|
|
16
|
+
*/
|
|
17
|
+
value?: string | string[] | null;
|
|
18
|
+
/**
|
|
19
|
+
* Fired with the new selection on every change. Receives a string|null for
|
|
20
|
+
* single-select and a string[] for multiple. Required for the controlled
|
|
21
|
+
* pattern; also fires for uncontrolled lists.
|
|
22
|
+
*/
|
|
23
|
+
onchange?: (value: string | string[] | null) => void;
|
|
24
|
+
class?: string;
|
|
25
|
+
children?: Snippet;
|
|
26
|
+
};
|
|
27
|
+
declare const SelectableList: import("svelte").Component<SelectableListProps, {}, "">;
|
|
28
|
+
type SelectableList = ReturnType<typeof SelectableList>;
|
|
29
|
+
export default SelectableList;
|
|
30
|
+
//# sourceMappingURL=SelectableList.svelte.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"SelectableList.svelte.d.ts","sourceRoot":"","sources":["../src/lib/SelectableList.svelte.ts"],"names":[],"mappings":"AAGE,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AAEtC,MAAM,MAAM,mBAAmB,GAAG;IAChC,+DAA+D;IAC/D,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,+EAA+E;IAC/E,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;OAGG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB;;;;OAIG;IACH,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,IAAI,CAAC;IACjC;;;;OAIG;IACH,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,IAAI,KAAK,IAAI,CAAC;IACrD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB,CAAC;AA0JJ,QAAA,MAAM,cAAc,yDAAwC,CAAC;AAC7D,KAAK,cAAc,GAAG,UAAU,CAAC,OAAO,cAAc,CAAC,CAAC;AACxD,eAAe,cAAc,CAAC"}
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
import type { Snippet } from "svelte";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Shared context contract between {@link SelectableList} and its slotted
|
|
6
|
+
* {@link SelectableRow} children. The list owns selection + roving tabindex and
|
|
7
|
+
* exposes reactive getters the rows read to derive their own `role` / `tabindex`
|
|
8
|
+
* / `aria-selected`, plus callbacks for registration and activation. When a row
|
|
9
|
+
* is used STANDALONE (no list) `getContext` returns undefined and the row falls
|
|
10
|
+
* back to its own `role` / `tabindex` / selection state.
|
|
11
|
+
*/
|
|
12
|
+
export const SELECTABLE_LIST_KEY = Symbol("st-selectable-list");
|
|
13
|
+
|
|
14
|
+
export type SelectableListContext = {
|
|
15
|
+
/** True when the list manages selection/roving for its rows. */
|
|
16
|
+
readonly managed: true;
|
|
17
|
+
/** listbox role for the wrapper → rows are "option". */
|
|
18
|
+
readonly itemRole: "option";
|
|
19
|
+
/** Register a row element; returns an unregister callback. */
|
|
20
|
+
register: (el: HTMLElement, value: string | undefined) => () => void;
|
|
21
|
+
/** Is the row with this element currently selected? */
|
|
22
|
+
isSelected: (el: HTMLElement) => boolean;
|
|
23
|
+
/** Should the row with this element be the roving-tabindex stop (tabindex 0)? */
|
|
24
|
+
isTabStop: (el: HTMLElement) => boolean;
|
|
25
|
+
/** Row was activated (click / Space / Enter). The list toggles selection. */
|
|
26
|
+
activate: (el: HTMLElement) => void;
|
|
27
|
+
/** Row received focus → becomes the roving tab stop. */
|
|
28
|
+
focusRow: (el: HTMLElement) => void;
|
|
29
|
+
/** Arrow / Home / End navigation from a row. */
|
|
30
|
+
navigate: (el: HTMLElement, key: string) => void;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type SelectableRowProps = {
|
|
34
|
+
/**
|
|
35
|
+
* Selected state (bindable). Honoured when the row is used STANDALONE; inside
|
|
36
|
+
* a {@link SelectableList} the list is the source of truth and drives the
|
|
37
|
+
* selected styling, so this prop is ignored for managed rows.
|
|
38
|
+
*/
|
|
39
|
+
selected?: boolean;
|
|
40
|
+
/** Notified on every toggle with the new selected state (standalone rows). */
|
|
41
|
+
onselect?: (selected: boolean) => void;
|
|
42
|
+
/** Non-interactive when true. */
|
|
43
|
+
disabled?: boolean;
|
|
44
|
+
/** Stable value, surfaced as `data-value` and used by the list for `value`. */
|
|
45
|
+
value?: string;
|
|
46
|
+
/**
|
|
47
|
+
* ARIA role for the standalone row. Defaults to "option" so a lone row still
|
|
48
|
+
* reads as a selectable item. Inside a list the role is forced to "option".
|
|
49
|
+
*/
|
|
50
|
+
role?: string;
|
|
51
|
+
/**
|
|
52
|
+
* Opt-in left accent bar on the selected state. Off by default so the
|
|
53
|
+
* selected item is a calm tinted surface + accented text (two signals only).
|
|
54
|
+
*/
|
|
55
|
+
accentBar?: boolean;
|
|
56
|
+
/** Leading slot (icon / avatar). */
|
|
57
|
+
leading?: Snippet;
|
|
58
|
+
/** Trailing slot (meta / icon). */
|
|
59
|
+
trailing?: Snippet;
|
|
60
|
+
/** Main content. */
|
|
61
|
+
children?: Snippet;
|
|
62
|
+
class?: string;
|
|
63
|
+
};
|
|
64
|
+
</script>
|
|
65
|
+
|
|
66
|
+
<script lang="ts">
|
|
67
|
+
import { getContext } from "svelte";
|
|
68
|
+
|
|
69
|
+
let {
|
|
70
|
+
selected = $bindable(false),
|
|
71
|
+
onselect,
|
|
72
|
+
disabled = false,
|
|
73
|
+
value,
|
|
74
|
+
role = "option",
|
|
75
|
+
accentBar = false,
|
|
76
|
+
leading,
|
|
77
|
+
trailing,
|
|
78
|
+
children,
|
|
79
|
+
class: className
|
|
80
|
+
}: SelectableRowProps = $props();
|
|
81
|
+
|
|
82
|
+
// When rendered inside a SelectableList, the list (via context) owns selection
|
|
83
|
+
// and the roving tabindex. Standalone rows manage their own state.
|
|
84
|
+
const list = getContext<SelectableListContext | undefined>(SELECTABLE_LIST_KEY);
|
|
85
|
+
|
|
86
|
+
let el: HTMLElement | null = $state(null);
|
|
87
|
+
|
|
88
|
+
// Register with the parent list (if any) so it can order rows for arrow nav
|
|
89
|
+
// and compute the roving tab stop. The effect re-registers if value changes.
|
|
90
|
+
$effect(() => {
|
|
91
|
+
if (!list || !el || disabled) return;
|
|
92
|
+
return list.register(el, value);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// Effective selected state: list-managed rows read the list; standalone rows
|
|
96
|
+
// use their own bindable prop.
|
|
97
|
+
const isSelected = $derived(list && el ? list.isSelected(el) : selected);
|
|
98
|
+
|
|
99
|
+
// Effective role: a managed row is always an "option" inside the listbox.
|
|
100
|
+
const effectiveRole = $derived(list ? list.itemRole : role);
|
|
101
|
+
|
|
102
|
+
// Roving tabindex: in a list, exactly one enabled row is the tab stop (0), the
|
|
103
|
+
// rest are -1. Standalone enabled rows are always tabbable (0). Disabled = -1.
|
|
104
|
+
const tabindex = $derived(
|
|
105
|
+
disabled ? -1 : list && el ? (list.isTabStop(el) ? 0 : -1) : 0
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
const classes = $derived(
|
|
109
|
+
[
|
|
110
|
+
"st-selectableRow",
|
|
111
|
+
isSelected ? "st-selectableRow--selected" : null,
|
|
112
|
+
disabled ? "st-selectableRow--disabled" : null,
|
|
113
|
+
accentBar ? "st-selectableRow--accentBar" : null,
|
|
114
|
+
className
|
|
115
|
+
]
|
|
116
|
+
.filter(Boolean)
|
|
117
|
+
.join(" ")
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
function activate() {
|
|
121
|
+
if (disabled) return;
|
|
122
|
+
if (list && el) {
|
|
123
|
+
list.activate(el);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
selected = !selected;
|
|
127
|
+
onselect?.(selected);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function handleKeydown(e: KeyboardEvent) {
|
|
131
|
+
if (disabled) return;
|
|
132
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
133
|
+
e.preventDefault();
|
|
134
|
+
activate();
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
// Roving navigation is owned by the list; forward the relevant keys.
|
|
138
|
+
if (
|
|
139
|
+
list &&
|
|
140
|
+
el &&
|
|
141
|
+
(e.key === "ArrowDown" ||
|
|
142
|
+
e.key === "ArrowUp" ||
|
|
143
|
+
e.key === "ArrowLeft" ||
|
|
144
|
+
e.key === "ArrowRight" ||
|
|
145
|
+
e.key === "Home" ||
|
|
146
|
+
e.key === "End")
|
|
147
|
+
) {
|
|
148
|
+
e.preventDefault();
|
|
149
|
+
list.navigate(el, e.key);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function handleFocus() {
|
|
154
|
+
if (disabled) return;
|
|
155
|
+
if (list && el) list.focusRow(el);
|
|
156
|
+
}
|
|
157
|
+
</script>
|
|
158
|
+
|
|
159
|
+
<!-- The row carries an interactive ARIA role (option/listbox item) so a roving
|
|
160
|
+
tabindex is correct; the role is dynamic, which the static a11y check cannot
|
|
161
|
+
verify, hence the targeted ignore. -->
|
|
162
|
+
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
|
163
|
+
<div
|
|
164
|
+
bind:this={el}
|
|
165
|
+
class={classes}
|
|
166
|
+
role={effectiveRole}
|
|
167
|
+
aria-selected={effectiveRole === "option" ? isSelected : undefined}
|
|
168
|
+
aria-disabled={disabled ? "true" : undefined}
|
|
169
|
+
data-value={value}
|
|
170
|
+
{tabindex}
|
|
171
|
+
onclick={activate}
|
|
172
|
+
onkeydown={handleKeydown}
|
|
173
|
+
onfocus={handleFocus}
|
|
174
|
+
>
|
|
175
|
+
{#if leading}
|
|
176
|
+
<span class="st-selectableRow__leading">{@render leading()}</span>
|
|
177
|
+
{/if}
|
|
178
|
+
<span class="st-selectableRow__content">{@render children?.()}</span>
|
|
179
|
+
{#if trailing}
|
|
180
|
+
<span class="st-selectableRow__trailing">{@render trailing()}</span>
|
|
181
|
+
{/if}
|
|
182
|
+
</div>
|
|
183
|
+
|
|
184
|
+
<style>
|
|
185
|
+
/* Compact, full-width selectable list/rail row. By DEFAULT the selected state
|
|
186
|
+
is just two calm signals — a tinted surface + accented text — deliberately
|
|
187
|
+
NOT the off-theme "boudin box" (inset box-shadow + heavy rounded border) it
|
|
188
|
+
replaces, and NOT a reflow-causing font-weight bump. The fine left accent
|
|
189
|
+
bar is OPT-IN via the `accentBar` prop. */
|
|
190
|
+
.st-selectableRow {
|
|
191
|
+
align-items: center;
|
|
192
|
+
background: transparent;
|
|
193
|
+
border-radius: var(--st-radius-sm, 0.25rem);
|
|
194
|
+
box-sizing: border-box;
|
|
195
|
+
color: var(--st-semantic-text-secondary, #475569);
|
|
196
|
+
cursor: pointer;
|
|
197
|
+
display: flex;
|
|
198
|
+
gap: var(--st-spacing-2, 0.5rem);
|
|
199
|
+
padding: 0.5rem 0.75rem;
|
|
200
|
+
position: relative;
|
|
201
|
+
text-align: left;
|
|
202
|
+
transition:
|
|
203
|
+
background-color var(--st-motion-fast, 120ms) var(--st-motion-easing, ease),
|
|
204
|
+
color var(--st-motion-fast, 120ms) var(--st-motion-easing, ease);
|
|
205
|
+
user-select: none;
|
|
206
|
+
width: 100%;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/* Opt-in accent bar: reserve the 2px gutter only when enabled so text never
|
|
210
|
+
shifts on selection. */
|
|
211
|
+
.st-selectableRow--accentBar {
|
|
212
|
+
padding-left: calc(0.75rem - 2px);
|
|
213
|
+
border-left: 2px solid transparent;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
.st-selectableRow:hover:not(.st-selectableRow--disabled):not(.st-selectableRow--selected) {
|
|
217
|
+
background: var(
|
|
218
|
+
--st-component-control-hoverBackground,
|
|
219
|
+
var(--st-semantic-surface-subtle, #f8fafc)
|
|
220
|
+
);
|
|
221
|
+
color: var(--st-semantic-text-primary, #0f172a);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/* Focus ring as an EXTERNAL offset (not inset) so it reads as a focus
|
|
225
|
+
affordance around the row rather than an inner stroke. */
|
|
226
|
+
.st-selectableRow:focus-visible {
|
|
227
|
+
outline: 2px solid var(--st-semantic-border-interactive, var(--st-semantic-action-primary));
|
|
228
|
+
outline-offset: 2px;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/* Selected: two signals by default — tinted surface + accented (contrast-safe)
|
|
232
|
+
text. The token values carry a flat fallback; the inline color-mix is only a
|
|
233
|
+
last-resort default when the token is unset. */
|
|
234
|
+
.st-selectableRow--selected {
|
|
235
|
+
background: var(
|
|
236
|
+
--st-component-selectableRow-selectedBackground,
|
|
237
|
+
color-mix(in oklch, var(--st-semantic-action-primary, #2563eb) 12%, transparent)
|
|
238
|
+
);
|
|
239
|
+
color: var(
|
|
240
|
+
--st-component-selectableRow-selectedText,
|
|
241
|
+
color-mix(in oklch, var(--st-semantic-action-primary, #2563eb) 78%, black)
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/* The left accent bar paints only when opt-in AND selected. */
|
|
246
|
+
.st-selectableRow--accentBar.st-selectableRow--selected {
|
|
247
|
+
border-left-color: var(
|
|
248
|
+
--st-component-selectableRow-selectedAccent,
|
|
249
|
+
var(--st-semantic-action-primary, #2563eb)
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/* color-mix fallback: engines without color-mix() get a flat tinted surface +
|
|
254
|
+
a solid accent text from the resolved tokens' plain values. */
|
|
255
|
+
@supports not (color: color-mix(in oklch, red, blue)) {
|
|
256
|
+
.st-selectableRow--selected {
|
|
257
|
+
background: var(
|
|
258
|
+
--st-component-selectableRow-selectedBackground,
|
|
259
|
+
var(--st-semantic-surface-subtle, #eef2ff)
|
|
260
|
+
);
|
|
261
|
+
color: var(
|
|
262
|
+
--st-component-selectableRow-selectedText,
|
|
263
|
+
var(--st-semantic-action-primary, #1d4ed8)
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
.st-selectableRow--disabled {
|
|
269
|
+
cursor: not-allowed;
|
|
270
|
+
opacity: 0.55;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
.st-selectableRow__leading,
|
|
274
|
+
.st-selectableRow__trailing {
|
|
275
|
+
align-items: center;
|
|
276
|
+
display: inline-flex;
|
|
277
|
+
flex: 0 0 auto;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
.st-selectableRow__content {
|
|
281
|
+
flex: 1 1 auto;
|
|
282
|
+
min-width: 0;
|
|
283
|
+
overflow: hidden;
|
|
284
|
+
text-overflow: ellipsis;
|
|
285
|
+
white-space: nowrap;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
@media (prefers-reduced-motion: reduce) {
|
|
289
|
+
.st-selectableRow { transition: none; }
|
|
290
|
+
}
|
|
291
|
+
</style>
|