@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.
@@ -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>