@marianmeres/stuic 3.172.0 → 3.174.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.
Files changed (61) hide show
  1. package/AGENTS.md +19 -4
  2. package/API.md +56 -2
  3. package/README.md +21 -5
  4. package/dist/actions/resizable-width.svelte.d.ts +14 -28
  5. package/dist/actions/resizable-width.svelte.js +16 -171
  6. package/dist/attachments/index.d.ts +1 -0
  7. package/dist/attachments/index.js +1 -0
  8. package/dist/attachments/resizable.d.ts +113 -0
  9. package/dist/attachments/resizable.fixture.svelte +53 -0
  10. package/dist/attachments/resizable.fixture.svelte.d.ts +10 -0
  11. package/dist/attachments/resizable.js +295 -0
  12. package/dist/components/Button/README.md +11 -10
  13. package/dist/components/ButtonGroupRadio/README.md +52 -23
  14. package/dist/components/ButtonGroupRadio/index.css +6 -2
  15. package/dist/components/PricingTable/PricingTable.svelte +8 -14
  16. package/dist/components/PricingTable/README.md +29 -6
  17. package/dist/components/PricingTable/index.css +54 -0
  18. package/dist/components/RangeSlider/README.md +291 -0
  19. package/dist/components/RangeSlider/RangeSlider.svelte +763 -0
  20. package/dist/components/RangeSlider/RangeSlider.svelte.d.ts +130 -0
  21. package/dist/components/RangeSlider/i18n-sk.d.ts +17 -0
  22. package/dist/components/RangeSlider/i18n-sk.js +19 -0
  23. package/dist/components/RangeSlider/i18n.d.ts +33 -0
  24. package/dist/components/RangeSlider/i18n.js +41 -0
  25. package/dist/components/RangeSlider/index.css +430 -0
  26. package/dist/components/RangeSlider/index.d.ts +3 -0
  27. package/dist/components/RangeSlider/index.js +3 -0
  28. package/dist/components/Rating/README.md +206 -0
  29. package/dist/components/Rating/Rating.svelte +355 -0
  30. package/dist/components/Rating/Rating.svelte.d.ts +82 -0
  31. package/dist/components/Rating/i18n-sk.d.ts +17 -0
  32. package/dist/components/Rating/i18n-sk.js +21 -0
  33. package/dist/components/Rating/i18n.d.ts +34 -0
  34. package/dist/components/Rating/i18n.js +42 -0
  35. package/dist/components/Rating/index.css +170 -0
  36. package/dist/components/Rating/index.d.ts +3 -0
  37. package/dist/components/Rating/index.js +3 -0
  38. package/dist/components/SplitPane/README.md +169 -0
  39. package/dist/components/SplitPane/SplitPane.svelte +202 -0
  40. package/dist/components/SplitPane/SplitPane.svelte.d.ts +67 -0
  41. package/dist/components/SplitPane/i18n-sk.d.ts +17 -0
  42. package/dist/components/SplitPane/i18n-sk.js +18 -0
  43. package/dist/components/SplitPane/i18n.d.ts +31 -0
  44. package/dist/components/SplitPane/i18n.js +39 -0
  45. package/dist/components/SplitPane/index.css +153 -0
  46. package/dist/components/SplitPane/index.d.ts +3 -0
  47. package/dist/components/SplitPane/index.js +3 -0
  48. package/dist/components/WithSidePanel/README.md +19 -16
  49. package/dist/icons/index.d.ts +2 -0
  50. package/dist/icons/index.js +3 -0
  51. package/dist/index.css +17 -0
  52. package/dist/index.d.ts +3 -0
  53. package/dist/index.js +3 -0
  54. package/docs/{maybe-todo.md → _archive/maybe-todo.md} +18 -7
  55. package/docs/architecture.md +1 -1
  56. package/docs/conventions.md +27 -7
  57. package/docs/domains/actions.md +18 -18
  58. package/docs/domains/attachments.md +72 -9
  59. package/docs/domains/components.md +175 -32
  60. package/docs/domains/theming.md +57 -13
  61. package/package.json +5 -5
@@ -0,0 +1,34 @@
1
+ import type { TranslateFn } from "../../types.js";
2
+ /**
3
+ * The built-in (English) message catalog of `Rating`. Also the fallback of every
4
+ * other bundled locale, so a locale missing a key still renders text.
5
+ *
6
+ * Placeholders are single-brace (`{value}`, `{max}`).
7
+ */
8
+ export declare const RATING_MESSAGES_EN: {
9
+ rating: string;
10
+ value_of_max: string;
11
+ required: string;
12
+ };
13
+ /** Every message key `Rating` may look up. */
14
+ export type RatingMessageKey = keyof typeof RATING_MESSAGES_EN;
15
+ /** A (possibly partial) catalog for one locale. */
16
+ export type RatingMessages = Record<RatingMessageKey, string>;
17
+ /**
18
+ * Builds the `t` prop of `Rating` from a message catalog. Unknown or untranslated
19
+ * keys fall back to `fallbackMessages` (English by default), so a catalog may safely be
20
+ * partial and never renders a raw key.
21
+ *
22
+ * @example
23
+ * ```svelte
24
+ * <script>
25
+ * import { Rating, createRatingT, RATING_MESSAGES_SK } from "@marianmeres/stuic";
26
+ * const t = createRatingT(RATING_MESSAGES_SK);
27
+ * </script>
28
+ *
29
+ * <Rating bind:value {t} />
30
+ * ```
31
+ */
32
+ export declare function createRatingT(messages: Partial<RatingMessages> | Record<string, string>, fallbackMessages?: Partial<RatingMessages> | Record<string, string>): TranslateFn;
33
+ /** The component's built-in English `t`. */
34
+ export declare const t_default: TranslateFn;
@@ -0,0 +1,42 @@
1
+ import { isPlainObject } from "../../utils/is-plain-object.js";
2
+ import { replaceMap } from "../../utils/replace-map.js";
3
+ /**
4
+ * The built-in (English) message catalog of `Rating`. Also the fallback of every
5
+ * other bundled locale, so a locale missing a key still renders text.
6
+ *
7
+ * Placeholders are single-brace (`{value}`, `{max}`).
8
+ */
9
+ export const RATING_MESSAGES_EN = {
10
+ rating: "Rating",
11
+ value_of_max: "{value} of {max} stars",
12
+ required: "Please select a rating",
13
+ };
14
+ /**
15
+ * Builds the `t` prop of `Rating` from a message catalog. Unknown or untranslated
16
+ * keys fall back to `fallbackMessages` (English by default), so a catalog may safely be
17
+ * partial and never renders a raw key.
18
+ *
19
+ * @example
20
+ * ```svelte
21
+ * <script>
22
+ * import { Rating, createRatingT, RATING_MESSAGES_SK } from "@marianmeres/stuic";
23
+ * const t = createRatingT(RATING_MESSAGES_SK);
24
+ * </script>
25
+ *
26
+ * <Rating bind:value {t} />
27
+ * ```
28
+ */
29
+ export function createRatingT(messages, fallbackMessages = RATING_MESSAGES_EN) {
30
+ return (k, values = null, fallback = "") => {
31
+ const out = messages[k] ??
32
+ fallbackMessages[k] ??
33
+ (typeof fallback === "string" ? fallback : k);
34
+ return isPlainObject(values)
35
+ ? replaceMap(out, values, {
36
+ preSearchKeyTransform: (k) => `{${k}}`,
37
+ })
38
+ : out;
39
+ };
40
+ }
41
+ /** The component's built-in English `t`. */
42
+ export const t_default = createRatingT(RATING_MESSAGES_EN);
@@ -0,0 +1,170 @@
1
+ /* ============================================================================
2
+ RATING COMPONENT TOKENS
3
+ Override globally: :root { --stuic-rating-icon-color: gold; }
4
+ Override locally: <Rating style="--stuic-rating-size: 2.5rem;">
5
+ ============================================================================ */
6
+
7
+ /* prettier-ignore */
8
+ :root {
9
+ /* Layout — `--stuic-rating-size` is intentionally unset: the `size` presets
10
+ below provide the defaults, and setting it overrides whichever preset is on. */
11
+ --stuic-rating-gap: 0.125rem;
12
+
13
+ /* Symbol colors — the filled color is a fixed "star amber" on purpose (like
14
+ every comparable library): it must read as a rating on any theme, including
15
+ the monochrome ones where the warning intent is grey. Use `intent` (or
16
+ override this token) to color by theme instead. The empty layer is a faint
17
+ tint of the surrounding text color, so it adapts to light/dark. */
18
+ --stuic-rating-icon-color: #f59e0b;
19
+ --stuic-rating-icon-color-empty: color-mix(in srgb, currentColor 20%, transparent);
20
+
21
+ /* Hover preview */
22
+ --stuic-rating-icon-scale-hover: 1.15;
23
+
24
+ /* Focus ring */
25
+ --stuic-rating-ring-width: 3px;
26
+ --stuic-rating-ring-color: var(--stuic-color-ring);
27
+
28
+ /* Disabled */
29
+ --stuic-rating-opacity-disabled: 0.5;
30
+ }
31
+
32
+ @layer components {
33
+ /* ============================================================================
34
+ BASE
35
+ ============================================================================ */
36
+
37
+ .stuic-rating {
38
+ --_size: var(--stuic-rating-size, 1.5rem);
39
+ --_color: var(--stuic-rating-icon-color);
40
+ display: inline-flex;
41
+ align-items: center;
42
+ gap: var(--stuic-rating-gap);
43
+ vertical-align: middle;
44
+ /* keep the scaled hover symbol from being clipped by an ancestor */
45
+ line-height: 1;
46
+ }
47
+
48
+ .stuic-rating[data-size="sm"] {
49
+ --_size: var(--stuic-rating-size, 1.125rem);
50
+ }
51
+ /* md is the base default (1.5rem) */
52
+ .stuic-rating[data-size="lg"] {
53
+ --_size: var(--stuic-rating-size, 2rem);
54
+ }
55
+
56
+ .stuic-rating[data-disabled] {
57
+ opacity: var(--stuic-rating-opacity-disabled);
58
+ }
59
+
60
+ /* ============================================================================
61
+ INTENT (colors the filled layer)
62
+ ============================================================================ */
63
+
64
+ .stuic-rating[data-intent="primary"] {
65
+ --_color: var(--stuic-color-primary);
66
+ }
67
+ .stuic-rating[data-intent="accent"] {
68
+ --_color: var(--stuic-color-accent);
69
+ }
70
+ .stuic-rating[data-intent="success"] {
71
+ --_color: var(--stuic-color-success);
72
+ }
73
+ .stuic-rating[data-intent="warning"] {
74
+ --_color: var(--stuic-color-warning);
75
+ }
76
+ .stuic-rating[data-intent="destructive"] {
77
+ --_color: var(--stuic-color-destructive);
78
+ }
79
+
80
+ /* ============================================================================
81
+ ITEM (one symbol: icon stack + hit zones)
82
+ ============================================================================ */
83
+
84
+ .stuic-rating-item {
85
+ position: relative;
86
+ display: inline-block;
87
+ flex-shrink: 0;
88
+ width: var(--_size);
89
+ height: var(--_size);
90
+ transition: transform var(--stuic-rating-transition, var(--stuic-transition));
91
+ }
92
+
93
+ .stuic-rating-item[data-hover] {
94
+ transform: scale(var(--stuic-rating-icon-scale-hover));
95
+ }
96
+
97
+ /* ============================================================================
98
+ ICON STACK — the empty symbol underneath, the filled one clipped on top.
99
+ `--stuic-rating-fill` (0–100%) is set inline per item by the component.
100
+ ============================================================================ */
101
+
102
+ .stuic-rating-icon {
103
+ position: absolute;
104
+ inset: 0;
105
+ display: block;
106
+ color: var(--stuic-rating-icon-color-empty);
107
+ }
108
+
109
+ .stuic-rating-icon svg {
110
+ display: block;
111
+ width: var(--_size);
112
+ height: var(--_size);
113
+ }
114
+
115
+ .stuic-rating-icon-empty,
116
+ .stuic-rating-icon-fill {
117
+ position: absolute;
118
+ inset-block: 0;
119
+ inset-inline-start: 0;
120
+ width: 100%;
121
+ }
122
+
123
+ /* The clip: a block child sits at the inline-start edge and overflows toward
124
+ inline-end, so the visible part is the start side in LTR and RTL alike. */
125
+ .stuic-rating-icon-fill {
126
+ width: var(--stuic-rating-fill, 0%);
127
+ overflow: hidden;
128
+ color: var(--_color);
129
+ transition: width var(--stuic-rating-transition, var(--stuic-transition));
130
+ }
131
+
132
+ /* ============================================================================
133
+ ZONE (the transparent radio button over a symbol, or over half of it)
134
+ ============================================================================ */
135
+
136
+ .stuic-rating-zone {
137
+ position: absolute;
138
+ inset: 0;
139
+ margin: 0;
140
+ padding: 0;
141
+ border: 0;
142
+ background: none;
143
+ appearance: none;
144
+ cursor: pointer;
145
+ -webkit-tap-highlight-color: transparent;
146
+ }
147
+
148
+ .stuic-rating-zone[data-half="start"] {
149
+ inset-inline-end: 50%;
150
+ }
151
+
152
+ .stuic-rating-zone[data-half="end"] {
153
+ inset-inline-start: 50%;
154
+ }
155
+
156
+ .stuic-rating-zone:disabled {
157
+ cursor: default;
158
+ }
159
+
160
+ .stuic-rating-zone:focus-visible {
161
+ outline: none;
162
+ }
163
+
164
+ /* the ring goes around the whole symbol, not the (half) zone */
165
+ .stuic-rating-item:has(.stuic-rating-zone:focus-visible) {
166
+ outline: var(--stuic-rating-ring-width) solid var(--stuic-rating-ring-color);
167
+ outline-offset: 2px;
168
+ border-radius: var(--stuic-rating-radius, var(--stuic-radius));
169
+ }
170
+ }
@@ -0,0 +1,3 @@
1
+ export { default as Rating, type Props as RatingProps, type RatingSize, type RatingItemState, } from "./Rating.svelte";
2
+ export { createRatingT, RATING_MESSAGES_EN, type RatingMessageKey, type RatingMessages, } from "./i18n.js";
3
+ export { RATING_MESSAGES_SK } from "./i18n-sk.js";
@@ -0,0 +1,3 @@
1
+ export { default as Rating, } from "./Rating.svelte";
2
+ export { createRatingT, RATING_MESSAGES_EN, } from "./i18n.js";
3
+ export { RATING_MESSAGES_SK } from "./i18n-sk.js";
@@ -0,0 +1,169 @@
1
+ # SplitPane
2
+
3
+ Two panes with one draggable separator between them. `horizontal` (default) puts the panes
4
+ side by side and resizes the primary pane's **width**, `vertical` stacks them and resizes its
5
+ **height**; the other pane flexes into whatever is left. The classic sidebar / editor /
6
+ inspector layout, nestable.
7
+
8
+ Built on the [`resizable` attachment](../../attachments/resizable.ts) with the separator as
9
+ its `handle`, so the separator is a proper ARIA window splitter: focusable `role="separator"`
10
+ with `aria-orientation`, `aria-valuenow` / `min` / `max` (in `units`) and `aria-controls`
11
+ pointing at the primary pane. Arrow keys along the axis move it by `step` (Shift ×10),
12
+ Home / End go to the bounds, Enter (or double-click) resets. Pointer Events with capture
13
+ cover mouse, touch and pen in one code path.
14
+
15
+ ## Usage
16
+
17
+ ```svelte
18
+ <script>
19
+ import { SplitPane } from "@marianmeres/stuic";
20
+ let size = $state(30);
21
+ </script>
22
+
23
+ <div class="h-screen">
24
+ <SplitPane bind:size min={15} max={60} key="sidebar" storage="local">
25
+ {#snippet start()}
26
+ <nav>…</nav>
27
+ {/snippet}
28
+ {#snippet end()}
29
+ <main>…</main>
30
+ {/snippet}
31
+ </SplitPane>
32
+ </div>
33
+ ```
34
+
35
+ Vertical, px, nested:
36
+
37
+ ```svelte
38
+ <SplitPane size={25} min={10}>
39
+ {#snippet start()}<Sidebar />{/snippet}
40
+ {#snippet end()}
41
+ <SplitPane orientation="vertical" units="px" size={400} min={120}>
42
+ {#snippet start()}<Editor />{/snippet}
43
+ {#snippet end()}<Terminal />{/snippet}
44
+ </SplitPane>
45
+ {/snippet}
46
+ </SplitPane>
47
+ ```
48
+
49
+ The root is `width: 100%; height: 100%`, so give the container a definite height: that is
50
+ what makes long pane content scroll instead of growing the layout, and what a `vertical`
51
+ split's `%` sizes resolve against. Without one, a `horizontal` split is as tall as its
52
+ content.
53
+
54
+ ## Props
55
+
56
+ | Prop | Type | Default | Description |
57
+ | ---------------- | ------------------------------- | ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
58
+ | `start` | `Snippet` | — | Content of the first pane (left; top when vertical) |
59
+ | `end` | `Snippet` | — | Content of the second pane (right; bottom when vertical) |
60
+ | `orientation` | `"horizontal" \| "vertical"` | `"horizontal"` | Side by side (resizes a width) or stacked (resizes a height) |
61
+ | `primary` | `"start" \| "end"` | `"start"` | Which pane carries the size; the other one fills the rest. With `end` the separator sits on the second pane's start edge and the drag / keys are inverted accordingly |
62
+ | `size` | `number` | `50` | **Bindable.** Size of the primary pane in `units`. Written on every resize; writing it resizes (clamped to `min` / `max`, persisted, announced). A size stored under `key` wins over it on mount and is written back |
63
+ | `units` | `"%" \| "px"` | `"%"` | Percent of the container, or px. Not meant to change after mount |
64
+ | `min` / `max` | `number` | `0` / `0` | Bounds in `units`; `0` = none (`%` is still capped at 100) |
65
+ | `step` | `number` | `1` (%) `10` (px) | Keyboard step in `units`; Shift multiplies by 10 |
66
+ | `key` | `string \| number \| null` | — | Persist the size under this key (`resizable-width-<key>` / `resizable-height-<key>`) |
67
+ | `storage` | `"local" \| "session"` | `"session"` | Where `key` persists to |
68
+ | `disabled` | `boolean` | `false` | Renders the separator inert: no drag, no keyboard, not focusable, no grip. The layout stays; writing `size` still works |
69
+ | `label` | `string` | `t("resize")` | Accessible name of the separator ("Resize") |
70
+ | `t` | `TranslateFn` | English | Translation function for the default name — see i18n below |
71
+ | `onResize` | `(info: ResizableInfo) => void` | — | Fires whenever a size is applied: on mount, while dragging, per key press, on reset, on `size` writes. `info` = `{ size, units, axis, container }` |
72
+ | `unstyled` | `boolean` | `false` | Skip all default styling (the size still lands as an inline width / height) |
73
+ | `class` | `string` | — | Root classes |
74
+ | `startClass` | `string` | — | First pane classes |
75
+ | `endClass` | `string` | — | Second pane classes |
76
+ | `separatorClass` | `string` | — | Separator classes |
77
+ | `el` | `HTMLDivElement` | — | Bindable root element |
78
+ | `separatorEl` | `HTMLDivElement` | — | Bindable separator element |
79
+
80
+ Other attributes (`style`, `data-*`, …) are spread onto the root.
81
+
82
+ ### Methods (via `bind:this`)
83
+
84
+ | Method | Description |
85
+ | --------- | -------------------------------------------------------------------------------------------- |
86
+ | `reset()` | Restores the size the component mounted with (what Enter / double-click on the separator do) |
87
+
88
+ ### `size`, `key` and reset — who wins
89
+
90
+ - **Mount:** a size stored under `key` (from an earlier drag) beats the `size` prop and is
91
+ written back into `bind:size`. Without `key`, `size` is it.
92
+ - **Later writes** to `size` go through the splitter: clamped, persisted, announced — an
93
+ out-of-range write is corrected in the binding.
94
+ - **Reset** (Enter, double-click, `reset()`) restores the size the component **mounted
95
+ with** — the `size` prop's value at that time, not the stored one.
96
+
97
+ ## Panes
98
+
99
+ Both panes are `min-width: 0; min-height: 0; overflow: auto`, so long content scrolls inside a
100
+ pane instead of blowing the layout, and a pane can shrink below its content's natural size.
101
+ The primary pane is `flex: 0 0 auto` with the size as an inline `width` / `height`; the other
102
+ one is `flex: 1 1 0`. Override per pane via `startClass` / `endClass`.
103
+
104
+ ## Data attributes
105
+
106
+ | Where | Attribute | Meaning |
107
+ | --------- | ------------------ | ------------------------------------------- |
108
+ | root | `data-orientation` | `horizontal` / `vertical` |
109
+ | root | `data-disabled` | Present when `disabled` |
110
+ | panes | `data-pane` | `start` / `end` |
111
+ | panes | `data-primary` | Present on the sized pane |
112
+ | separator | `data-separator` | Always |
113
+ | separator | `data-resizing` | Present while a pointer drag is in progress |
114
+
115
+ ## CSS Tokens
116
+
117
+ Prefix: `--stuic-split-pane-*`. The defaults reproduce the `resizable` attachment's built-in
118
+ handle (what `resizableWidth` / `WithSidePanel` show): a faint 1px line with a small rounded
119
+ grip, palette-based rather than theme-based, with `:root.dark` variants.
120
+
121
+ | Token | Default (light / dark) | Description |
122
+ | ----------------------- | --------------------------------------------- | -------------------------------------------------- |
123
+ | `separator-color` | `rgb(0 0 0 / 0.2)` / `rgb(255 255 255 / 0.1)` | The separator line |
124
+ | `separator-color-hover` | `rgb(0 0 0 / 0.3)` / `rgb(255 255 255 / 0.2)` | … while hovered, focused or dragged |
125
+ | `separator-thickness` | `1px` | Line thickness (the flex basis of the separator) |
126
+ | `separator-hit-area` | `4px` | Invisible grab zone added on each side of the line |
127
+ | `grip-color` | `--color-gray-300` / `--color-gray-600` | The centered grip (hidden when `disabled`) |
128
+ | `grip-color-hover` | `--color-gray-400` / `--color-gray-500` | … while hovered, focused or dragged |
129
+ | `grip-border-color` | `rgb(0 0 0 / 0.2)` / `rgb(255 255 255 / 0.2)` | Grip border |
130
+ | `grip-length` | `20px` | Grip size along the separator |
131
+ | `grip-thickness` | `9px` | Grip size across the separator |
132
+ | `grip-radius` | `0.25rem` | Grip corner radius |
133
+ | `ring-color` | `--stuic-color-ring` | `:focus-visible` outline of the separator |
134
+ | `transition` | `--stuic-transition` | Color transition duration |
135
+
136
+ ```svelte
137
+ <SplitPane
138
+ style="--stuic-split-pane-separator-thickness: 6px; --stuic-split-pane-grip-length: 3rem;"
139
+ >…</SplitPane
140
+ >
141
+ ```
142
+
143
+ ## i18n
144
+
145
+ The only text is the separator's default accessible name. English is built in; Slovak is
146
+ bundled, opt-in:
147
+
148
+ ```svelte
149
+ <script>
150
+ import {
151
+ SplitPane,
152
+ createSplitPaneT,
153
+ SPLIT_PANE_MESSAGES_SK,
154
+ } from "@marianmeres/stuic";
155
+ const t = createSplitPaneT(SPLIT_PANE_MESSAGES_SK);
156
+ </script>
157
+
158
+ <SplitPane {t}>…</SplitPane>
159
+ ```
160
+
161
+ `createSplitPaneT(messages, fallback = SPLIT_PANE_MESSAGES_EN)` accepts a partial catalog
162
+ (missing keys fall back to English). Keys: `resize`. Or just pass `label`.
163
+
164
+ ## Related
165
+
166
+ - [`resizable`](../../attachments/resizable.ts) attachment — the same behavior on any element
167
+ (own handle or a created one, width or height), for layouts that aren't a two-pane split.
168
+ - [`WithSidePanel`](../WithSidePanel/) — a sidebar layout with responsive collapse and an
169
+ optional resizable side (built on the same attachment via `resizableWidth`).
@@ -0,0 +1,202 @@
1
+ <!--
2
+ SplitPane — two panes with one draggable separator between them. `horizontal` lays
3
+ them side by side and resizes the primary pane's width, `vertical` stacks them and
4
+ resizes its height; the other pane fills what is left.
5
+
6
+ Built on the `resizable` attachment, which owns the pointer drag, the keyboard
7
+ (arrows along the axis, Home / End, Enter to reset) and the ARIA window-splitter
8
+ semantics of the separator. This component adds the layout, the theming,
9
+ `bind:size` (a stored size wins over it on mount and is written back), `reset()`
10
+ and the `t`-able accessible name.
11
+ -->
12
+ <script lang="ts" module>
13
+ import type { Snippet } from "svelte";
14
+ import type { HTMLAttributes } from "svelte/elements";
15
+ import type { ResizableInfo, ResizableUnits } from "../../attachments/resizable.js";
16
+ import type { TranslateFn } from "../../types.js";
17
+
18
+ export type SplitPaneOrientation = "horizontal" | "vertical";
19
+
20
+ /** Which pane carries the size; the other one flexes into the rest. */
21
+ export type SplitPanePrimary = "start" | "end";
22
+
23
+ export interface Props extends Omit<HTMLAttributes<HTMLDivElement>, "children"> {
24
+ /** Content of the first pane (left; top when vertical) */
25
+ start?: Snippet;
26
+ /** Content of the second pane (right; bottom when vertical) */
27
+ end?: Snippet;
28
+ /**
29
+ * `horizontal` (default) puts the panes side by side and resizes a width,
30
+ * `vertical` stacks them and resizes a height — the container then needs a
31
+ * definite height (`%` units resolve against it).
32
+ */
33
+ orientation?: SplitPaneOrientation;
34
+ /** Which pane the size applies to (the other one fills the rest). Default `start`. */
35
+ primary?: SplitPanePrimary;
36
+ /**
37
+ * Size of the primary pane in `units` (bindable). Default `50`. Written on every
38
+ * resize; writing it resizes the pane (clamped to `min` / `max`). A size stored
39
+ * under `key` wins over it on mount and is written back into the binding. The
40
+ * size the component mounted with is what double-click / Enter on the separator
41
+ * and `reset()` restore.
42
+ */
43
+ size?: number;
44
+ /** `%` (default) of the container, or `px`. Not meant to change after mount. */
45
+ units?: ResizableUnits;
46
+ /** Lower bound in `units`. `0` = none. */
47
+ min?: number;
48
+ /** Upper bound in `units`. `0` = none (`%` is still capped at 100). */
49
+ max?: number;
50
+ /** Keyboard step in `units` (Shift multiplies by 10). Default `1` for %, `10` for px. */
51
+ step?: number;
52
+ /** Persist the size under this key (`resizable-width-<key>` / `resizable-height-<key>`) */
53
+ key?: string | number | null;
54
+ /** Where `key` persists to. Default `session`. */
55
+ storage?: "local" | "session";
56
+ /** Renders the separator inert: no drag, no keyboard, not focusable */
57
+ disabled?: boolean;
58
+ /** Accessible name of the separator. Defaults to `t("resize")` — "Resize". */
59
+ label?: string;
60
+ /** Skip all default styling, use only custom classes */
61
+ unstyled?: boolean;
62
+ class?: string;
63
+ /** Classes for the first pane */
64
+ startClass?: string;
65
+ /** Classes for the second pane */
66
+ endClass?: string;
67
+ /** Classes for the separator */
68
+ separatorClass?: string;
69
+ /** Bindable root element reference */
70
+ el?: HTMLDivElement;
71
+ /** Bindable separator element reference */
72
+ separatorEl?: HTMLDivElement;
73
+ /** Fires whenever a size is applied: on mount, while dragging, per key press, on reset, on `size` writes */
74
+ onResize?: (info: ResizableInfo) => void;
75
+ /** Translation function for the separator's default name (`resize`). English built in. */
76
+ t?: TranslateFn;
77
+ }
78
+ </script>
79
+
80
+ <script lang="ts">
81
+ import { untrack } from "svelte";
82
+ import { resizable, type ResizableApi } from "../../attachments/resizable.js";
83
+ import { twMerge } from "../../utils/tw-merge.js";
84
+ import { t_default } from "./i18n.js";
85
+
86
+ let {
87
+ start,
88
+ end,
89
+ orientation = "horizontal",
90
+ primary = "start",
91
+ size = $bindable(50),
92
+ units = "%",
93
+ min = 0,
94
+ max = 0,
95
+ step,
96
+ key,
97
+ storage = "session",
98
+ disabled = false,
99
+ label,
100
+ unstyled = false,
101
+ class: classProp,
102
+ startClass,
103
+ endClass,
104
+ separatorClass,
105
+ el = $bindable(),
106
+ separatorEl = $bindable(),
107
+ onResize,
108
+ t = t_default,
109
+ ...rest
110
+ }: Props = $props();
111
+
112
+ // The size the component mounted with (before a stored one applies): the reset target.
113
+ const _initial = untrack(() => size);
114
+
115
+ let _api = $state.raw<ResizableApi | undefined>();
116
+
117
+ let _isX = $derived(orientation === "horizontal");
118
+
119
+ // What the primary pane renders while the attachment is not active (`disabled`, or
120
+ // before its first run). While it is active both write the very same string.
121
+ let _sizeCss = $derived(`${size}${units}`);
122
+
123
+ function _attachment(pane: SplitPanePrimary) {
124
+ if (disabled || pane !== primary || !separatorEl) return undefined;
125
+ return resizable({
126
+ axis: _isX ? "x" : "y",
127
+ // the current size, not the mount one: a re-setup (an option changed,
128
+ // `disabled` toggled off) must not jump the pane
129
+ initial: untrack(() => size),
130
+ resetTo: _initial,
131
+ min,
132
+ max,
133
+ units,
134
+ step,
135
+ key,
136
+ storage,
137
+ reverse: primary === "end",
138
+ handle: separatorEl,
139
+ label: label ?? t("resize"),
140
+ onResize(info) {
141
+ size = info.size;
142
+ onResize?.(info);
143
+ },
144
+ onInit(api) {
145
+ _api = api;
146
+ },
147
+ });
148
+ }
149
+
150
+ // Writes into `size` (a consumer's) go through the attachment, so they get clamped,
151
+ // persisted and announced like a drag. Its own writes are already `current`.
152
+ $effect(() => {
153
+ const api = _api;
154
+ const s = size;
155
+ if (!api || s === api.current) return;
156
+ untrack(() => api.set(s));
157
+ });
158
+
159
+ /** Restores the size the component mounted with. */
160
+ export function reset() {
161
+ if (_api) _api.reset();
162
+ else size = _initial;
163
+ }
164
+ </script>
165
+
166
+ <div
167
+ bind:this={el}
168
+ class={unstyled ? classProp : twMerge("stuic-split-pane", classProp)}
169
+ data-orientation={orientation}
170
+ data-disabled={disabled ? "" : undefined}
171
+ {...rest}
172
+ >
173
+ <div
174
+ class={unstyled ? startClass : twMerge("stuic-split-pane-pane", startClass)}
175
+ data-pane="start"
176
+ data-primary={primary === "start" ? "" : undefined}
177
+ style:width={_isX && primary === "start" ? _sizeCss : undefined}
178
+ style:height={!_isX && primary === "start" ? _sizeCss : undefined}
179
+ {@attach _attachment("start")}
180
+ >
181
+ {@render start?.()}
182
+ </div>
183
+ <div
184
+ bind:this={separatorEl}
185
+ class={unstyled
186
+ ? separatorClass
187
+ : twMerge("stuic-split-pane-separator", separatorClass)}
188
+ data-separator
189
+ role="separator"
190
+ aria-orientation={_isX ? "vertical" : "horizontal"}
191
+ ></div>
192
+ <div
193
+ class={unstyled ? endClass : twMerge("stuic-split-pane-pane", endClass)}
194
+ data-pane="end"
195
+ data-primary={primary === "end" ? "" : undefined}
196
+ style:width={_isX && primary === "end" ? _sizeCss : undefined}
197
+ style:height={!_isX && primary === "end" ? _sizeCss : undefined}
198
+ {@attach _attachment("end")}
199
+ >
200
+ {@render end?.()}
201
+ </div>
202
+ </div>