@sentropic/design-system-svelte 0.13.0 → 0.15.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/dist/Autosave.svelte +185 -0
- package/dist/Autosave.svelte.d.ts +25 -0
- package/dist/Autosave.svelte.d.ts.map +1 -0
- package/dist/Calendar.svelte +406 -0
- package/dist/Calendar.svelte.d.ts +31 -0
- package/dist/Calendar.svelte.d.ts.map +1 -0
- package/dist/Popper.svelte +317 -0
- package/dist/Popper.svelte.d.ts +70 -0
- package/dist/Popper.svelte.d.ts.map +1 -0
- package/dist/Portal.svelte +80 -0
- package/dist/Portal.svelte.d.ts +22 -0
- package/dist/Portal.svelte.d.ts.map +1 -0
- package/dist/Rating.svelte +189 -0
- package/dist/Rating.svelte.d.ts +24 -0
- package/dist/Rating.svelte.d.ts.map +1 -0
- package/dist/SlideIndicator.svelte +173 -0
- package/dist/SlideIndicator.svelte.d.ts +19 -0
- package/dist/SlideIndicator.svelte.d.ts.map +1 -0
- package/dist/TimePicker.svelte +335 -0
- package/dist/TimePicker.svelte.d.ts +25 -0
- package/dist/TimePicker.svelte.d.ts.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -0
- package/package.json +1 -1
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
import type { Snippet } from "svelte";
|
|
3
|
+
import Portal from "./Portal.svelte";
|
|
4
|
+
|
|
5
|
+
export type PopperStrategy = "absolute" | "fixed";
|
|
6
|
+
|
|
7
|
+
export type PopperPlacement =
|
|
8
|
+
| "top"
|
|
9
|
+
| "bottom"
|
|
10
|
+
| "left"
|
|
11
|
+
| "right"
|
|
12
|
+
| "top-start"
|
|
13
|
+
| "top-end"
|
|
14
|
+
| "bottom-start"
|
|
15
|
+
| "bottom-end"
|
|
16
|
+
| "left-start"
|
|
17
|
+
| "left-end"
|
|
18
|
+
| "right-start"
|
|
19
|
+
| "right-end";
|
|
20
|
+
|
|
21
|
+
export type PopperSide = "top" | "bottom" | "left" | "right";
|
|
22
|
+
export type PopperAlign = "start" | "center" | "end";
|
|
23
|
+
|
|
24
|
+
export type PopperProps = {
|
|
25
|
+
/** Reference element the panel is positioned against. */
|
|
26
|
+
anchor: HTMLElement | null;
|
|
27
|
+
/** Controlled open state. When false (or no anchor) nothing renders. */
|
|
28
|
+
open?: boolean;
|
|
29
|
+
/** Wanted placement of the panel relative to the anchor. */
|
|
30
|
+
placement?: PopperPlacement;
|
|
31
|
+
/** Main-axis distance (px) between the anchor and the panel. */
|
|
32
|
+
offset?: number;
|
|
33
|
+
/** Flip to the opposite side when the panel would overflow the viewport. */
|
|
34
|
+
flip?: boolean;
|
|
35
|
+
/** Shift along the cross axis to keep the panel within the viewport. */
|
|
36
|
+
shift?: boolean;
|
|
37
|
+
/** Expose a positioned arrow element. */
|
|
38
|
+
arrow?: boolean;
|
|
39
|
+
/** CSS positioning strategy. */
|
|
40
|
+
strategy?: PopperStrategy;
|
|
41
|
+
/** Render the panel into `document.body` via a Portal. */
|
|
42
|
+
portal?: boolean;
|
|
43
|
+
/** Optional class applied to the floating panel. */
|
|
44
|
+
class?: string;
|
|
45
|
+
/** Notified whenever the resolved placement changes (after flip). */
|
|
46
|
+
onPlacementChange?: (placement: PopperPlacement) => void;
|
|
47
|
+
children?: Snippet;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/** Split a placement into its side and (optional) alignment. */
|
|
51
|
+
export function splitPlacement(placement: PopperPlacement): {
|
|
52
|
+
side: PopperSide;
|
|
53
|
+
align: PopperAlign;
|
|
54
|
+
} {
|
|
55
|
+
const [side, align] = placement.split("-") as [PopperSide, PopperAlign?];
|
|
56
|
+
return { side, align: align ?? "center" };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Recompose a side + alignment into a placement string. */
|
|
60
|
+
export function joinPlacement(side: PopperSide, align: PopperAlign): PopperPlacement {
|
|
61
|
+
return (align === "center" ? side : `${side}-${align}`) as PopperPlacement;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const OPPOSITE: Record<PopperSide, PopperSide> = {
|
|
65
|
+
top: "bottom",
|
|
66
|
+
bottom: "top",
|
|
67
|
+
left: "right",
|
|
68
|
+
right: "left"
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export type Rect = {
|
|
72
|
+
top: number;
|
|
73
|
+
left: number;
|
|
74
|
+
right: number;
|
|
75
|
+
bottom: number;
|
|
76
|
+
width: number;
|
|
77
|
+
height: number;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Pure geometry: compute the panel coordinates (in the chosen strategy's
|
|
82
|
+
* coordinate space) given the anchor rect, the panel size, and options.
|
|
83
|
+
* Returns the resolved placement (after flip) and the top/left coordinates,
|
|
84
|
+
* plus the arrow offset along the main edge.
|
|
85
|
+
*
|
|
86
|
+
* Coordinates are viewport-relative; callers add scroll offsets for the
|
|
87
|
+
* `absolute` strategy. No DOM access here — safe to unit test.
|
|
88
|
+
*/
|
|
89
|
+
export function computePosition(
|
|
90
|
+
anchorRect: Rect,
|
|
91
|
+
panelWidth: number,
|
|
92
|
+
panelHeight: number,
|
|
93
|
+
options: {
|
|
94
|
+
placement: PopperPlacement;
|
|
95
|
+
offset: number;
|
|
96
|
+
flip: boolean;
|
|
97
|
+
shift: boolean;
|
|
98
|
+
viewportWidth: number;
|
|
99
|
+
viewportHeight: number;
|
|
100
|
+
}
|
|
101
|
+
): { placement: PopperPlacement; top: number; left: number } {
|
|
102
|
+
const { offset, flip, shift, viewportWidth, viewportHeight } = options;
|
|
103
|
+
let { side, align } = splitPlacement(options.placement);
|
|
104
|
+
|
|
105
|
+
const place = (s: PopperSide, a: PopperAlign) => {
|
|
106
|
+
let top = 0;
|
|
107
|
+
let left = 0;
|
|
108
|
+
if (s === "top" || s === "bottom") {
|
|
109
|
+
top = s === "top" ? anchorRect.top - panelHeight - offset : anchorRect.bottom + offset;
|
|
110
|
+
if (a === "start") left = anchorRect.left;
|
|
111
|
+
else if (a === "end") left = anchorRect.right - panelWidth;
|
|
112
|
+
else left = anchorRect.left + anchorRect.width / 2 - panelWidth / 2;
|
|
113
|
+
} else {
|
|
114
|
+
left = s === "left" ? anchorRect.left - panelWidth - offset : anchorRect.right + offset;
|
|
115
|
+
if (a === "start") top = anchorRect.top;
|
|
116
|
+
else if (a === "end") top = anchorRect.bottom - panelHeight;
|
|
117
|
+
else top = anchorRect.top + anchorRect.height / 2 - panelHeight / 2;
|
|
118
|
+
}
|
|
119
|
+
return { top, left };
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
// Flip: if the panel overflows on the chosen side, try the opposite side.
|
|
123
|
+
if (flip) {
|
|
124
|
+
const candidate = place(side, align);
|
|
125
|
+
const overflows =
|
|
126
|
+
(side === "top" && candidate.top < 0) ||
|
|
127
|
+
(side === "bottom" && candidate.top + panelHeight > viewportHeight) ||
|
|
128
|
+
(side === "left" && candidate.left < 0) ||
|
|
129
|
+
(side === "right" && candidate.left + panelWidth > viewportWidth);
|
|
130
|
+
if (overflows) {
|
|
131
|
+
const flipped = OPPOSITE[side];
|
|
132
|
+
const flippedPos = place(flipped, align);
|
|
133
|
+
const flippedOverflows =
|
|
134
|
+
(flipped === "top" && flippedPos.top < 0) ||
|
|
135
|
+
(flipped === "bottom" && flippedPos.top + panelHeight > viewportHeight) ||
|
|
136
|
+
(flipped === "left" && flippedPos.left < 0) ||
|
|
137
|
+
(flipped === "right" && flippedPos.left + panelWidth > viewportWidth);
|
|
138
|
+
// Only flip if the opposite side fits better.
|
|
139
|
+
if (!flippedOverflows) side = flipped;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
let { top, left } = place(side, align);
|
|
144
|
+
|
|
145
|
+
// Shift: clamp along the cross axis so the panel stays in the viewport.
|
|
146
|
+
if (shift) {
|
|
147
|
+
if (side === "top" || side === "bottom") {
|
|
148
|
+
const max = Math.max(0, viewportWidth - panelWidth);
|
|
149
|
+
left = Math.min(Math.max(0, left), max);
|
|
150
|
+
} else {
|
|
151
|
+
const max = Math.max(0, viewportHeight - panelHeight);
|
|
152
|
+
top = Math.min(Math.max(0, top), max);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return { placement: joinPlacement(side, align), top, left };
|
|
157
|
+
}
|
|
158
|
+
</script>
|
|
159
|
+
|
|
160
|
+
<script lang="ts">
|
|
161
|
+
let {
|
|
162
|
+
anchor,
|
|
163
|
+
open = false,
|
|
164
|
+
placement = "bottom",
|
|
165
|
+
offset = 8,
|
|
166
|
+
flip = true,
|
|
167
|
+
shift = true,
|
|
168
|
+
arrow = false,
|
|
169
|
+
strategy = "absolute",
|
|
170
|
+
portal = true,
|
|
171
|
+
class: className,
|
|
172
|
+
onPlacementChange,
|
|
173
|
+
children
|
|
174
|
+
}: PopperProps = $props();
|
|
175
|
+
|
|
176
|
+
let panel = $state<HTMLDivElement | undefined>();
|
|
177
|
+
let top = $state(0);
|
|
178
|
+
let left = $state(0);
|
|
179
|
+
// Placement actually applied (may differ from the requested `placement`
|
|
180
|
+
// after a flip). Initialised lazily; defaults to the requested placement.
|
|
181
|
+
let flippedPlacement = $state<PopperPlacement | undefined>();
|
|
182
|
+
const resolvedPlacement = $derived(flippedPlacement ?? placement);
|
|
183
|
+
|
|
184
|
+
function reposition() {
|
|
185
|
+
if (typeof window === "undefined") return;
|
|
186
|
+
if (!open || !anchor || !panel) return;
|
|
187
|
+
|
|
188
|
+
const anchorRect = anchor.getBoundingClientRect();
|
|
189
|
+
const panelRect = panel.getBoundingClientRect();
|
|
190
|
+
|
|
191
|
+
const result = computePosition(
|
|
192
|
+
{
|
|
193
|
+
top: anchorRect.top,
|
|
194
|
+
left: anchorRect.left,
|
|
195
|
+
right: anchorRect.right,
|
|
196
|
+
bottom: anchorRect.bottom,
|
|
197
|
+
width: anchorRect.width,
|
|
198
|
+
height: anchorRect.height
|
|
199
|
+
},
|
|
200
|
+
panelRect.width,
|
|
201
|
+
panelRect.height,
|
|
202
|
+
{
|
|
203
|
+
placement,
|
|
204
|
+
offset,
|
|
205
|
+
flip,
|
|
206
|
+
shift,
|
|
207
|
+
viewportWidth: window.innerWidth,
|
|
208
|
+
viewportHeight: window.innerHeight
|
|
209
|
+
}
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
// `absolute` is positioned relative to the document, so add scroll offsets.
|
|
213
|
+
// `fixed` is viewport-relative, so coordinates are used as-is.
|
|
214
|
+
if (strategy === "absolute") {
|
|
215
|
+
top = result.top + window.scrollY;
|
|
216
|
+
left = result.left + window.scrollX;
|
|
217
|
+
} else {
|
|
218
|
+
top = result.top;
|
|
219
|
+
left = result.left;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (result.placement !== resolvedPlacement) {
|
|
223
|
+
flippedPlacement = result.placement;
|
|
224
|
+
onPlacementChange?.(result.placement);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
$effect(() => {
|
|
229
|
+
// Client-only: register listeners and compute the position once mounted.
|
|
230
|
+
if (typeof window === "undefined") return;
|
|
231
|
+
if (!open || !anchor || !panel) return;
|
|
232
|
+
|
|
233
|
+
reposition();
|
|
234
|
+
|
|
235
|
+
const onScroll = () => reposition();
|
|
236
|
+
const onResize = () => reposition();
|
|
237
|
+
window.addEventListener("scroll", onScroll, true);
|
|
238
|
+
window.addEventListener("resize", onResize);
|
|
239
|
+
|
|
240
|
+
return () => {
|
|
241
|
+
window.removeEventListener("scroll", onScroll, true);
|
|
242
|
+
window.removeEventListener("resize", onResize);
|
|
243
|
+
};
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
const panelStyle = () =>
|
|
247
|
+
`position: ${strategy}; top: ${top}px; left: ${left}px;`;
|
|
248
|
+
const panelSide = () => splitPlacement(resolvedPlacement).side;
|
|
249
|
+
</script>
|
|
250
|
+
|
|
251
|
+
{#snippet floating()}
|
|
252
|
+
<div
|
|
253
|
+
bind:this={panel}
|
|
254
|
+
class={className ? `st-popper ${className}` : "st-popper"}
|
|
255
|
+
data-popper-placement={resolvedPlacement}
|
|
256
|
+
style={panelStyle()}
|
|
257
|
+
>
|
|
258
|
+
{@render children?.()}
|
|
259
|
+
{#if arrow}
|
|
260
|
+
<span
|
|
261
|
+
class="st-popper__arrow"
|
|
262
|
+
data-popper-side={panelSide()}
|
|
263
|
+
aria-hidden="true"
|
|
264
|
+
></span>
|
|
265
|
+
{/if}
|
|
266
|
+
</div>
|
|
267
|
+
{/snippet}
|
|
268
|
+
|
|
269
|
+
{#if open && anchor}
|
|
270
|
+
{#if portal}
|
|
271
|
+
<Portal target="body">
|
|
272
|
+
{@render floating()}
|
|
273
|
+
</Portal>
|
|
274
|
+
{:else}
|
|
275
|
+
{@render floating()}
|
|
276
|
+
{/if}
|
|
277
|
+
{/if}
|
|
278
|
+
|
|
279
|
+
<style>
|
|
280
|
+
.st-popper {
|
|
281
|
+
z-index: var(--st-component-popover-zIndex, 80);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
.st-popper__arrow {
|
|
285
|
+
position: absolute;
|
|
286
|
+
width: 0.5rem;
|
|
287
|
+
height: 0.5rem;
|
|
288
|
+
background: inherit;
|
|
289
|
+
border: inherit;
|
|
290
|
+
transform: rotate(45deg);
|
|
291
|
+
pointer-events: none;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
.st-popper__arrow[data-popper-side="bottom"] {
|
|
295
|
+
top: -0.25rem;
|
|
296
|
+
left: 50%;
|
|
297
|
+
margin-left: -0.25rem;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
.st-popper__arrow[data-popper-side="top"] {
|
|
301
|
+
bottom: -0.25rem;
|
|
302
|
+
left: 50%;
|
|
303
|
+
margin-left: -0.25rem;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
.st-popper__arrow[data-popper-side="right"] {
|
|
307
|
+
left: -0.25rem;
|
|
308
|
+
top: 50%;
|
|
309
|
+
margin-top: -0.25rem;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
.st-popper__arrow[data-popper-side="left"] {
|
|
313
|
+
right: -0.25rem;
|
|
314
|
+
top: 50%;
|
|
315
|
+
margin-top: -0.25rem;
|
|
316
|
+
}
|
|
317
|
+
</style>
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { Snippet } from "svelte";
|
|
2
|
+
export type PopperStrategy = "absolute" | "fixed";
|
|
3
|
+
export type PopperPlacement = "top" | "bottom" | "left" | "right" | "top-start" | "top-end" | "bottom-start" | "bottom-end" | "left-start" | "left-end" | "right-start" | "right-end";
|
|
4
|
+
export type PopperSide = "top" | "bottom" | "left" | "right";
|
|
5
|
+
export type PopperAlign = "start" | "center" | "end";
|
|
6
|
+
export type PopperProps = {
|
|
7
|
+
/** Reference element the panel is positioned against. */
|
|
8
|
+
anchor: HTMLElement | null;
|
|
9
|
+
/** Controlled open state. When false (or no anchor) nothing renders. */
|
|
10
|
+
open?: boolean;
|
|
11
|
+
/** Wanted placement of the panel relative to the anchor. */
|
|
12
|
+
placement?: PopperPlacement;
|
|
13
|
+
/** Main-axis distance (px) between the anchor and the panel. */
|
|
14
|
+
offset?: number;
|
|
15
|
+
/** Flip to the opposite side when the panel would overflow the viewport. */
|
|
16
|
+
flip?: boolean;
|
|
17
|
+
/** Shift along the cross axis to keep the panel within the viewport. */
|
|
18
|
+
shift?: boolean;
|
|
19
|
+
/** Expose a positioned arrow element. */
|
|
20
|
+
arrow?: boolean;
|
|
21
|
+
/** CSS positioning strategy. */
|
|
22
|
+
strategy?: PopperStrategy;
|
|
23
|
+
/** Render the panel into `document.body` via a Portal. */
|
|
24
|
+
portal?: boolean;
|
|
25
|
+
/** Optional class applied to the floating panel. */
|
|
26
|
+
class?: string;
|
|
27
|
+
/** Notified whenever the resolved placement changes (after flip). */
|
|
28
|
+
onPlacementChange?: (placement: PopperPlacement) => void;
|
|
29
|
+
children?: Snippet;
|
|
30
|
+
};
|
|
31
|
+
/** Split a placement into its side and (optional) alignment. */
|
|
32
|
+
export declare function splitPlacement(placement: PopperPlacement): {
|
|
33
|
+
side: PopperSide;
|
|
34
|
+
align: PopperAlign;
|
|
35
|
+
};
|
|
36
|
+
/** Recompose a side + alignment into a placement string. */
|
|
37
|
+
export declare function joinPlacement(side: PopperSide, align: PopperAlign): PopperPlacement;
|
|
38
|
+
export type Rect = {
|
|
39
|
+
top: number;
|
|
40
|
+
left: number;
|
|
41
|
+
right: number;
|
|
42
|
+
bottom: number;
|
|
43
|
+
width: number;
|
|
44
|
+
height: number;
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* Pure geometry: compute the panel coordinates (in the chosen strategy's
|
|
48
|
+
* coordinate space) given the anchor rect, the panel size, and options.
|
|
49
|
+
* Returns the resolved placement (after flip) and the top/left coordinates,
|
|
50
|
+
* plus the arrow offset along the main edge.
|
|
51
|
+
*
|
|
52
|
+
* Coordinates are viewport-relative; callers add scroll offsets for the
|
|
53
|
+
* `absolute` strategy. No DOM access here — safe to unit test.
|
|
54
|
+
*/
|
|
55
|
+
export declare function computePosition(anchorRect: Rect, panelWidth: number, panelHeight: number, options: {
|
|
56
|
+
placement: PopperPlacement;
|
|
57
|
+
offset: number;
|
|
58
|
+
flip: boolean;
|
|
59
|
+
shift: boolean;
|
|
60
|
+
viewportWidth: number;
|
|
61
|
+
viewportHeight: number;
|
|
62
|
+
}): {
|
|
63
|
+
placement: PopperPlacement;
|
|
64
|
+
top: number;
|
|
65
|
+
left: number;
|
|
66
|
+
};
|
|
67
|
+
declare const Popper: import("svelte").Component<PopperProps, {}, "">;
|
|
68
|
+
type Popper = ReturnType<typeof Popper>;
|
|
69
|
+
export default Popper;
|
|
70
|
+
//# sourceMappingURL=Popper.svelte.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"Popper.svelte.d.ts","sourceRoot":"","sources":["../src/lib/Popper.svelte.ts"],"names":[],"mappings":"AAGE,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AAGtC,MAAM,MAAM,cAAc,GAAG,UAAU,GAAG,OAAO,CAAC;AAElD,MAAM,MAAM,eAAe,GACvB,KAAK,GACL,QAAQ,GACR,MAAM,GACN,OAAO,GACP,WAAW,GACX,SAAS,GACT,cAAc,GACd,YAAY,GACZ,YAAY,GACZ,UAAU,GACV,aAAa,GACb,WAAW,CAAC;AAEhB,MAAM,MAAM,UAAU,GAAG,KAAK,GAAG,QAAQ,GAAG,MAAM,GAAG,OAAO,CAAC;AAC7D,MAAM,MAAM,WAAW,GAAG,OAAO,GAAG,QAAQ,GAAG,KAAK,CAAC;AAErD,MAAM,MAAM,WAAW,GAAG;IACxB,yDAAyD;IACzD,MAAM,EAAE,WAAW,GAAG,IAAI,CAAC;IAC3B,wEAAwE;IACxE,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,4DAA4D;IAC5D,SAAS,CAAC,EAAE,eAAe,CAAC;IAC5B,gEAAgE;IAChE,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,4EAA4E;IAC5E,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,wEAAwE;IACxE,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,yCAAyC;IACzC,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,gCAAgC;IAChC,QAAQ,CAAC,EAAE,cAAc,CAAC;IAC1B,0DAA0D;IAC1D,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,oDAAoD;IACpD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,qEAAqE;IACrE,iBAAiB,CAAC,EAAE,CAAC,SAAS,EAAE,eAAe,KAAK,IAAI,CAAC;IACzD,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB,CAAC;AAEF,gEAAgE;AAChE,wBAAgB,cAAc,CAAC,SAAS,EAAE,eAAe,GAAG;IAC1D,IAAI,EAAE,UAAU,CAAC;IACjB,KAAK,EAAE,WAAW,CAAC;CACpB,CAGA;AAED,4DAA4D;AAC5D,wBAAgB,aAAa,CAAC,IAAI,EAAE,UAAU,EAAE,KAAK,EAAE,WAAW,GAAG,eAAe,CAEnF;AASD,MAAM,MAAM,IAAI,GAAG;IACjB,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;CAChB,CAAC;AAEF;;;;;;;;GAQG;AACH,wBAAgB,eAAe,CAC7B,UAAU,EAAE,IAAI,EAChB,UAAU,EAAE,MAAM,EAClB,WAAW,EAAE,MAAM,EACnB,OAAO,EAAE;IACP,SAAS,EAAE,eAAe,CAAC;IAC3B,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,OAAO,CAAC;IACd,KAAK,EAAE,OAAO,CAAC;IACf,aAAa,EAAE,MAAM,CAAC;IACtB,cAAc,EAAE,MAAM,CAAC;CACxB,GACA;IAAE,SAAS,EAAE,eAAe,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAwD3D;AAsHH,QAAA,MAAM,MAAM,iDAAwC,CAAC;AACrD,KAAK,MAAM,GAAG,UAAU,CAAC,OAAO,MAAM,CAAC,CAAC;AACxC,eAAe,MAAM,CAAC"}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
import type { Snippet } from "svelte";
|
|
3
|
+
|
|
4
|
+
export type PortalProps = {
|
|
5
|
+
/**
|
|
6
|
+
* Where to teleport the children. A CSS selector string or an actual
|
|
7
|
+
* `HTMLElement`. Defaults to the document `<body>`.
|
|
8
|
+
*/
|
|
9
|
+
target?: string | HTMLElement;
|
|
10
|
+
/** When `true`, render inline in place (no teleportation). */
|
|
11
|
+
disabled?: boolean;
|
|
12
|
+
/** Optional class applied to the portal container element. */
|
|
13
|
+
class?: string;
|
|
14
|
+
children?: Snippet;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Resolve a target prop to an `HTMLElement`. Returns `null` when it cannot be
|
|
19
|
+
* resolved (SSR, missing selector, etc.).
|
|
20
|
+
*/
|
|
21
|
+
export function resolvePortalTarget(
|
|
22
|
+
target: string | HTMLElement | undefined
|
|
23
|
+
): HTMLElement | null {
|
|
24
|
+
if (typeof document === "undefined") return null;
|
|
25
|
+
if (target == null) return document.body;
|
|
26
|
+
if (typeof target === "string") {
|
|
27
|
+
return document.querySelector<HTMLElement>(target) ?? document.body;
|
|
28
|
+
}
|
|
29
|
+
return target;
|
|
30
|
+
}
|
|
31
|
+
</script>
|
|
32
|
+
|
|
33
|
+
<script lang="ts">
|
|
34
|
+
let {
|
|
35
|
+
target = "body",
|
|
36
|
+
disabled = false,
|
|
37
|
+
class: className,
|
|
38
|
+
children
|
|
39
|
+
}: PortalProps = $props();
|
|
40
|
+
|
|
41
|
+
// The container that actually holds the children. We render it inline first
|
|
42
|
+
// (so SSR produces markup in place) and move it into the target on mount.
|
|
43
|
+
let container = $state<HTMLDivElement | undefined>();
|
|
44
|
+
|
|
45
|
+
$effect(() => {
|
|
46
|
+
// Client-only: never touch the DOM during SSR or before mount.
|
|
47
|
+
if (disabled || !container) return;
|
|
48
|
+
if (typeof document === "undefined") return;
|
|
49
|
+
|
|
50
|
+
const destination = resolvePortalTarget(target);
|
|
51
|
+
if (!destination) return;
|
|
52
|
+
|
|
53
|
+
destination.appendChild(container);
|
|
54
|
+
|
|
55
|
+
return () => {
|
|
56
|
+
// Clean up on unmount / target change: remove the container from the DOM.
|
|
57
|
+
container?.remove();
|
|
58
|
+
};
|
|
59
|
+
});
|
|
60
|
+
</script>
|
|
61
|
+
|
|
62
|
+
{#if disabled}
|
|
63
|
+
<div class={className ? `st-portal ${className}` : "st-portal"} data-st-portal="inline">
|
|
64
|
+
{@render children?.()}
|
|
65
|
+
</div>
|
|
66
|
+
{:else}
|
|
67
|
+
<div
|
|
68
|
+
bind:this={container}
|
|
69
|
+
class={className ? `st-portal ${className}` : "st-portal"}
|
|
70
|
+
data-st-portal="teleported"
|
|
71
|
+
>
|
|
72
|
+
{@render children?.()}
|
|
73
|
+
</div>
|
|
74
|
+
{/if}
|
|
75
|
+
|
|
76
|
+
<style>
|
|
77
|
+
.st-portal {
|
|
78
|
+
display: contents;
|
|
79
|
+
}
|
|
80
|
+
</style>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Snippet } from "svelte";
|
|
2
|
+
export type PortalProps = {
|
|
3
|
+
/**
|
|
4
|
+
* Where to teleport the children. A CSS selector string or an actual
|
|
5
|
+
* `HTMLElement`. Defaults to the document `<body>`.
|
|
6
|
+
*/
|
|
7
|
+
target?: string | HTMLElement;
|
|
8
|
+
/** When `true`, render inline in place (no teleportation). */
|
|
9
|
+
disabled?: boolean;
|
|
10
|
+
/** Optional class applied to the portal container element. */
|
|
11
|
+
class?: string;
|
|
12
|
+
children?: Snippet;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Resolve a target prop to an `HTMLElement`. Returns `null` when it cannot be
|
|
16
|
+
* resolved (SSR, missing selector, etc.).
|
|
17
|
+
*/
|
|
18
|
+
export declare function resolvePortalTarget(target: string | HTMLElement | undefined): HTMLElement | null;
|
|
19
|
+
declare const Portal: import("svelte").Component<PortalProps, {}, "">;
|
|
20
|
+
type Portal = ReturnType<typeof Portal>;
|
|
21
|
+
export default Portal;
|
|
22
|
+
//# sourceMappingURL=Portal.svelte.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"Portal.svelte.d.ts","sourceRoot":"","sources":["../src/lib/Portal.svelte.ts"],"names":[],"mappings":"AAGE,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AAEtC,MAAM,MAAM,WAAW,GAAG;IACxB;;;OAGG;IACH,MAAM,CAAC,EAAE,MAAM,GAAG,WAAW,CAAC;IAC9B,8DAA8D;IAC9D,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,8DAA8D;IAC9D,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB,CAAC;AAEF;;;GAGG;AACH,wBAAgB,mBAAmB,CACjC,MAAM,EAAE,MAAM,GAAG,WAAW,GAAG,SAAS,GACvC,WAAW,GAAG,IAAI,CAOpB;AA+CH,QAAA,MAAM,MAAM,iDAAwC,CAAC;AACrD,KAAK,MAAM,GAAG,UAAU,CAAC,OAAO,MAAM,CAAC,CAAC;AACxC,eAAe,MAAM,CAAC"}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
export type RatingSize = "sm" | "md" | "lg";
|
|
3
|
+
</script>
|
|
4
|
+
|
|
5
|
+
<script lang="ts">
|
|
6
|
+
import { Star, StarHalf } from "@lucide/svelte";
|
|
7
|
+
import type { HTMLAttributes } from "svelte/elements";
|
|
8
|
+
|
|
9
|
+
type RatingProps = Omit<HTMLAttributes<HTMLDivElement>, "class" | "onchange"> & {
|
|
10
|
+
/** Note courante (0..max). Pas de 1, ou 0.5 si `allowHalf`. */
|
|
11
|
+
value?: number;
|
|
12
|
+
/** Nombre d'étoiles. */
|
|
13
|
+
max?: number;
|
|
14
|
+
/** Appelé avec la nouvelle note au clic ou au clavier. */
|
|
15
|
+
onChange?: (value: number) => void;
|
|
16
|
+
/** Affichage seul : ni clic ni clavier n'émettent. */
|
|
17
|
+
readonly?: boolean;
|
|
18
|
+
/** Autorise les demi-étoiles (sélection au demi-point). */
|
|
19
|
+
allowHalf?: boolean;
|
|
20
|
+
size?: RatingSize;
|
|
21
|
+
/** Attribut name (utile dans un formulaire / pour la sémantique radio). */
|
|
22
|
+
name?: string;
|
|
23
|
+
/** Étiquette accessible du groupe. */
|
|
24
|
+
label?: string;
|
|
25
|
+
class?: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
let {
|
|
29
|
+
value = 0,
|
|
30
|
+
max = 5,
|
|
31
|
+
onChange,
|
|
32
|
+
readonly = false,
|
|
33
|
+
allowHalf = false,
|
|
34
|
+
size = "md",
|
|
35
|
+
name,
|
|
36
|
+
label,
|
|
37
|
+
class: className,
|
|
38
|
+
...rest
|
|
39
|
+
}: RatingProps = $props();
|
|
40
|
+
|
|
41
|
+
const iconSize = $derived(size === "sm" ? 16 : size === "lg" ? 28 : 22);
|
|
42
|
+
|
|
43
|
+
const classes = $derived(
|
|
44
|
+
["st-rating", `st-rating--${size}`, readonly && "st-rating--readonly", className]
|
|
45
|
+
.filter(Boolean)
|
|
46
|
+
.join(" ")
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
const stars = $derived(Array.from({ length: max }, (_, i) => i + 1));
|
|
50
|
+
|
|
51
|
+
// L'étoile « focusable » (tabindex 0) suit la valeur ; à 0 c'est la première.
|
|
52
|
+
const focusedStar = $derived(value > 0 ? Math.ceil(value) : 1);
|
|
53
|
+
|
|
54
|
+
function fill(star: number): "full" | "half" | "empty" {
|
|
55
|
+
if (value >= star) return "full";
|
|
56
|
+
if (allowHalf && value >= star - 0.5) return "half";
|
|
57
|
+
return "empty";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function commit(next: number) {
|
|
61
|
+
if (readonly) return;
|
|
62
|
+
const clamped = Math.max(0, Math.min(max, next));
|
|
63
|
+
value = clamped;
|
|
64
|
+
onChange?.(clamped);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function onStarClick(event: MouseEvent, star: number) {
|
|
68
|
+
if (readonly) return;
|
|
69
|
+
let next = star;
|
|
70
|
+
if (allowHalf) {
|
|
71
|
+
const target = event.currentTarget as HTMLElement;
|
|
72
|
+
const rect = target.getBoundingClientRect();
|
|
73
|
+
const isLeftHalf = event.clientX - rect.left < rect.width / 2;
|
|
74
|
+
next = isLeftHalf ? star - 0.5 : star;
|
|
75
|
+
}
|
|
76
|
+
// Re-cliquer la valeur déjà sélectionnée remet à zéro.
|
|
77
|
+
if (next === value) {
|
|
78
|
+
commit(0);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
commit(next);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function onKeyDown(event: KeyboardEvent) {
|
|
85
|
+
if (readonly) return;
|
|
86
|
+
const step = allowHalf ? 0.5 : 1;
|
|
87
|
+
let handled = true;
|
|
88
|
+
switch (event.key) {
|
|
89
|
+
case "ArrowRight":
|
|
90
|
+
case "ArrowUp":
|
|
91
|
+
commit(Math.min(max, value + step));
|
|
92
|
+
break;
|
|
93
|
+
case "ArrowLeft":
|
|
94
|
+
case "ArrowDown":
|
|
95
|
+
commit(Math.max(0, value - step));
|
|
96
|
+
break;
|
|
97
|
+
case "Home":
|
|
98
|
+
commit(0);
|
|
99
|
+
break;
|
|
100
|
+
case "End":
|
|
101
|
+
commit(max);
|
|
102
|
+
break;
|
|
103
|
+
default:
|
|
104
|
+
handled = false;
|
|
105
|
+
}
|
|
106
|
+
if (handled) event.preventDefault();
|
|
107
|
+
}
|
|
108
|
+
</script>
|
|
109
|
+
|
|
110
|
+
<div
|
|
111
|
+
{...rest}
|
|
112
|
+
class={classes}
|
|
113
|
+
role="radiogroup"
|
|
114
|
+
aria-label={label}
|
|
115
|
+
aria-readonly={readonly ? "true" : undefined}
|
|
116
|
+
>
|
|
117
|
+
{#each stars as star (star)}
|
|
118
|
+
{@const state = fill(star)}
|
|
119
|
+
<button
|
|
120
|
+
type="button"
|
|
121
|
+
class="st-rating__star"
|
|
122
|
+
class:st-rating__star--full={state === "full"}
|
|
123
|
+
class:st-rating__star--half={state === "half"}
|
|
124
|
+
role="radio"
|
|
125
|
+
name={name}
|
|
126
|
+
aria-checked={Math.ceil(value) === star ? "true" : "false"}
|
|
127
|
+
aria-label={`${star} / ${max}`}
|
|
128
|
+
tabindex={!readonly && star === focusedStar ? 0 : -1}
|
|
129
|
+
disabled={readonly}
|
|
130
|
+
onclick={(event) => onStarClick(event, star)}
|
|
131
|
+
onkeydown={onKeyDown}
|
|
132
|
+
>
|
|
133
|
+
{#if state === "half"}
|
|
134
|
+
<StarHalf size={iconSize} strokeWidth={1.75} aria-hidden="true" />
|
|
135
|
+
{:else}
|
|
136
|
+
<Star
|
|
137
|
+
size={iconSize}
|
|
138
|
+
strokeWidth={1.75}
|
|
139
|
+
fill={state === "full" ? "currentColor" : "none"}
|
|
140
|
+
aria-hidden="true"
|
|
141
|
+
/>
|
|
142
|
+
{/if}
|
|
143
|
+
</button>
|
|
144
|
+
{/each}
|
|
145
|
+
</div>
|
|
146
|
+
|
|
147
|
+
<style>
|
|
148
|
+
.st-rating {
|
|
149
|
+
align-items: center;
|
|
150
|
+
color: var(--st-semantic-text-secondary);
|
|
151
|
+
display: inline-flex;
|
|
152
|
+
gap: var(--st-spacing-1, 0.25rem);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
.st-rating__star {
|
|
156
|
+
align-items: center;
|
|
157
|
+
background: transparent;
|
|
158
|
+
border: 0;
|
|
159
|
+
border-radius: var(--st-component-control-radius, 0.375rem);
|
|
160
|
+
color: var(--st-semantic-text-muted);
|
|
161
|
+
cursor: pointer;
|
|
162
|
+
display: inline-flex;
|
|
163
|
+
justify-content: center;
|
|
164
|
+
line-height: 0;
|
|
165
|
+
padding: var(--st-spacing-1, 0.25rem);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
.st-rating__star--full,
|
|
169
|
+
.st-rating__star--half {
|
|
170
|
+
color: var(--st-semantic-feedback-warning, var(--st-semantic-action-primary));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
.st-rating__star:hover:not(:disabled) {
|
|
174
|
+
color: var(--st-semantic-feedback-warning, var(--st-semantic-action-primary));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
.st-rating__star:focus-visible {
|
|
178
|
+
outline: 2px solid var(--st-component-control-focusRing, var(--st-semantic-border-interactive));
|
|
179
|
+
outline-offset: 2px;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
.st-rating__star:disabled {
|
|
183
|
+
cursor: default;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
.st-rating--readonly .st-rating__star {
|
|
187
|
+
cursor: default;
|
|
188
|
+
}
|
|
189
|
+
</style>
|