@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.
- package/AGENTS.md +19 -4
- package/API.md +56 -2
- package/README.md +21 -5
- package/dist/actions/resizable-width.svelte.d.ts +14 -28
- package/dist/actions/resizable-width.svelte.js +16 -171
- package/dist/attachments/index.d.ts +1 -0
- package/dist/attachments/index.js +1 -0
- package/dist/attachments/resizable.d.ts +113 -0
- package/dist/attachments/resizable.fixture.svelte +53 -0
- package/dist/attachments/resizable.fixture.svelte.d.ts +10 -0
- package/dist/attachments/resizable.js +295 -0
- package/dist/components/Button/README.md +11 -10
- package/dist/components/ButtonGroupRadio/README.md +52 -23
- package/dist/components/ButtonGroupRadio/index.css +6 -2
- package/dist/components/PricingTable/PricingTable.svelte +8 -14
- package/dist/components/PricingTable/README.md +29 -6
- package/dist/components/PricingTable/index.css +54 -0
- package/dist/components/RangeSlider/README.md +291 -0
- package/dist/components/RangeSlider/RangeSlider.svelte +763 -0
- package/dist/components/RangeSlider/RangeSlider.svelte.d.ts +130 -0
- package/dist/components/RangeSlider/i18n-sk.d.ts +17 -0
- package/dist/components/RangeSlider/i18n-sk.js +19 -0
- package/dist/components/RangeSlider/i18n.d.ts +33 -0
- package/dist/components/RangeSlider/i18n.js +41 -0
- package/dist/components/RangeSlider/index.css +430 -0
- package/dist/components/RangeSlider/index.d.ts +3 -0
- package/dist/components/RangeSlider/index.js +3 -0
- package/dist/components/Rating/README.md +206 -0
- package/dist/components/Rating/Rating.svelte +355 -0
- package/dist/components/Rating/Rating.svelte.d.ts +82 -0
- package/dist/components/Rating/i18n-sk.d.ts +17 -0
- package/dist/components/Rating/i18n-sk.js +21 -0
- package/dist/components/Rating/i18n.d.ts +34 -0
- package/dist/components/Rating/i18n.js +42 -0
- package/dist/components/Rating/index.css +170 -0
- package/dist/components/Rating/index.d.ts +3 -0
- package/dist/components/Rating/index.js +3 -0
- package/dist/components/SplitPane/README.md +169 -0
- package/dist/components/SplitPane/SplitPane.svelte +202 -0
- package/dist/components/SplitPane/SplitPane.svelte.d.ts +67 -0
- package/dist/components/SplitPane/i18n-sk.d.ts +17 -0
- package/dist/components/SplitPane/i18n-sk.js +18 -0
- package/dist/components/SplitPane/i18n.d.ts +31 -0
- package/dist/components/SplitPane/i18n.js +39 -0
- package/dist/components/SplitPane/index.css +153 -0
- package/dist/components/SplitPane/index.d.ts +3 -0
- package/dist/components/SplitPane/index.js +3 -0
- package/dist/components/WithSidePanel/README.md +19 -16
- package/dist/icons/index.d.ts +2 -0
- package/dist/icons/index.js +3 -0
- package/dist/index.css +17 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/docs/{maybe-todo.md → _archive/maybe-todo.md} +18 -7
- package/docs/architecture.md +1 -1
- package/docs/conventions.md +27 -7
- package/docs/domains/actions.md +18 -18
- package/docs/domains/attachments.md +72 -9
- package/docs/domains/components.md +175 -32
- package/docs/domains/theming.md +57 -13
- package/package.json +5 -5
package/AGENTS.md
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
|
|
24
24
|
```
|
|
25
25
|
src/lib/
|
|
26
|
-
├── components/ #
|
|
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) —
|
|
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={() => ({
|
|
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-
|
|
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
|
|
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
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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:
|
|
52
|
+
export declare function resizableWidth(el: HTMLElement, fn?: () => ResizableWidthOptions): void;
|
|
@@ -1,22 +1,16 @@
|
|
|
1
|
-
import {
|
|
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
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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 {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
}
|
|
@@ -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
|
+
>
|