@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
package/AGENTS.md CHANGED
@@ -23,7 +23,7 @@
23
23
 
24
24
  ```
25
25
  src/lib/
26
- ├── components/ # 75 component directories
26
+ ├── components/ # 78 component directories
27
27
  ├── actions/ # 16 Svelte actions (use: directives)
28
28
  ├── attachments/ # Svelte attachments ({@attach} — preferred for new DOM helpers)
29
29
  ├── utils/ # 55 utility modules (48 on the barrel)
@@ -101,12 +101,27 @@ Global tokens that control cross-component visual properties. Defined in `src/li
101
101
  }
102
102
  ```
103
103
 
104
- **Element vs Container classification:**
104
+ **Element vs Button vs Container classification:**
105
105
 
106
106
  - **Element** (`--stuic-radius`): inputs, badges, list items, checkboxes, tabs — interactive controls
107
- - **Button** (`--stuic-radius-button`): buttons, button groups — allows rounded buttons even with flat elements
107
+ - **Button** (`--stuic-radius-button`): buttons, split buttons, button groups — allows rounded buttons even with flat elements
108
108
  - **Container** (`--stuic-radius-container`): cards, modals, dropdowns, notifications, accordions — content wrappers
109
109
 
110
+ **Tier tokens vs component tokens — the mirrored names.** Tier tokens (the table above) are
111
+ `--stuic-{property}-{tier}` and ARE declared in `src/lib/index.css`. Component tokens are the
112
+ transposition, `--stuic-{component}-{property}`, and are NEVER declared — they exist only as
113
+ the first argument of a `var()` fallback:
114
+
115
+ ```css
116
+ border-radius: var(--stuic-button-radius, var(--stuic-radius-button));
117
+ /* ^ component token (3) ^ tier token (2) */
118
+ ```
119
+
120
+ So `--stuic-radius-button` applies to Button, SplitButton and ButtonGroupRadio, while
121
+ `--stuic-button-radius` applies to Button alone. Same for `--stuic-border-width-button` vs
122
+ `--stuic-button-border-width`. `button` is the only tier name that is also a component name,
123
+ so it is the only confusable pair — do not "fix" one into the other.
124
+
110
125
  ---
111
126
 
112
127
  ## Before Making Changes
@@ -131,7 +146,7 @@ Global tokens that control cross-component visual properties. Defined in `src/li
131
146
 
132
147
  ### Domain Docs
133
148
 
134
- - [Components](./docs/domains/components.md) — 75 component directories, Props pattern, snippets
149
+ - [Components](./docs/domains/components.md) — 78 component directories, Props pattern, snippets
135
150
  - [Theming](./docs/domains/theming.md) — CSS tokens, dark mode, themes
136
151
  - [CSS presets](./docs/domains/css-presets.md) — ratio-locked frame (letterbox), safe-area, scrollbar
137
152
  - [Actions](./docs/domains/actions.md) — 16 Svelte directives
package/API.md CHANGED
@@ -114,6 +114,25 @@ Expandable sections with exclusive or multi-open modes.
114
114
 
115
115
  Responsive two-column layout with collapsible side panel, resizable width, and mobile-aware slide transitions.
116
116
 
117
+ #### `SplitPane`
118
+
119
+ Two panes with a draggable, keyboard-operable separator (ARIA window splitter) — `horizontal` (side by side, a width) or `vertical` (stacked, a height); nestable. Built on the `resizable` attachment.
120
+
121
+ | Prop | Type | Default | Description |
122
+ | --------------- | ---------------------------- | -------------- | ------------------------------------------------------------------------- |
123
+ | `start` / `end` | `Snippet` | — | The two panes' content |
124
+ | `orientation` | `"horizontal" \| "vertical"` | `"horizontal"` | Resize a width (side by side) or a height (stacked) |
125
+ | `primary` | `"start" \| "end"` | `"start"` | Which pane carries the size; the other one fills the rest |
126
+ | `size` | `number` | `50` | Bindable size of the primary pane in `units`; a stored size wins on mount |
127
+ | `units` | `"%" \| "px"` | `"%"` | Of the container, or px |
128
+ | `min` / `max` | `number` | `0` | Bounds in `units` (`0` = none) |
129
+ | `key` | `string \| number` | — | Persist the size (`storage`: `"session"` default, or `"local"`) |
130
+ | `disabled` | `boolean` | `false` | Inert separator, layout kept |
131
+ | `label` / `t` | `string` / `TranslateFn` | `"Resize"` | Accessible name of the separator (Slovak catalog bundled) |
132
+ | `onResize` | `(info) => void` | — | Every applied size |
133
+
134
+ Imperative `reset()` restores the mount size. Class slots `startClass` / `endClass` / `separatorClass`.
135
+
117
136
  #### `Collapsible`
118
137
 
119
138
  Expandable/collapsible content sections.
@@ -1606,10 +1625,12 @@ Visual feedback (class toggle) on drag-over.
1606
1625
 
1607
1626
  ### `resizableWidth`
1608
1627
 
1609
- Make an element's width draggable.
1628
+ Make an element's width draggable. A thin wrapper over the `resizable` attachment (`axis: "x"`, see [Attachments](#attachments)) — the handle is a keyboard-operable `role="separator"`, `min` / `max` clamp, `key` persists.
1610
1629
 
1611
1630
  ```svelte
1612
- <div use:resizableWidth={() => ({ minWidth: 200, maxWidth: 600 })}>Resizable</div>
1631
+ <div use:resizableWidth={() => ({ initial: 300, min: 200, max: 600, key: "sidebar" })}>
1632
+ Resizable
1633
+ </div>
1613
1634
  ```
1614
1635
 
1615
1636
  ### `trim`
@@ -1802,6 +1823,37 @@ When a step has `selector`, the tour uses `document.querySelector(selector)` to
1802
1823
 
1803
1824
  ---
1804
1825
 
1826
+ ## Attachments
1827
+
1828
+ Svelte `{@attach}` helpers — the preferred form for new DOM behavior (reactive, composable, forwardable through components). Imported from the package root like everything else.
1829
+
1830
+ ### `autoHeight`
1831
+
1832
+ Animates the host's height to its single child's natural height (pair it with a CSS `height` transition).
1833
+
1834
+ ```svelte
1835
+ <div class="viewport" {@attach autoHeight}>
1836
+ <div>…variable-height content…</div>
1837
+ </div>
1838
+ ```
1839
+
1840
+ ### `longPress`
1841
+
1842
+ Factory: calls `onLongPress` when a touch / pen pointer stays down for `duration` ms (default 500) without moving beyond `moveTolerance` px.
1843
+
1844
+ ```svelte
1845
+ <div {@attach longPress({ onLongPress: (e) => openAt(e.clientX, e.clientY) })}>…</div>
1846
+ ```
1847
+
1848
+ ### `resizable`
1849
+
1850
+ Factory: drag-resizable width (`axis: "x"`) or height (`axis: "y"`) with an ARIA window-splitter handle (arrows / Home / End / Enter), `min` / `max`, `px` or `%` of the parent, persisted under `key`; `handle` drives an element of your own, `onInit` hands out `set()` / `reset()`.
1851
+
1852
+ ```svelte
1853
+ <aside {@attach resizable({ initial: 300, min: 200, max: 600, key: "sidebar" })}>…</aside>
1854
+ <div {@attach resizable({ axis: "y", units: "%", initial: 40 })}>…</div>
1855
+ ```
1856
+
1805
1857
  ## Utilities
1806
1858
 
1807
1859
  Import from `@marianmeres/stuic`.
@@ -2327,6 +2379,8 @@ Each component defines customization tokens. Override globally in `:root {}` or
2327
2379
  | Button | `--stuic-button-*` | `bg`, `text`, `border`, `ring-color`, `radius`, `padding-x-{size}` |
2328
2380
  | Switch | `--stuic-switch-*` | `accent` |
2329
2381
  | Slider | `--stuic-slider-*` | `track`, `fill`, `thumb`, `tick`, `tick-on-fill`, `thickness`, `length`, `radius`, `fill-radius` |
2382
+ | RangeSlider | `--stuic-range-slider-*` | `track`, `fill`, `thumb`, `tick`, `tick-on-fill`, `thickness`, `length`, `radius`, `fill-radius`, `ring-width`, `ring-color` |
2383
+ | SplitPane | `--stuic-split-pane-*` | `separator-color`, `separator-color-hover`, `separator-thickness`, `separator-hit-area`, `grip-color`, `grip-color-hover`, `grip-border-color`, `grip-length`, `grip-thickness`, `grip-radius`, `ring-color`, `transition` |
2330
2384
  | Input | `--stuic-input-*` | `accent`, `accent-error` |
2331
2385
  | Progress | `--stuic-progress-*` | `bg`, `accent` |
2332
2386
  | ListItemButton | `--stuic-list-item-button-*` | `bg`, `text`, `border`, `bg-hover`, `text-hover` |
package/README.md CHANGED
@@ -57,7 +57,7 @@ STUIC uses a 4-layer CSS variable token system:
57
57
  ```
58
58
  Layer 1: Theme Tokens (--stuic-color-*)
59
59
 
60
- Layer 2: Structural Tokens (--stuic-radius, --stuic-shadow, --stuic-border-width, --stuic-transition)
60
+ Layer 2: Structural Tokens (--stuic-radius, --stuic-radius-button, --stuic-shadow, ...)
61
61
  ↓ (used as fallback defaults)
62
62
  Layer 3: Component Tokens (--stuic-button-radius, --stuic-input-accent, etc.)
63
63
  ↓ (Tailwind utility class references)
@@ -87,16 +87,20 @@ Override shared structural tokens to change the entire library's visual characte
87
87
  /* Brutalist — sharp, flat, borderless */
88
88
  :root {
89
89
  --stuic-radius: 0;
90
+ --stuic-radius-button: 0;
90
91
  --stuic-radius-container: 0;
91
92
  --stuic-shadow: none;
92
93
  --stuic-shadow-hover: none;
93
94
  --stuic-shadow-overlay: none;
94
95
  --stuic-shadow-dialog: none;
95
96
  --stuic-border-width: 0;
97
+ --stuic-border-width-button: 0;
96
98
  }
97
99
  ```
98
100
 
99
- Available tokens: `--stuic-radius`, `--stuic-radius-container`, `--stuic-shadow`, `--stuic-shadow-hover`, `--stuic-shadow-overlay`, `--stuic-shadow-dialog`, `--stuic-border-width`, `--stuic-transition`.
101
+ Available tokens: `--stuic-radius`, `--stuic-radius-button`, `--stuic-radius-container`, `--stuic-shadow`, `--stuic-shadow-hover`, `--stuic-shadow-overlay`, `--stuic-shadow-dialog`, `--stuic-border-width`, `--stuic-border-width-button`, `--stuic-transition`.
102
+
103
+ Radius and border-width come in three tiers — elements, **buttons**, containers — so you can flatten inputs while keeping pill buttons, or the reverse.
100
104
 
101
105
  ### Per-Component Customization
102
106
 
@@ -104,7 +108,7 @@ Override specific component tokens:
104
108
 
105
109
  ```css
106
110
  :root {
107
- --stuic-button-radius: 9999px; /* Pill buttons — overrides the shared --stuic-radius */
111
+ --stuic-button-radius: 9999px; /* Pill buttons — overrides the shared --stuic-radius-button */
108
112
  --stuic-switch-accent: #10b981; /* Green switches */
109
113
  }
110
114
  ```
@@ -167,7 +171,7 @@ See [API.md](API.md) for the full list of exported theme types (`ThemeSchema`, `
167
171
 
168
172
  ### Layout & Overlays
169
173
 
170
- AppShell, Accordion, Backdrop, Modal, ModalDialog, Drawer, Collapsible, Header, SlidingPanels, Nav, WithSidePanel
174
+ AppShell, Accordion, Backdrop, Modal, ModalDialog, Drawer, Collapsible, Header, SlidingPanels, Nav, WithSidePanel, SplitPane
171
175
 
172
176
  ### Forms & Inputs
173
177
 
@@ -175,7 +179,7 @@ FieldInput, FieldMoney, FieldDate, FieldDateRange, Calendar, FieldTextarea, Fiel
175
179
 
176
180
  ### Buttons & Controls
177
181
 
178
- Button, ButtonGroupRadio, Switch, Slider, TwCheck, ListItemButton, X
182
+ Button, ButtonGroupRadio, Switch, Slider, RangeSlider, TwCheck, ListItemButton, X
179
183
 
180
184
  ### Feedback & Notifications
181
185
 
@@ -206,6 +210,18 @@ Cart, Checkout (CheckoutProgress, CheckoutOrderSummary, CheckoutCartReview, Chec
206
210
 
207
211
  `autogrow` · `validate` · `focusTrap` · `autoscroll` · `dimBehind` · `fileDropzone` · `highlightDragover` · `resizableWidth` · `spotlight` · `trim` · `typeahead` · `onSubmitValidityCheck` · `popover` · `tooltip` · `createTour` / `tourStep` (onboarding)
208
212
 
213
+ ## Attachments
214
+
215
+ Svelte `{@attach}` helpers — preferred over new actions (reactive, composable, forwardable).
216
+
217
+ ```svelte
218
+ <div {@attach autoHeight}>…</div>
219
+ <aside {@attach resizable({ initial: 300, min: 200, max: 600, key: "sidebar" })}>…</aside>
220
+ <div {@attach longPress({ onLongPress: (e) => openAt(e.clientX, e.clientY) })}>…</div>
221
+ ```
222
+
223
+ `autoHeight` · `longPress` · `resizable`
224
+
209
225
  ## PWA safe-area insets
210
226
 
211
227
  When a stuic app is installed and launched standalone (iOS Home Screen, Android/desktop PWA), the web view fills the entire screen, so edge-anchored chrome can render under the status bar / notch / home indicator. stuic ships an **opt-in** safe-area layer that is **inert in a normal browser tab** (`env()` → `0`) and only engages under `@media (display-mode: standalone), (display-mode: fullscreen)`.
@@ -1,42 +1,28 @@
1
+ import { type ResizableOptions } from "../attachments/resizable.js";
1
2
  /**
2
- * Options for the resizable width action.
3
+ * Options for the {@link resizableWidth} action — the `x`-axis
4
+ * {@link ResizableOptions} of the `resizable` attachment with the legacy
5
+ * `onResize` payload shape (`width` instead of `size`).
3
6
  */
4
- export interface ResizableWidthOptions {
5
- enabled?: boolean;
6
- initial?: number;
7
- min?: number;
8
- max?: number;
9
- units?: "px" | "%";
10
- /** Reverses handle position (left instead of right) and drag direction */
11
- reverse?: boolean;
12
- key?: string | number | null | undefined;
13
- storage?: "local" | "session";
14
- handleClass?: string;
15
- handleDragClass?: string;
7
+ export interface ResizableWidthOptions extends Omit<ResizableOptions, "axis" | "onResize"> {
16
8
  onResize?: (info: {
17
9
  width: number;
18
10
  units: "px" | "%";
19
11
  container: number;
20
12
  }) => void;
21
- debug?: (...args: unknown[]) => void;
22
13
  }
23
14
  /**
24
- * A Svelte action that makes an element's width resizable via drag handle.
15
+ * A Svelte action that makes an element's width resizable via a drag handle on its
16
+ * right (or, with `reverse`, left) edge.
25
17
  *
26
- * Adds a draggable handle to the right (or left with `reverse`) edge of the element.
27
- * Supports mouse and touch input.
28
- * Optionally persists the width to localStorage/sessionStorage.
29
- *
30
- * Features:
31
- * - Drag handle with visual feedback
32
- * - Min/max width constraints
33
- * - Support for px or % units
34
- * - Optional storage persistence with custom key
35
- * - Double-click handle to reset to initial width
36
- * - Touch device support
18
+ * Kept for back-compat: it is a thin wrapper over the `resizable` attachment
19
+ * (`axis: "x"`), so it has everything the attachment has — the handle is a focusable
20
+ * `role="separator"` with arrow / Home / End / Enter keyboard resizing, Pointer Events
21
+ * cover mouse, touch and pen, `min` / `max` clamp, `key` persists, double-click resets.
22
+ * For new code (and for heights) use `{@attach resizable(...)}` directly.
37
23
  *
38
24
  * @param el - The element to make resizable
39
- * @param fn - Function returning configuration options
25
+ * @param fn - Function returning configuration options (re-runs the action on reactive change)
40
26
  *
41
27
  * @remarks
42
28
  * The `units` option should not be changed dynamically after initialization.
@@ -63,4 +49,4 @@ export interface ResizableWidthOptions {
63
49
  * </div>
64
50
  * ```
65
51
  */
66
- export declare function resizableWidth(el: HTMLDivElement, fn?: () => ResizableWidthOptions): void;
52
+ export declare function resizableWidth(el: HTMLElement, fn?: () => ResizableWidthOptions): void;
@@ -1,22 +1,16 @@
1
- import { localStorageState, sessionStorageState, } from "../utils/persistent-state.svelte.js";
2
- import { twMerge } from "../utils/tw-merge.js";
1
+ import { resizable } from "../attachments/resizable.js";
3
2
  /**
4
- * A Svelte action that makes an element's width resizable via drag handle.
3
+ * A Svelte action that makes an element's width resizable via a drag handle on its
4
+ * right (or, with `reverse`, left) edge.
5
5
  *
6
- * Adds a draggable handle to the right (or left with `reverse`) edge of the element.
7
- * Supports mouse and touch input.
8
- * Optionally persists the width to localStorage/sessionStorage.
9
- *
10
- * Features:
11
- * - Drag handle with visual feedback
12
- * - Min/max width constraints
13
- * - Support for px or % units
14
- * - Optional storage persistence with custom key
15
- * - Double-click handle to reset to initial width
16
- * - Touch device support
6
+ * Kept for back-compat: it is a thin wrapper over the `resizable` attachment
7
+ * (`axis: "x"`), so it has everything the attachment has — the handle is a focusable
8
+ * `role="separator"` with arrow / Home / End / Enter keyboard resizing, Pointer Events
9
+ * cover mouse, touch and pen, `min` / `max` clamp, `key` persists, double-click resets.
10
+ * For new code (and for heights) use `{@attach resizable(...)}` directly.
17
11
  *
18
12
  * @param el - The element to make resizable
19
- * @param fn - Function returning configuration options
13
+ * @param fn - Function returning configuration options (re-runs the action on reactive change)
20
14
  *
21
15
  * @remarks
22
16
  * The `units` option should not be changed dynamically after initialization.
@@ -44,162 +38,13 @@ import { twMerge } from "../utils/tw-merge.js";
44
38
  * ```
45
39
  */
46
40
  export function resizableWidth(el, fn) {
47
- const HANDLE_CLS_BASE = [
48
- "group",
49
- "absolute top-0 bottom-0",
50
- "w-[1px]",
51
- "bg-black/20 hover:bg-black/30",
52
- "dark:bg-white/10 dark:hover:bg-white/20",
53
- "transition-colors duration-200",
54
- "touch-none cursor-ew-resize",
55
- ].join(" ");
56
- const DEFAULT_DRAG_HANDLE_CLS = [
57
- "absolute h-[20px] w-[9px]",
58
- "-translate-x-[4px] top-1/2 -translate-y-1/2",
59
- "rounded border border-black/20 dark:border-white/20",
60
- "bg-gray-300 group-hover:bg-gray-400",
61
- "dark:bg-gray-600 dark:group-hover:bg-gray-500",
62
- "transition-colors duration-200",
63
- "touch-none cursor-ew-resize",
64
- ].join(" ");
65
- function create_handle(el, handleClass, handleDragClass, reverse) {
66
- const handle = document.createElement("div");
67
- handle.setAttribute("data-handle", "true");
68
- const dragHandle = document.createElement("div");
69
- dragHandle.classList.add(...twMerge(DEFAULT_DRAG_HANDLE_CLS, handleDragClass).split(" "));
70
- handle.appendChild(dragHandle);
71
- el.appendChild(handle);
72
- //
73
- const positionCls = reverse ? "left-0" : "right-0";
74
- handle.classList.add(...twMerge(HANDLE_CLS_BASE, positionCls, handleClass).split(" "));
75
- return handle;
76
- }
77
41
  $effect(() => {
78
- const { enabled = true, initial: initialValue = 0, min = 0, max = 0, units = "px", reverse = false, key, storage = "session", handleClass = "", handleDragClass = "", onResize, debug, } = fn?.() || {};
79
- let initial = initialValue;
80
- const _debug = (...args) => debug?.("[resizable-width]", ...args);
81
- _debug("$effect");
82
- if (!enabled)
83
- return;
84
- // initialize ////////////////////////////////////////////////////////////////////
85
- //
86
- let isResizing = false;
87
- let startX = 0;
88
- let startWidth = 0;
89
- let containerW = undefined;
90
- //
91
- const handle = create_handle(el, handleClass, handleDragClass, reverse);
92
- const container = el.parentElement;
93
- // do we have a storage? if so, adjust the initial value...
94
- const initialBackup = initial;
95
- const _storage = get_storage(storage, key, initial);
96
- if (_storage)
97
- initial = _storage.current ?? initial;
98
- // handlers/workers/helpers //////////////////////////////////////////////////////
99
- function set_width(pxOrPercent) {
100
- if (pxOrPercent) {
101
- _debug(`set_width(${pxOrPercent})`);
102
- set_width_px(units === "%" ? container.offsetWidth * (pxOrPercent / 100) : pxOrPercent);
103
- }
104
- }
105
- function set_width_px(widthPx) {
106
- _debug(`set_width_px(${widthPx})`);
107
- containerW ??= container.offsetWidth;
108
- const clamp = (value) => {
109
- const _initial = value;
110
- if (min)
111
- value = Math.max(min, value);
112
- if (max)
113
- value = Math.min(max, value);
114
- if (_initial !== value)
115
- _debug("clamped", value, units);
116
- return value;
117
- };
118
- let width;
119
- if (units === "%") {
120
- const widthPercent = Math.min(100, (widthPx / containerW) * 100); // convert to % (with 100 max)
121
- width = clamp(widthPercent);
122
- }
123
- else {
124
- width = clamp(widthPx);
125
- }
126
- el.style.width = `${width}${units}`;
127
- _debug("new width", width, units);
128
- const info = { width, units, container: containerW };
129
- onResize?.(info);
130
- // maybe save to storage
131
- if (_storage)
132
- _storage.current = width;
133
- return info;
134
- }
135
- function resize_start(e) {
136
- e.preventDefault(); // prevent scrolling on touch devices
137
- isResizing = true;
138
- //
139
- const clientX = "touches" in e ? e.touches[0].clientX : e.clientX;
140
- startX = clientX;
141
- startWidth = parseInt(getComputedStyle(el).width, 10);
142
- containerW = container.offsetWidth;
143
- //
144
- document.body.style.cursor = "ew-resize";
145
- document.body.style.userSelect = "none";
146
- }
147
- function resize(e) {
148
- if (!isResizing)
149
- return;
150
- e.preventDefault(); // prevent scrolling on touch devices
151
- //
152
- const clientX = "touches" in e ? e.touches[0].clientX : e.clientX;
153
- const deltaX = clientX - startX;
154
- const width = reverse ? startWidth - deltaX : startWidth + deltaX;
155
- set_width_px(width);
156
- }
157
- function resize_stop() {
158
- if (isResizing) {
159
- isResizing = false;
160
- containerW = undefined;
161
- //
162
- document.body.style.cursor = "";
163
- document.body.style.userSelect = "";
164
- }
165
- }
166
- function on_dblclick() {
167
- set_width(initialBackup);
168
- }
169
- // initial styles ////////////////////////////////////////////////////////////////
170
- el.style.position = "relative"; // so the handle will work
171
- set_width(initial);
172
- // listeners /////////////////////////////////////////////////////////////////////
173
- // handle
174
- handle.addEventListener("dblclick", on_dblclick);
175
- handle.addEventListener("selectstart", (e) => e.preventDefault()); // prevent text selection during resize
176
- // mouse
177
- handle.addEventListener("mousedown", resize_start);
178
- document.addEventListener("mousemove", resize);
179
- document.addEventListener("mouseup", resize_stop);
180
- // touch
181
- handle.addEventListener("touchstart", resize_start, { passive: false });
182
- document.addEventListener("touchmove", resize, { passive: false });
183
- document.addEventListener("touchend", resize_stop);
184
- document.addEventListener("touchcancel", resize_stop);
185
- // cleanup ///////////////////////////////////////////////////////////////////////
186
- return () => {
187
- // mouse
188
- document.removeEventListener("mousemove", resize);
189
- document.removeEventListener("mouseup", resize_stop);
190
- // touch
191
- document.removeEventListener("touchmove", resize);
192
- document.removeEventListener("touchend", resize_stop);
193
- document.removeEventListener("touchcancel", resize_stop);
194
- // will also remove it's own event listeners
195
- handle.remove();
196
- };
42
+ const { onResize, ...rest } = fn?.() || {};
43
+ return resizable({
44
+ ...rest,
45
+ axis: "x",
46
+ onResize: onResize &&
47
+ (({ size, units, container }) => onResize({ width: size, units, container })),
48
+ })(el);
197
49
  });
198
50
  }
199
- // helpers ///////////////////////////////////////////////////////////////////////////////
200
- function get_storage(type, key, initialValue) {
201
- if (key) {
202
- return (type === "session" ? sessionStorageState : localStorageState)(`resizable-width-${key}`, initialValue);
203
- }
204
- return null;
205
- }
@@ -1,2 +1,3 @@
1
1
  export * from "./auto-height.js";
2
2
  export * from "./long-press.js";
3
+ export * from "./resizable.js";
@@ -1,2 +1,3 @@
1
1
  export * from "./auto-height.js";
2
2
  export * from "./long-press.js";
3
+ export * from "./resizable.js";
@@ -0,0 +1,113 @@
1
+ import type { Attachment } from "svelte/attachments";
2
+ /** Which box dimension is resized: `x` = width, `y` = height. */
3
+ export type ResizableAxis = "x" | "y";
4
+ /** `px`, or `%` of the parent element's size along the axis. */
5
+ export type ResizableUnits = "px" | "%";
6
+ /** Payload of {@link ResizableOptions.onResize}. */
7
+ export interface ResizableInfo {
8
+ /** The applied (clamped) size, in `units` */
9
+ size: number;
10
+ units: ResizableUnits;
11
+ axis: ResizableAxis;
12
+ /** The parent element's size along the axis in px, as measured for this resize */
13
+ container: number;
14
+ }
15
+ /** Imperative handle, delivered through {@link ResizableOptions.onInit}. */
16
+ export interface ResizableApi {
17
+ /** The last applied size, in `units` */
18
+ readonly current: number;
19
+ /** Applies a size (in `units`): clamped to `min` / `max`, persisted, announced, reported via `onResize` */
20
+ set(size: number): void;
21
+ /** Restores `resetTo` (defaults to `initial`) — what double-click / Enter on the handle do */
22
+ reset(): void;
23
+ }
24
+ /**
25
+ * Options for the {@link resizable} attachment.
26
+ */
27
+ export interface ResizableOptions {
28
+ /** Master switch. When `false` nothing is set up: no handle, no size applied. Default `true`. */
29
+ enabled?: boolean;
30
+ /**
31
+ * `x` resizes the width (the handle sits on a vertical edge), `y` resizes the height
32
+ * (horizontal edge). Default `x`.
33
+ */
34
+ axis?: ResizableAxis;
35
+ /**
36
+ * Size applied on setup when nothing is stored under `key`. `0` (default) leaves the
37
+ * element's own CSS size alone until the first resize.
38
+ */
39
+ initial?: number;
40
+ /** Lower bound in `units`. `0` = none. */
41
+ min?: number;
42
+ /** Upper bound in `units`. `0` = none (`%` is still capped at 100). */
43
+ max?: number;
44
+ /** `px` (default) or `%` of the parent element. Not meant to change after setup. */
45
+ units?: ResizableUnits;
46
+ /**
47
+ * Puts the handle on the start edge (left / top) and inverts the drag direction — for
48
+ * an element sitting at the end (right / bottom) of its container.
49
+ */
50
+ reverse?: boolean;
51
+ /** Persist the size under this key (as `resizable-width-<key>` / `resizable-height-<key>`). */
52
+ key?: string | number | null | undefined;
53
+ /** Where `key` persists to. Default `session`. */
54
+ storage?: "local" | "session";
55
+ /** Keyboard step in `units`; Shift multiplies it by 10. Default `10` for px, `1` for %. */
56
+ step?: number;
57
+ /** Accessible name of the handle (it is a `separator`). Default `"Resize"`. */
58
+ label?: string;
59
+ /** The size restored by double-click / Enter / `api.reset()`. Defaults to `initial`. */
60
+ resetTo?: number;
61
+ /**
62
+ * Use this element as the handle instead of creating one inside the resized element.
63
+ * It receives the separator semantics, the keyboard and the pointer handling (and
64
+ * `touch-action: none`), but no visual styling — that stays with the consumer.
65
+ */
66
+ handle?: HTMLElement;
67
+ /** Extra classes of the created handle (the full-edge strip). Ignored with `handle`. */
68
+ handleClass?: string;
69
+ /** Extra classes of the created handle's grip. Ignored with `handle`. */
70
+ handleDragClass?: string;
71
+ /** Fires whenever a size is applied: on setup, while dragging, per key press, on reset and `api.set()` */
72
+ onResize?: (info: ResizableInfo) => void;
73
+ /** Receives the imperative api, once per setup */
74
+ onInit?: (api: ResizableApi) => void;
75
+ debug?: (...args: unknown[]) => void;
76
+ }
77
+ /**
78
+ * Makes an element's width (`axis: "x"`) or height (`axis: "y"`) resizable by dragging
79
+ * its edge. The handle is a keyboard-operable ARIA window splitter (`role="separator"`,
80
+ * focusable, `aria-valuenow` in `units`): arrow keys along the axis move it by `step`
81
+ * (×10 with Shift), Home / End go to `min` / `max`, Enter (like double-click) resets.
82
+ * Pointer Events with capture cover mouse, touch and pen in one code path.
83
+ *
84
+ * By default a thin handle strip with a grip is appended inside the element on the
85
+ * resized edge (the element becomes `position: relative`); pass `handle` to drive an
86
+ * element of your own (a sibling separator, say) instead. The size can be persisted
87
+ * under `key`. `min` / `max` clamp every path (drag, keys, `api.set()`).
88
+ *
89
+ * A factory: call it with the options (or a function returning them — reactive reads
90
+ * inside re-run the attachment) and attach the result.
91
+ *
92
+ * @example
93
+ * ```svelte
94
+ * <script>
95
+ * import { resizable } from "@marianmeres/stuic";
96
+ * </script>
97
+ *
98
+ * <!-- side by side: the first flex child gets a draggable width -->
99
+ * <div class="flex">
100
+ * <aside {@attach resizable({ initial: 300, min: 200, max: 600, key: "sidebar" })}>…</aside>
101
+ * <main class="flex-1">…</main>
102
+ * </div>
103
+ *
104
+ * <!-- stacked: a draggable height, in % of the (definite-height) parent -->
105
+ * <div class="flex flex-col h-96">
106
+ * <div {@attach resizable({ axis: "y", units: "%", initial: 40, max: 80 })}>…</div>
107
+ * <div class="flex-1">…</div>
108
+ * </div>
109
+ * ```
110
+ *
111
+ * @param options - {@link ResizableOptions}, or a function returning them
112
+ */
113
+ export declare function resizable(options?: ResizableOptions | (() => ResizableOptions)): Attachment<HTMLElement>;
@@ -0,0 +1,53 @@
1
+ <!--
2
+ TEST-ONLY fixture for the `resizable` attachment (and the `resizableWidth` action
3
+ wrapper). A 400x300 flex container, the resized element first, a flexing sibling
4
+ after it; `external` adds a sibling to serve as the provided handle; `unmount`
5
+ tears the whole thing down so cleanup can be observed on the detached nodes.
6
+ -->
7
+ <script lang="ts">
8
+ import { resizable, type ResizableOptions } from "./resizable.js";
9
+ import {
10
+ resizableWidth,
11
+ type ResizableWidthOptions,
12
+ } from "../actions/resizable-width.svelte.js";
13
+
14
+ let {
15
+ options = {} as ResizableOptions,
16
+ action = undefined as ResizableWidthOptions | undefined,
17
+ external = false,
18
+ vertical = false,
19
+ } = $props();
20
+
21
+ let handleEl = $state<HTMLDivElement>();
22
+ let mounted = $state(true);
23
+ </script>
24
+
25
+ <div
26
+ data-testid="container"
27
+ style="width:400px;height:300px;display:flex;flex-direction:{vertical
28
+ ? 'column'
29
+ : 'row'}"
30
+ >
31
+ {#if mounted}
32
+ {#if action}
33
+ <div data-testid="el" style="width:100px" use:resizableWidth={() => action}>el</div>
34
+ {:else}
35
+ <div
36
+ data-testid="el"
37
+ style={vertical ? "height:100px" : "width:100px"}
38
+ {@attach external
39
+ ? handleEl && resizable({ ...options, handle: handleEl })
40
+ : resizable(options)}
41
+ >
42
+ el
43
+ </div>
44
+ {/if}
45
+ {#if external}
46
+ <div data-testid="handle" data-foo="bar" bind:this={handleEl}></div>
47
+ {/if}
48
+ {/if}
49
+ <div style="flex:1">rest</div>
50
+ </div>
51
+ <button type="button" data-testid="unmount" onclick={() => (mounted = false)}
52
+ >unmount</button
53
+ >