@lilydesignsystem/svelte-headless 0.5.0 → 0.6.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,108 @@
1
+ <script lang="ts">
2
+ // MiddleTruncation component
3
+ //
4
+ // A string shortened in the middle to fit the width its container gives it ("report-fi…q3.pdf"), keeping the start and the
5
+ // end. The width is measured in the browser (canvas text metrics in the element's own font) and re-measured when the
6
+ // container resizes; until then, and on the server, the full text is shown.
7
+ //
8
+ // The full text always stays in the accessibility tree: the shortened string is aria-hidden and the whole text sits in a
9
+ // sibling the consumer visually hides. A screen reader, find-in-page and copy therefore get the real text, which a
10
+ // title attribute (hover only) or a text-only truncation would lose.
11
+ //
12
+ // Props:
13
+ // class — string, optional. Consumer class appended after the base class.
14
+ // text — string, required. The full text.
15
+ // ellipsis — string, required. The cut marker. No default: the headless layer ships no text.
16
+ // strategy — "even" | "fixed-end" | "min-end", default "even".
17
+ // even: start and end share the room. fixed-end: exactly `end` characters at the end. min-end: even, but at least `end` at the end.
18
+ // end — number, optional. Characters kept at the end for "fixed-end" and "min-end".
19
+ // ...restProps — spread onto the root <span>.
20
+ //
21
+ // Markup:
22
+ // <span class="middle-truncation" data-strategy data-truncated?>
23
+ // <span class="middle-truncation-visual" aria-hidden="true">report-fi…q3.pdf</span>
24
+ // <span class="middle-truncation-full">report-final-q3.pdf</span>
25
+ // </span>
26
+ //
27
+ // Consumer CSS (the component ships none): the root must be a block with a bounded width, `overflow: hidden` and
28
+ // `white-space: nowrap`, and `.middle-truncation-full` must be visually hidden (clipped, not display:none).
29
+ //
30
+ // Keyboard: none.
31
+ //
32
+ // Claude rules:
33
+ // - Headless: no CSS, no inline styles.
34
+ // - No hardcoded user-facing strings: the ellipsis is a required prop.
35
+
36
+ import { onMount } from "svelte";
37
+ import { truncateMiddle, type TruncationStrategy } from "./middle-truncation";
38
+
39
+ let {
40
+ class: className = "",
41
+ text,
42
+ ellipsis,
43
+ strategy = "even",
44
+ end,
45
+ ...restProps
46
+ }: {
47
+ /** The full text. */
48
+ text: string;
49
+ /** The cut marker; no default. */
50
+ ellipsis: string;
51
+ /** How the room is shared between start and end. */
52
+ strategy?: TruncationStrategy;
53
+ /** Characters kept at the end for "fixed-end" and "min-end". */
54
+ end?: number;
55
+ [key: string]: unknown;
56
+ } = $props();
57
+
58
+ let root: HTMLSpanElement | undefined = $state();
59
+ let shown: string | null = $state(null);
60
+ const displayed = $derived(shown ?? text);
61
+
62
+ let context: CanvasRenderingContext2D | null | undefined;
63
+
64
+ function recalculate(): void {
65
+ if (!root) return;
66
+ const width = root.clientWidth;
67
+ if (context === undefined) context = document.createElement("canvas").getContext("2d");
68
+ if (!context || width <= 0) {
69
+ shown = null;
70
+ return;
71
+ }
72
+ const style = getComputedStyle(root);
73
+ context.font = `${style.fontStyle} ${style.fontWeight} ${style.fontSize} ${style.fontFamily}`;
74
+ const ctx = context;
75
+ const result = truncateMiddle(text, { ellipsis, strategy, end, maxWidth: width, measure: (s) => ctx.measureText(s).width });
76
+ shown = result === text ? null : result;
77
+ }
78
+
79
+ // Re-measure whenever the text or the options change.
80
+ $effect(() => {
81
+ text; ellipsis; strategy; end;
82
+ recalculate();
83
+ });
84
+
85
+ // Re-measure when the container resizes, at most once per animation frame.
86
+ onMount(() => {
87
+ if (!root || typeof ResizeObserver === "undefined") return;
88
+ let frame = 0;
89
+ const observer = new ResizeObserver(() => {
90
+ cancelAnimationFrame(frame);
91
+ frame = requestAnimationFrame(recalculate);
92
+ });
93
+ observer.observe(root);
94
+ return () => {
95
+ cancelAnimationFrame(frame);
96
+ observer.disconnect();
97
+ };
98
+ });
99
+ </script>
100
+
101
+ <!-- MiddleTruncation.svelte -->
102
+ <span
103
+ bind:this={root}
104
+ class={`middle-truncation ${className}`}
105
+ data-strategy={strategy}
106
+ data-truncated={shown === null ? undefined : ""}
107
+ {...restProps}
108
+ ><span class="middle-truncation-visual" aria-hidden="true">{displayed}</span><span class="middle-truncation-full">{text}</span></span>
@@ -0,0 +1,15 @@
1
+ import { type TruncationStrategy } from "./middle-truncation";
2
+ type $$ComponentProps = {
3
+ /** The full text. */
4
+ text: string;
5
+ /** The cut marker; no default. */
6
+ ellipsis: string;
7
+ /** How the room is shared between start and end. */
8
+ strategy?: TruncationStrategy;
9
+ /** Characters kept at the end for "fixed-end" and "min-end". */
10
+ end?: number;
11
+ [key: string]: unknown;
12
+ };
13
+ declare const MiddleTruncation: import("svelte").Component<$$ComponentProps, {}, "">;
14
+ type MiddleTruncation = ReturnType<typeof MiddleTruncation>;
15
+ export default MiddleTruncation;
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Middle truncation: shorten a string to fit a width by cutting out the middle, keeping the start and the end ("report-fi…q3.pdf").
3
+ * Pure and framework-neutral: the caller supplies `measure`, so the module reads no DOM and is tested without a browser.
4
+ *
5
+ * Works on grapheme clusters (Intl.Segmenter, falling back to code points), so it never splits a surrogate pair, a combining
6
+ * sequence or an emoji. The width search is binary, so it measures O(log n) candidate strings.
7
+ *
8
+ * Strategies:
9
+ * "even" the start and the end share what fits, the end taking the extra cluster when the count is odd.
10
+ * "fixed-end" exactly `end` clusters are kept at the end; the start takes all the remaining room.
11
+ * "min-end" an even split, but at least `end` clusters are kept at the end (a file extension, a domain suffix).
12
+ */
13
+ export type TruncationStrategy = "even" | "fixed-end" | "min-end";
14
+ export interface TruncateMiddleOptions {
15
+ /** The ellipsis string. Required: the module has no default text (internationalisation). */
16
+ ellipsis: string;
17
+ /** The width available to the whole result, in the unit `measure` returns. */
18
+ maxWidth: number;
19
+ /** Width of a string in the same unit as `maxWidth`. */
20
+ measure: (text: string) => number;
21
+ strategy?: TruncationStrategy;
22
+ /** Clusters kept at the end for "fixed-end" and "min-end". Ignored by "even". */
23
+ end?: number;
24
+ }
25
+ /** Split into user-perceived characters. */
26
+ export declare function splitGraphemes(text: string): string[];
27
+ /** The text shortened to fit `maxWidth`, or unchanged when it already fits. */
28
+ export declare function truncateMiddle(text: string, options: TruncateMiddleOptions): string;
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Middle truncation: shorten a string to fit a width by cutting out the middle, keeping the start and the end ("report-fi…q3.pdf").
3
+ * Pure and framework-neutral: the caller supplies `measure`, so the module reads no DOM and is tested without a browser.
4
+ *
5
+ * Works on grapheme clusters (Intl.Segmenter, falling back to code points), so it never splits a surrogate pair, a combining
6
+ * sequence or an emoji. The width search is binary, so it measures O(log n) candidate strings.
7
+ *
8
+ * Strategies:
9
+ * "even" the start and the end share what fits, the end taking the extra cluster when the count is odd.
10
+ * "fixed-end" exactly `end` clusters are kept at the end; the start takes all the remaining room.
11
+ * "min-end" an even split, but at least `end` clusters are kept at the end (a file extension, a domain suffix).
12
+ */
13
+ /** Split into user-perceived characters. */
14
+ export function splitGraphemes(text) {
15
+ const Segmenter = Intl.Segmenter;
16
+ if (Segmenter)
17
+ return Array.from(new Segmenter(undefined, { granularity: "grapheme" }).segment(text), (s) => s.segment);
18
+ return Array.from(text);
19
+ }
20
+ function clampEnd(end, length) {
21
+ if (end === undefined || !Number.isFinite(end))
22
+ return 0;
23
+ return Math.min(Math.max(Math.floor(end), 0), length);
24
+ }
25
+ /** The text shortened to fit `maxWidth`, or unchanged when it already fits. */
26
+ export function truncateMiddle(text, options) {
27
+ const { ellipsis, maxWidth, measure, strategy = "even" } = options;
28
+ if (measure(text) <= maxWidth)
29
+ return text;
30
+ const g = splitGraphemes(text);
31
+ const n = g.length;
32
+ const tail = (count) => (count > 0 ? g.slice(n - count).join("") : "");
33
+ const head = (count) => g.slice(0, count).join("");
34
+ if (strategy === "fixed-end") {
35
+ const end = clampEnd(options.end, n);
36
+ const endText = tail(end);
37
+ const room = maxWidth - measure(ellipsis + endText);
38
+ let low = 0;
39
+ let high = n - end;
40
+ while (low < high) {
41
+ const middle = Math.ceil((low + high) / 2);
42
+ if (measure(head(middle)) <= room)
43
+ low = middle;
44
+ else
45
+ high = middle - 1;
46
+ }
47
+ return head(low) + ellipsis + endText;
48
+ }
49
+ const minEnd = strategy === "min-end" ? clampEnd(options.end, n) : 0;
50
+ const split = (kept) => {
51
+ const endCount = Math.min(n, Math.max(Math.ceil(kept / 2), minEnd));
52
+ return [Math.max(0, kept - endCount), endCount];
53
+ };
54
+ const room = maxWidth - measure(ellipsis);
55
+ let low = 0;
56
+ let high = n;
57
+ while (low < high) {
58
+ const middle = Math.ceil((low + high) / 2);
59
+ const [startCount, endCount] = split(middle);
60
+ if (measure(head(startCount) + tail(endCount)) <= room)
61
+ low = middle;
62
+ else
63
+ high = middle - 1;
64
+ }
65
+ const [startCount, endCount] = split(low);
66
+ return head(startCount) + ellipsis + tail(endCount);
67
+ }
@@ -62,7 +62,7 @@
62
62
  /** Current value. Bindable. */
63
63
  value?: string;
64
64
  /** Autocomplete hint. */
65
- autocomplete?: string;
65
+ autocomplete?: "current-password" | "new-password" | "off";
66
66
  /** Whether required. */
67
67
  required?: boolean;
68
68
  /** Whether disabled. */
@@ -4,7 +4,7 @@ type $$ComponentProps = {
4
4
  /** Current value. Bindable. */
5
5
  value?: string;
6
6
  /** Autocomplete hint. */
7
- autocomplete?: string;
7
+ autocomplete?: "current-password" | "new-password" | "off";
8
8
  /** Whether required. */
9
9
  required?: boolean;
10
10
  /** Whether disabled. */
@@ -40,7 +40,7 @@
40
40
  //
41
41
  // Claude rules:
42
42
  // - Headless: no CSS, no styles -- consumer provides all styling
43
- // - Queries [role='treeitem'] descendants for keyboard navigation
43
+ // - Queries [role='treeitem'] descendants for keyboard navigation, skipping items inside a collapsed (aria-expanded=false) item
44
44
  // - Uses $state() for internal DOM reference
45
45
  //
46
46
  // References:
@@ -68,7 +68,7 @@
68
68
  if (!treeRef) return;
69
69
  const items = Array.from(
70
70
  treeRef.querySelectorAll<HTMLElement>("[role='treeitem']"),
71
- );
71
+ ).filter((el) => !el.parentElement?.closest("[role='treeitem'][aria-expanded='false']"));
72
72
  const current = document.activeElement as HTMLElement;
73
73
  const index = items.indexOf(current);
74
74
  switch (event.key) {
@@ -3,16 +3,52 @@
3
3
  let {
4
4
  class: className = "",
5
5
  label = undefined,
6
+ expanded = $bindable(undefined),
6
7
  children,
7
8
  ...restProps
8
9
  }: {
9
10
  label?: string;
11
+ /** Expanded state, bindable. Leave undefined for a leaf (no aria-expanded). */
12
+ expanded?: boolean;
10
13
  children: Snippet;
11
14
  [key: string]: unknown;
12
15
  } = $props();
16
+
17
+ // ArrowRight/ArrowLeft (WAI-ARIA tree pattern). Only for the focused item itself, not a
18
+ // nested one the event bubbled up from. Swapped in right-to-left layouts.
19
+ function onkeydown(event: KeyboardEvent) {
20
+ const item = event.currentTarget as HTMLElement;
21
+ if (event.target !== item) return;
22
+ const rtl = getComputedStyle(item).direction === "rtl";
23
+ const open = rtl ? "ArrowLeft" : "ArrowRight";
24
+ const close = rtl ? "ArrowRight" : "ArrowLeft";
25
+ if (event.key === open) {
26
+ if (expanded === false) {
27
+ event.preventDefault();
28
+ expanded = true;
29
+ } else if (expanded === true) {
30
+ const child = item.querySelector<HTMLElement>("[role='treeitem']");
31
+ if (child) {
32
+ event.preventDefault();
33
+ child.focus();
34
+ }
35
+ }
36
+ } else if (event.key === close) {
37
+ if (expanded === true) {
38
+ event.preventDefault();
39
+ expanded = false;
40
+ } else {
41
+ const parent = item.parentElement?.closest<HTMLElement>("[role='treeitem']");
42
+ if (parent) {
43
+ event.preventDefault();
44
+ parent.focus();
45
+ }
46
+ }
47
+ }
48
+ }
13
49
  </script>
14
50
 
15
51
  <!-- TreeListItem.svelte -->
16
- <div class={`tree-list-item ${className}`} aria-label={label} {...restProps}>
52
+ <li class={`tree-list-item ${className}`} role="treeitem" tabindex="-1" aria-label={label} aria-expanded={expanded} {onkeydown} {...restProps}>
17
53
  {@render children?.()}
18
- </div>
54
+ </li>
@@ -1,9 +1,11 @@
1
1
  import type { Snippet } from "svelte";
2
2
  type $$ComponentProps = {
3
3
  label?: string;
4
+ /** Expanded state, bindable. Leave undefined for a leaf (no aria-expanded). */
5
+ expanded?: boolean;
4
6
  children: Snippet;
5
7
  [key: string]: unknown;
6
8
  };
7
- declare const TreeListItem: import("svelte").Component<$$ComponentProps, {}, "">;
9
+ declare const TreeListItem: import("svelte").Component<$$ComponentProps, {}, "expanded">;
8
10
  type TreeListItem = ReturnType<typeof TreeListItem>;
9
11
  export default TreeListItem;
@@ -0,0 +1,150 @@
1
+ <script lang="ts">
2
+ // WheelPicker component
3
+ //
4
+ // A vertical single-select listbox where the selection follows the cursor: moving with the arrow keys, typing, or
5
+ // clicking an option chooses it. Use it to pick one of a short list (hours, minutes, months, years) in the manner of a
6
+ // scroll wheel. The component owns the roles, the keys and the selected value; the wheel LOOK (scroll snapping, fading,
7
+ // 3-D) is the consumer's CSS. Pointer dragging and touch inertia are plain scrolling of `.wheel-picker-list`.
8
+ //
9
+ // Props:
10
+ // class — string, optional. Consumer class appended after the base class.
11
+ // label — string, required. Accessible name of the listbox.
12
+ // options — { value: string; label: string; disabled?: boolean }[], required.
13
+ // value — string, bindable, default "". The selected option's value; "" or an unknown value selects nothing.
14
+ // loop — boolean, default false. Arrow keys wrap from the last enabled option to the first and back.
15
+ // pageSize — number, default 5. Enabled options PageUp / PageDown move by.
16
+ // disabled — boolean, default false. Ignores keys and clicks; not focusable.
17
+ // name — string, optional. When set, a hidden input carries the value for a form.
18
+ // onChange — (value: string) => void, optional. Called once when the selection changes.
19
+ // ...restProps — spread onto the root <div>.
20
+ //
21
+ // Markup:
22
+ // <div class="wheel-picker">
23
+ // <div class="wheel-picker-list" role="listbox" aria-orientation="vertical" tabindex="0" aria-activedescendant>
24
+ // <div class="wheel-picker-option" role="option" aria-selected aria-disabled? data-value>…</div>
25
+ // </div>
26
+ // <input type="hidden" name value> (only with `name`)
27
+ // </div>
28
+ //
29
+ // Keyboard (APG listbox, selection follows focus): ArrowDown / ArrowUp, Home / End, PageDown / PageUp, type-ahead over
30
+ // the labels. Disabled options are skipped.
31
+ //
32
+ // Claude rules:
33
+ // - Headless: no CSS, no inline styles; no animation (the selected option is centred instantly).
34
+ // - No hardcoded user-facing strings.
35
+
36
+ import {
37
+ isWheelTypeaheadKey,
38
+ nextWheelIndex,
39
+ nextWheelPrefix,
40
+ WHEEL_MOVE_KEYS,
41
+ wheelTypeaheadIndex,
42
+ type WheelMoveKey,
43
+ type WheelOption,
44
+ type WheelTypeahead,
45
+ } from "./wheel-picker";
46
+
47
+ let {
48
+ class: className = "",
49
+ label,
50
+ options,
51
+ value = $bindable(""),
52
+ loop = false,
53
+ pageSize = 5,
54
+ disabled = false,
55
+ name,
56
+ onChange,
57
+ ...restProps
58
+ }: {
59
+ /** Accessible name. */
60
+ label: string;
61
+ /** The choices. */
62
+ options: WheelOption[];
63
+ /** The selected option's value. Bindable. */
64
+ value?: string;
65
+ /** Arrow keys wrap at the ends. */
66
+ loop?: boolean;
67
+ /** Enabled options PageUp / PageDown move by. */
68
+ pageSize?: number;
69
+ /** Ignores keys and clicks. */
70
+ disabled?: boolean;
71
+ /** Form field name for a hidden input. */
72
+ name?: string;
73
+ /** Called once when the selection changes. */
74
+ onChange?: (value: string) => void;
75
+ [key: string]: unknown;
76
+ } = $props();
77
+
78
+ const uid = $props.id();
79
+ let list: HTMLElement | undefined = $state();
80
+ let typeahead: WheelTypeahead = { prefix: "", at: 0 };
81
+
82
+ const selectedIndex = $derived(options.findIndex((o) => o.value === value));
83
+
84
+ function select(index: number): void {
85
+ if (index < 0) return;
86
+ const next = options[index].value;
87
+ if (next === value) return; // applying the selection already held is a no-op
88
+ value = next;
89
+ onChange?.(next);
90
+ }
91
+
92
+ function onkeydown(event: KeyboardEvent): void {
93
+ if (disabled) return;
94
+ if (WHEEL_MOVE_KEYS.includes(event.key)) {
95
+ event.preventDefault();
96
+ select(nextWheelIndex(options, selectedIndex, event.key as WheelMoveKey, { loop, pageSize }));
97
+ } else if (isWheelTypeaheadKey(event)) {
98
+ typeahead = nextWheelPrefix(typeahead, event.key, Date.now());
99
+ select(wheelTypeaheadIndex(options, selectedIndex, typeahead.prefix));
100
+ }
101
+ }
102
+
103
+ function onclick(event: MouseEvent): void {
104
+ if (disabled) return;
105
+ const target = (event.target as Element).closest<HTMLElement>('[role="option"]');
106
+ if (!target || target.getAttribute("aria-disabled") === "true") return;
107
+ select(Number(target.getAttribute("data-index")));
108
+ list?.focus();
109
+ }
110
+
111
+ // Keep the selected option centred in the list. Only the list scrolls (never the page), and instantly: no animation.
112
+ $effect(() => {
113
+ const index = selectedIndex;
114
+ if (!list || index < 0) return;
115
+ const option = list.querySelector<HTMLElement>(`[data-index="${index}"]`);
116
+ if (!option) return;
117
+ list.scrollTop = option.offsetTop - (list.clientHeight - option.offsetHeight) / 2;
118
+ });
119
+ </script>
120
+
121
+ <!-- WheelPicker.svelte -->
122
+ <div class={`wheel-picker ${className}`} {...restProps}>
123
+ <div
124
+ bind:this={list}
125
+ class="wheel-picker-list"
126
+ role="listbox"
127
+ aria-label={label}
128
+ aria-orientation="vertical"
129
+ aria-disabled={disabled ? "true" : undefined}
130
+ aria-activedescendant={selectedIndex >= 0 ? `${uid}-option-${selectedIndex}` : undefined}
131
+ tabindex={disabled ? -1 : 0}
132
+ {onkeydown}
133
+ {onclick}
134
+ >
135
+ {#each options as option, index (option.value)}
136
+ <div
137
+ class="wheel-picker-option"
138
+ role="option"
139
+ id={`${uid}-option-${index}`}
140
+ aria-selected={index === selectedIndex}
141
+ aria-disabled={option.disabled ? "true" : undefined}
142
+ data-value={option.value}
143
+ data-index={index}
144
+ >{option.label}</div>
145
+ {/each}
146
+ </div>
147
+ {#if name}
148
+ <input type="hidden" {name} {value} />
149
+ {/if}
150
+ </div>
@@ -0,0 +1,23 @@
1
+ import { type WheelOption } from "./wheel-picker";
2
+ type $$ComponentProps = {
3
+ /** Accessible name. */
4
+ label: string;
5
+ /** The choices. */
6
+ options: WheelOption[];
7
+ /** The selected option's value. Bindable. */
8
+ value?: string;
9
+ /** Arrow keys wrap at the ends. */
10
+ loop?: boolean;
11
+ /** Enabled options PageUp / PageDown move by. */
12
+ pageSize?: number;
13
+ /** Ignores keys and clicks. */
14
+ disabled?: boolean;
15
+ /** Form field name for a hidden input. */
16
+ name?: string;
17
+ /** Called once when the selection changes. */
18
+ onChange?: (value: string) => void;
19
+ [key: string]: unknown;
20
+ };
21
+ declare const WheelPicker: import("svelte").Component<$$ComponentProps, {}, "value">;
22
+ type WheelPicker = ReturnType<typeof WheelPicker>;
23
+ export default WheelPicker;
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Keyboard model for a wheel picker: a single-select listbox where the selection follows the cursor (APG listbox, "selection
3
+ * follows focus"), so moving the cursor IS choosing. Pure and framework-neutral, tested without a browser.
4
+ *
5
+ * Disabled options are skipped by every move. Arrows wrap only when `loop` is set; Home, End, PageUp and PageDown never wrap.
6
+ */
7
+ export interface WheelOption {
8
+ value: string;
9
+ label: string;
10
+ disabled?: boolean;
11
+ }
12
+ export type WheelMoveKey = "ArrowUp" | "ArrowDown" | "Home" | "End" | "PageUp" | "PageDown";
13
+ export declare const WHEEL_MOVE_KEYS: readonly string[];
14
+ export interface WheelMoveOptions {
15
+ /** Arrow keys wrap from the last enabled option to the first and back. Default false. */
16
+ loop?: boolean;
17
+ /** Enabled options PageUp / PageDown move by. Default 5. */
18
+ pageSize?: number;
19
+ }
20
+ /**
21
+ * The index to select after `key`, or -1 when no option can be selected. `current` is the selected index, or -1 for none.
22
+ * With nothing selected, ArrowDown / PageDown / Home choose the first enabled option and ArrowUp / PageUp / End the last.
23
+ */
24
+ export declare function nextWheelIndex(options: readonly WheelOption[], current: number, key: WheelMoveKey, move?: WheelMoveOptions): number;
25
+ export declare const WHEEL_TYPEAHEAD_RESET_MS = 700;
26
+ export interface WheelTypeahead {
27
+ prefix: string;
28
+ at: number;
29
+ }
30
+ /** The prefix after `key` at time `now`: it grows while presses are within the reset time, else starts over. */
31
+ export declare function nextWheelPrefix(previous: WheelTypeahead, key: string, now: number, resetMs?: number): WheelTypeahead;
32
+ /** Whether a key press is a type-ahead character: one printable character other than space, no Ctrl, Alt or Meta. */
33
+ export declare function isWheelTypeaheadKey(event: {
34
+ key: string;
35
+ ctrlKey?: boolean;
36
+ altKey?: boolean;
37
+ metaKey?: boolean;
38
+ }): boolean;
39
+ /**
40
+ * The enabled option to move to for the typed `prefix`, or -1. Searches from the option after `current` and wraps; a prefix of one
41
+ * repeated letter ("ss") counts as that single letter, so pressing it again steps through the options that start with it.
42
+ */
43
+ export declare function wheelTypeaheadIndex(options: readonly WheelOption[], current: number, prefix: string): number;
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Keyboard model for a wheel picker: a single-select listbox where the selection follows the cursor (APG listbox, "selection
3
+ * follows focus"), so moving the cursor IS choosing. Pure and framework-neutral, tested without a browser.
4
+ *
5
+ * Disabled options are skipped by every move. Arrows wrap only when `loop` is set; Home, End, PageUp and PageDown never wrap.
6
+ */
7
+ export const WHEEL_MOVE_KEYS = ["ArrowUp", "ArrowDown", "Home", "End", "PageUp", "PageDown"];
8
+ /** Indexes of the options that can be selected. */
9
+ function enabledIndexes(options) {
10
+ const out = [];
11
+ options.forEach((o, i) => {
12
+ if (!o.disabled)
13
+ out.push(i);
14
+ });
15
+ return out;
16
+ }
17
+ /**
18
+ * The index to select after `key`, or -1 when no option can be selected. `current` is the selected index, or -1 for none.
19
+ * With nothing selected, ArrowDown / PageDown / Home choose the first enabled option and ArrowUp / PageUp / End the last.
20
+ */
21
+ export function nextWheelIndex(options, current, key, move = {}) {
22
+ const enabled = enabledIndexes(options);
23
+ if (enabled.length === 0)
24
+ return -1;
25
+ const first = enabled[0];
26
+ const last = enabled[enabled.length - 1];
27
+ const position = enabled.indexOf(current);
28
+ const pageSize = Math.max(1, Math.floor(move.pageSize ?? 5));
29
+ if (key === "Home")
30
+ return first;
31
+ if (key === "End")
32
+ return last;
33
+ if (position === -1)
34
+ return key === "ArrowDown" || key === "PageDown" ? first : last;
35
+ switch (key) {
36
+ case "ArrowDown":
37
+ return position + 1 < enabled.length ? enabled[position + 1] : move.loop ? first : current;
38
+ case "ArrowUp":
39
+ return position > 0 ? enabled[position - 1] : move.loop ? last : current;
40
+ case "PageDown":
41
+ return enabled[Math.min(enabled.length - 1, position + pageSize)];
42
+ case "PageUp":
43
+ return enabled[Math.max(0, position - pageSize)];
44
+ }
45
+ }
46
+ /** Lower-case and strip diacritics so "é" is typed with "e". */
47
+ function fold(text) {
48
+ return text.normalize("NFD").replace(/\p{M}/gu, "").toLocaleLowerCase();
49
+ }
50
+ export const WHEEL_TYPEAHEAD_RESET_MS = 700;
51
+ /** The prefix after `key` at time `now`: it grows while presses are within the reset time, else starts over. */
52
+ export function nextWheelPrefix(previous, key, now, resetMs = WHEEL_TYPEAHEAD_RESET_MS) {
53
+ const continuing = previous.prefix !== "" && now - previous.at <= resetMs;
54
+ return { prefix: (continuing ? previous.prefix : "") + key, at: now };
55
+ }
56
+ /** Whether a key press is a type-ahead character: one printable character other than space, no Ctrl, Alt or Meta. */
57
+ export function isWheelTypeaheadKey(event) {
58
+ return event.key.length === 1 && event.key !== " " && !event.ctrlKey && !event.altKey && !event.metaKey;
59
+ }
60
+ /**
61
+ * The enabled option to move to for the typed `prefix`, or -1. Searches from the option after `current` and wraps; a prefix of one
62
+ * repeated letter ("ss") counts as that single letter, so pressing it again steps through the options that start with it.
63
+ */
64
+ export function wheelTypeaheadIndex(options, current, prefix) {
65
+ const folded = fold(prefix);
66
+ if (folded === "" || options.length === 0)
67
+ return -1;
68
+ const repeated = folded.length > 1 && [...folded].every((c) => c === folded[0]);
69
+ const needle = repeated ? folded[0] : folded;
70
+ const startOffset = needle.length > 1 ? 0 : 1;
71
+ const from = current < 0 ? -startOffset : current;
72
+ for (let step = 0; step < options.length; step++) {
73
+ const index = (from + startOffset + step + options.length) % options.length;
74
+ const option = options[index];
75
+ if (!option.disabled && fold(option.label).startsWith(needle))
76
+ return index;
77
+ }
78
+ return -1;
79
+ }
package/dist/index.d.ts CHANGED
@@ -241,6 +241,7 @@ export { default as HangukJuminDeungnokBeonhoView } from "./components/HangukJum
241
241
  export { default as Header } from "./components/Header/Header.svelte";
242
242
  export { default as Headline } from "./components/Headline/Headline.svelte";
243
243
  export { default as HeatmapChart } from "./components/HeatmapChart/HeatmapChart.svelte";
244
+ export { default as HeatmapChartWithYearOfDays } from "./components/HeatmapChartWithYearOfDays/HeatmapChartWithYearOfDays.svelte";
244
245
  export { default as Hero } from "./components/Hero/Hero.svelte";
245
246
  export { default as HeroHeadline } from "./components/HeroHeadline/HeroHeadline.svelte";
246
247
  export { default as HiddenInput } from "./components/HiddenInput/HiddenInput.svelte";
@@ -323,6 +324,7 @@ export { default as MenuItem } from "./components/MenuItem/MenuItem.svelte";
323
324
  export { default as Meter } from "./components/Meter/Meter.svelte";
324
325
  export { default as MexicoClaveUnicaDeRegistroDePoblacionInput } from "./components/MexicoClaveUnicaDeRegistroDePoblacionInput/MexicoClaveUnicaDeRegistroDePoblacionInput.svelte";
325
326
  export { default as MexicoClaveUnicaDeRegistroDePoblacionView } from "./components/MexicoClaveUnicaDeRegistroDePoblacionView/MexicoClaveUnicaDeRegistroDePoblacionView.svelte";
327
+ export { default as MiddleTruncation } from "./components/MiddleTruncation/MiddleTruncation.svelte";
326
328
  export { default as MisrAlRaqmAlQawmiInput } from "./components/MisrAlRaqmAlQawmiInput/MisrAlRaqmAlQawmiInput.svelte";
327
329
  export { default as MisrAlRaqmAlQawmiView } from "./components/MisrAlRaqmAlQawmiView/MisrAlRaqmAlQawmiView.svelte";
328
330
  export { default as MockupBrowser } from "./components/MockupBrowser/MockupBrowser.svelte";
@@ -565,6 +567,7 @@ export { default as Visible } from "./components/Visible/Visible.svelte";
565
567
  export { default as WarningCallout } from "./components/WarningCallout/WarningCallout.svelte";
566
568
  export { default as Watermark } from "./components/Watermark/Watermark.svelte";
567
569
  export { default as WeekInput } from "./components/WeekInput/WeekInput.svelte";
570
+ export { default as WheelPicker } from "./components/WheelPicker/WheelPicker.svelte";
568
571
  export { default as YisraelTeudatZehutInput } from "./components/YisraelTeudatZehutInput/YisraelTeudatZehutInput.svelte";
569
572
  export { default as YisraelTeudatZehutView } from "./components/YisraelTeudatZehutView/YisraelTeudatZehutView.svelte";
570
573
  export { default as ZhongguoJuminShenfenzhengHaomaInput } from "./components/ZhongguoJuminShenfenzhengHaomaInput/ZhongguoJuminShenfenzhengHaomaInput.svelte";