sb-floating-panel-vue 0.1.6 → 3.0.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/README.md CHANGED
@@ -1,157 +1,355 @@
1
1
  # sb-floating-panel-vue
2
+
2
3
  [![npm](https://img.shields.io/npm/v/sb-floating-panel-vue.svg)](https://www.npmjs.com/package/sb-floating-panel-vue)
3
4
  [![license](https://img.shields.io/npm/l/sb-floating-panel-vue.svg)](https://github.com/stefanBid/sb-floating-panel-vue/blob/main/LICENSE)
5
+ [![Vue 3](https://img.shields.io/badge/vue-3.x-brightgreen.svg)](https://vuejs.org)
6
+ [![CI](https://github.com/stefanBid/sb-floating-panel-vue/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/stefanBid/sb-floating-panel-vue/actions/workflows/ci.yml)
7
+ [![coverage](https://raw.githubusercontent.com/stefanBid/sb-floating-panel-vue/badges/coverage.svg)](https://github.com/stefanBid/sb-floating-panel-vue/actions/workflows/ci.yml)
8
+ [![branches](https://raw.githubusercontent.com/stefanBid/sb-floating-panel-vue/badges/coverage-branches.svg)](https://github.com/stefanBid/sb-floating-panel-vue/actions/workflows/ci.yml)
9
+
10
+ ## 🔥 Features
4
11
 
5
- > A lightweight Vue 3 composable hook built on top of [@floating-ui/vue](https://github.com/floating-ui/floating-ui) to manage floating panels with arrow and resize support.
12
+ - 🧩 Three components (`SbContainer`, `SbReference`, `SbFloating`) for dropdowns, popovers, menus and tooltips
13
+ - ⚡ Built on top of [`@floating-ui/vue`](https://floating-ui.com/): `offset`, `flip`, `shift`, `size` and `hide` already configured
14
+ - 🎨 Zero CSS shipped: the library adapts to your design system (plain CSS, Tailwind, CSS modules)
15
+ - ♿ Accessible by default: ARIA attributes, keyboard support, Escape, click outside, focus management
16
+ - 🔁 Fully reactive props, `v-model:open`, nested panels
6
17
 
7
18
  ---
8
19
 
9
- ## ✨ Features
20
+ ## 👤 Author
10
21
 
11
- - 📦 Minimal and composable API
12
- - 🎯 Precise floating panel positioning via `@floating-ui/vue`
13
- - 🎈 Optional arrow positioning
14
- - 📏 Auto-resize support
15
- - 🧠 TypeScript-first
22
+ Created with ❤️ by [Stefano Biddau](https://www.stefano-biddau.com/)
16
23
 
17
24
  ---
18
25
 
19
- ## 🚀 Installation
26
+ ## ❓ Why This Library?
20
27
 
21
- ```bash
22
- npm install sb-floating-panel-vue
23
- # or
24
- yarn add sb-floating-panel-vue
25
- ```
28
+ `sb-floating-panel-vue` makes it easy to build floating panels in Vue 3 without touching positioning logic. You write three components; the library handles placement, flipping, viewport collisions, open state, accessibility and teleporting. It never decides how your panel looks: no stylesheet, no default visual style, only data attributes and CSS variables your styles can read.
26
29
 
27
30
  ---
28
31
 
29
- ## 🧩 Usage
32
+ ## 📦 Installation
30
33
 
31
- The `useSbFloatingPanel` composable provides an easy-to-integrate API to handle floating panels, such as tooltips, popovers, dropdowns, and other UI overlays.
34
+ ```bash
35
+ # npm
36
+ npm install sb-floating-panel-vue vue @floating-ui/vue
32
37
 
33
- ### Importing
38
+ # yarn
39
+ yarn add sb-floating-panel-vue vue @floating-ui/vue
34
40
 
35
- ```ts
36
- import { useSbFloatingPanel } from 'sb-floating-panel-vue';
41
+ # pnpm
42
+ pnpm add sb-floating-panel-vue vue @floating-ui/vue
37
43
  ```
38
44
 
39
- ### 🧠 API
45
+ `vue` (`^3.5.13`) and `@floating-ui/vue` (`^2.0.1`) are peer dependencies: your app provides them, so only one copy of each ends up in the bundle. Vue is usually already installed in your app.
40
46
 
41
- ```ts
42
- const {
43
- reference,
44
- floating,
45
- popperArrow,
46
- isOpen,
47
- floatingPosition,
48
- floatingStyles,
49
- floatingArrowStyles,
50
- changeFloatingVisibility,
51
- } = useSbFloatingPanel(settings);
47
+ There is no CSS file to import.
48
+
49
+ ---
50
+
51
+ ## 🧱 Usage
52
+
53
+ ```vue
54
+ <script setup lang="ts">
55
+ import { SbContainer, SbReference, SbFloating } from 'sb-floating-panel-vue';
56
+ </script>
57
+
58
+ <template>
59
+ <SbContainer placement="bottom-start" match-width="min">
60
+ <template #default="{ close }">
61
+ <SbReference class="my-trigger" aria-haspopup="menu">Account</SbReference>
62
+ <SbFloating role="menu" transition="pop" class="my-panel">
63
+ <button type="button" role="menuitem" @click="close()">Sign out</button>
64
+ </SbFloating>
65
+ </template>
66
+ </SbContainer>
67
+ </template>
52
68
  ```
53
69
 
54
- ### Parameters
70
+ `SbContainer` shares its state with `SbReference` and `SbFloating` through `provide`/`inject`: no refs or styles to pass by hand. Both must be used inside an `SbContainer`.
71
+
72
+ ### `<SbContainer>`
73
+
74
+ Holds the state and the positioning. Renders no element of its own, only its slot.
75
+
76
+ | Prop | Type | Default | Description |
77
+ |-----------------------|----------------------------------------|------------------|----------------------------------------------------------------------------------------------|
78
+ | `placement` | `SbPlacement` | `'bottom-start'` | Preferred placement; the panel flips when there is not enough space |
79
+ | `strategy` | `'absolute' \| 'fixed'` | `'absolute'` | CSS position strategy. Use `fixed` inside scroll containers or fixed layouts |
80
+ | `offset` | `number` | `8` | Distance in px between reference and panel |
81
+ | `padding` | `number` | `8` | Minimum distance in px between the panel and the viewport edges |
82
+ | `matchWidth` | `boolean \| 'min'` | `false` | `true` sets `width`, `'min'` sets `min-width` to the reference width |
83
+ | `hideWhenDetached` | `boolean` | `false` | Sets `visibility: hidden` when the reference is scrolled out of view |
84
+ | `v-model:open` | `boolean` | — | Open state. Leave it unset to let the container manage it |
85
+ | `defaultOpen` | `boolean` | `false` | Initial open state when `v-model:open` is not used |
86
+ | `disabled` | `boolean` | `false` | Prevents opening; closes the panel if it is open |
87
+ | `trigger` | `'click' \| 'manual'` | `'click'` | `manual`: the reference does not toggle; open with `v-model:open`, slot props or methods |
88
+ | `closeOnEscape` | `boolean` | `true` | Closes on Escape |
89
+ | `closeOnClickOutside` | `boolean` | `true` | Closes on a pointer press outside the reference and the panel |
90
+
91
+ | Event | Payload | Description |
92
+ |---------------|-----------|--------------------------|
93
+ | `update:open` | `boolean` | Used by `v-model:open` |
94
+ | `open` | — | The panel has opened |
95
+ | `close` | — | The panel has closed |
96
+
97
+ **Slot props**: `isOpen`, `open()`, `close()`, `toggle()`, `placement` (resolved after flip), `side`.
98
+
99
+ **Exposed methods** (template ref): `open()`, `close()`, `toggle()`, `update()` (recomputes the position).
100
+
101
+ ### `<SbReference>`
55
102
 
56
- The composable takes a `settings` object:
103
+ The trigger the panel is anchored to.
104
+
105
+ | Prop | Type | Default | Description |
106
+ |----------------|----------|------------|--------------------------------------------------------------------------------------------------------------|
107
+ | `as` | `string` | `'button'` | Tag to render. `button` gets `type="button"`; tags other than `button`/`a` get `role="button"`, `tabindex="0"` and Enter/Space |
108
+ | `ariaHaspopup` | `'dialog' \| 'menu' \| 'listbox' \| 'tree' \| 'grid' \| 'true' \| 'false'` | `'dialog'` | Value of `aria-haspopup`, matching the `role` of the panel |
109
+
110
+ Sets `aria-expanded`, `aria-controls` (while open) and `data-sbf-state`. When the container is `disabled`, a `button` gets `disabled`, other tags get `aria-disabled="true"` and `tabindex="-1"`. Your own `class`, attributes and listeners (`@click`, `@mouseenter`, …) are applied to the element; the click is not stopped.
111
+
112
+ ### `<SbFloating>`
113
+
114
+ The panel, teleported to `body`. It is rendered while open and stays mounted until its leave transition has finished.
115
+
116
+ | Prop | Type | Default | Description |
117
+ |--------------------|-------------------------|------------|-----------------------------------------------------------------------------------------------------|
118
+ | `as` | `string` | `'div'` | Tag of the panel element |
119
+ | `role` | `string` | `'dialog'` | ARIA role of the panel |
120
+ | `transition` | `string` | — | Name of a Vue `<Transition>` defined in your CSS. No animation when unset |
121
+ | `teleportTo` | `string \| HTMLElement` | `'body'` | Teleport target |
122
+ | `teleportDisabled` | `boolean` | `false` | Renders the panel in place |
123
+ | `zIndex` | `number \| string` | — | `z-index` of the positioned wrapper |
124
+ | `autoFocus` | `boolean` | `false` | Once positioned, focuses the first `[autofocus]` or focusable element inside, else the panel itself |
125
+
126
+ | Event | Description |
127
+ |---------------|----------------------------------------------------------------|
128
+ | `after-enter` | The enter transition has finished |
129
+ | `after-leave` | The leave transition has finished and the panel is removed |
130
+
131
+ When focus is inside the panel as it closes, it goes back to the reference.
132
+
133
+ ### Types
57
134
 
58
135
  ```ts
59
- interface FloatingSettings {
60
- placement: Placement; // e.g., 'bottom', 'top-start', etc.
61
- strategy: Strategy; // 'absolute' | 'fixed'
62
- offsetValue: number; // offset in pixels from the reference element
63
- hasArrow?: boolean; // enable arrow positioning
64
- hasResize?: boolean; // enable auto-resizing with anchor element
65
- }
136
+ import type {
137
+ SbContainerProps,
138
+ SbContainerSlotProps,
139
+ SbContainerExposed,
140
+ SbReferenceProps,
141
+ SbFloatingProps,
142
+ SbTrigger,
143
+ SbPlacement,
144
+ SbSide,
145
+ SbAlignment,
146
+ SbStrategy,
147
+ } from 'sb-floating-panel-vue';
66
148
  ```
67
149
 
68
150
  ---
69
151
 
70
- ## 🔍 Computed Values Explained
152
+ ## 🧭 How It Works
71
153
 
72
- ### `floatingStyles`
154
+ ### Shared state
73
155
 
74
- A Vue `computed` value containing the CSS style object that must be applied to your floating panel. It is dynamically calculated by `@floating-ui/vue` to maintain optimal placement relative to the reference element.
156
+ `SbContainer` owns the state (open state, positioning, ARIA ids) and shares it with its descendants through Vue `provide`/`inject`:
157
+
158
+ ```
159
+ SbContainer ── provides the state ──┬─▶ SbReference
160
+ └─▶ SbFloating
161
+ ```
75
162
 
76
- ### `floatingPosition`
163
+ - `SbReference` and `SbFloating` find their container on their own: you pass no refs, styles or handlers, and never write `.value`.
164
+ - The lookup follows the component tree, not the DOM, so it keeps working although `SbFloating` is teleported to `body`.
165
+ - They can sit at any depth inside the container slot, also inside your own components.
166
+ - Used outside an `SbContainer`, they throw: `[sb-floating-panel-vue] <SbFloating> must be used inside <SbContainer>.`
77
167
 
78
- The actual resolved placement string, e.g., `'bottom-start'`, useful for adding custom animations or behavior depending on where the panel is placed.
168
+ ### Nested containers
79
169
 
80
- ### `floatingArrowStyles`
170
+ An `SbContainer` placed inside another one (e.g. a submenu in a menu panel) registers with its parent:
81
171
 
82
- An object that includes styles for positioning the optional arrow element (enabled via the `hasArrow` setting). You can directly bind this to the arrow’s `style` attribute.
172
+ - a press inside the nested panel is not "outside" for the parent, so both stay open;
173
+ - Escape closes the innermost open panel first, then the next one;
174
+ - a press outside all of them closes them all.
175
+
176
+ ### From click to open panel
177
+
178
+ 1. **Click** on `SbReference` (or Enter/Space on a non-interactive tag). With `trigger="click"` and the container not `disabled`, the container toggles: it emits `update:open` and, without `v-model:open`, updates its own state.
179
+ 2. **Open state changes.** `SbReference` gets `aria-expanded="true"`, `aria-controls` and `data-sbf-state="open"`; the container starts listening for Escape and outside presses (only while open) and emits `open`.
180
+ 3. **The panel mounts.** `SbFloating` renders the wrapper in the teleport target and the panel inside it; the enter classes of your `transition`, if any, are applied to the panel.
181
+ 4. **Positioning.** Floating UI starts tracking the reference (scroll, resize, layout changes) and computes the position with `offset`, `flip`, `shift`, `size` and, with `hideWhenDetached`, `hide`. `size` writes the `--sbf-*` variables and applies `matchWidth`; the position is written on the wrapper.
182
+ 5. **Positioned.** With `autoFocus`, focus moves inside the panel now, not earlier, so the page does not jump to the panel's initial (0, 0) position.
183
+
184
+ Closing (click on the reference, `close()`, Escape, press outside, `v-model:open` set to `false`, or `disabled`):
185
+
186
+ 1. If focus is inside the panel, it goes back to the reference; the container emits `close`.
187
+ 2. The leave classes of your `transition` are applied; the wrapper stays mounted and positioned until the animation ends (immediately without `transition`).
188
+ 3. The wrapper is removed, Floating UI stops tracking, `SbFloating` emits `after-leave`.
83
189
 
84
190
  ---
85
191
 
86
- ## 🧪 Example
192
+ ## 🎨 Styling
87
193
 
88
- ```ts
89
- <script setup lang="ts">
90
- import { ref } from 'vue';
91
- import { useSbFloatingPanel } from 'sb-floating-panel-vue';
92
-
93
- const {
94
- reference,
95
- floating,
96
- popperArrow,
97
- isOpen,
98
- floatingStyles,
99
- floatingArrowStyles,
100
- changeFloatingVisibility,
101
- } = useSbFloatingPanel({
102
- placement: 'bottom',
103
- strategy: 'absolute',
104
- offsetValue: 8,
105
- hasArrow: true,
106
- hasResize: true,
107
- });
108
-
109
- const togglePanel = () => {
110
- changeFloatingVisibility(!isOpen.value);
111
- };
112
- </script>
113
- ```
194
+ The library writes values on the elements; your CSS reads them. `SbFloating` renders two elements:
114
195
 
115
- ```vue
116
- <template>
117
- <button ref="reference" @click="togglePanel">Toggle Panel</button>
118
- <div
119
- v-if="isOpen"
120
- ref="floating"
121
- :style="floatingStyles"
122
- class="popover"
123
- >
124
- I'm a floating panel!
125
- <div ref="popperArrow" :style="floatingArrowStyles.arrow" class="arrow" />
196
+ ```html
197
+ <!-- wrapper: positioning only (Floating UI styles, z-index, CSS variables) -->
198
+ <div style="position: absolute; transform: translate(120px, 40px); --sbf-reference-width: 180px; …">
199
+ <!-- panel: your class, style and attributes -->
200
+ <div class="my-panel" role="dialog" data-sbf-state="open" data-sbf-side="bottom" data-sbf-align="start">
201
+ …
126
202
  </div>
127
- </template>
203
+ </div>
128
204
  ```
129
205
 
130
- ---
206
+ Your styles, transitions and transforms go on the panel, so they never fight with the positioning `transform` of the wrapper.
131
207
 
132
- ## 🎨 Styling
208
+ ### Data attributes
209
+
210
+ | Attribute | Values | On | Use |
211
+ |------------------|--------------------------------------|--------------------------|---------------------------------------------|
212
+ | `data-sbf-state` | `open` / `closed` | reference | Open/closed styles (e.g. rotate an icon) |
213
+ | `data-sbf-state` | `open` | panel | Always `open` while rendered: style the exit with the leave classes of `transition` |
214
+ | `data-sbf-side` | `top` / `bottom` / `left` / `right` | panel | Real side after flip: directional animation |
215
+ | `data-sbf-align` | `start` / `end` / absent | panel | Alignment part of the placement |
216
+
217
+ ### CSS variables
218
+
219
+ Set on the wrapper and inherited by the panel:
133
220
 
134
- You can use your own CSS or utility classes (like Tailwind CSS) to style the panel and the arrow. Example:
221
+ | Variable | Meaning |
222
+ |--------------------------|------------------------------------------------------|
223
+ | `--sbf-reference-width` | Width of the reference |
224
+ | `--sbf-available-width` | Horizontal space left before overflowing the viewport |
225
+ | `--sbf-available-height` | Vertical space left before overflowing the viewport |
226
+
227
+ All attributes and variables use the `sbf` prefix, so they do not clash with other libraries in the same app (e.g. Radix / Reka UI `data-state`).
228
+
229
+ ### Plain CSS
230
+
231
+ Panels are teleported to `body`, so their styles cannot be `scoped` to the component that renders them: use global styles, CSS modules or utility classes.
135
232
 
136
233
  ```css
137
- .popover {
234
+ .my-panel {
138
235
  background: white;
139
- border: 1px solid #ccc;
140
- padding: 0.75rem;
141
- border-radius: 0.5rem;
142
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
236
+ border: 1px solid #ddd;
237
+ border-radius: 8px;
238
+ max-height: var(--sbf-available-height);
239
+ overflow-y: auto;
143
240
  }
144
241
 
145
- .arrow {
146
- width: 10px;
147
- height: 10px;
148
- background: white;
149
- transform: rotate(45deg);
150
- position: absolute;
242
+ .my-trigger[data-sbf-state='open'] .chevron {
243
+ transform: rotate(180deg);
244
+ }
245
+
246
+ /* <SbFloating transition="pop"> */
247
+ .pop-enter-active,
248
+ .pop-leave-active {
249
+ transition: opacity 150ms ease, transform 150ms ease;
151
250
  }
251
+ .pop-enter-from,
252
+ .pop-leave-to {
253
+ opacity: 0;
254
+ transform: scale(0.96);
255
+ }
256
+
257
+ /* direction from the side resolved after flip */
258
+ .pop-enter-from[data-sbf-side='bottom'] { transform: translateY(-6px); }
259
+ .pop-enter-from[data-sbf-side='top'] { transform: translateY(6px); }
260
+ ```
261
+
262
+ ### Tailwind (v4)
263
+
264
+ ```vue
265
+ <SbReference class="group rounded border px-3 py-2">
266
+ Options
267
+ <span class="inline-block transition-transform group-data-[sbf-state=open]:rotate-180">▾</span>
268
+ </SbReference>
269
+ <SbFloating class="max-h-(--sbf-available-height) w-(--sbf-reference-width) overflow-y-auto rounded border bg-white shadow-lg">
270
+ …
271
+ </SbFloating>
272
+ ```
273
+
274
+ ---
275
+
276
+ ## ♿ Accessibility
277
+
278
+ - `SbReference` gets `aria-expanded`, `aria-controls` (pointing to the panel `id`) and `aria-haspopup`; non-interactive tags get `role="button"`, `tabindex="0"` and Enter/Space.
279
+ - `SbFloating` gets an `id` generated with Vue `useId()` (SSR safe) and a `role` (`dialog` by default).
280
+ - Escape closes the innermost open panel; a press outside closes the panel. A container nested inside a panel counts as part of it.
281
+ - With `autoFocus`, focus moves inside the panel after it is positioned.
282
+ - When the panel closes with focus inside it, focus returns to the reference (with or without `autoFocus`).
283
+
284
+ ---
285
+
286
+ ## 🔀 Migrating from v2
287
+
288
+ v3 is a breaking release.
289
+
290
+ - **The composable is no longer exported.** Use the components; for full control use `@floating-ui/vue` directly.
291
+ - **No refs or styles in the slot.** Remove `reference-ref`, `floating-ref`, `is-open`, `floating-placement`, `floating-style` and `:on-click`: the components share their state.
292
+ - **No arrow.** `hasArrow`, `floating-arrow-ref`, `floating-arrow-style` and `arrow-dimensions` are gone.
293
+ - **No CSS.** Remove `import 'sb-floating-panel-vue/style.css'`. `animation` and `duration` are replaced by `transition`, the name of your own `<Transition>` classes.
294
+ - **Renamed props:** `offsetValue` → `offset` (default `15` → `8`), `hasResize` → `matchWidth`.
295
+ - **New defaults:** `padding: 8` from the viewport edges; Escape and click outside close the panel.
296
+ - `SbContainer` no longer renders a `<div>`; `SbReference` no longer stops click propagation and sets `type="button"`.
297
+
298
+ ```vue
299
+ <!-- v2 -->
300
+ <SbContainer :has-resize="true">
301
+ <template #default="{ reference, floating, floatingPlacement, floatingStyle, isOpen, toggle }">
302
+ <SbReference :reference-ref="reference" :on-click="toggle">Toggle</SbReference>
303
+ <SbFloating
304
+ :is-open="isOpen.value"
305
+ :floating-ref="floating"
306
+ :floating-placement="floatingPlacement.value"
307
+ :floating-style="floatingStyle.value"
308
+ >
309
+ Hello Panel!
310
+ </SbFloating>
311
+ </template>
312
+ </SbContainer>
313
+
314
+ <!-- v3 -->
315
+ <SbContainer match-width>
316
+ <SbReference>Toggle</SbReference>
317
+ <SbFloating>Hello Panel!</SbFloating>
318
+ </SbContainer>
152
319
  ```
153
320
 
154
321
  ---
322
+
323
+ ## ⚠️ Directive Note (Teleport Limitation)
324
+
325
+ `<SbFloating>` renders a `<teleport>` root, so a custom directive applied directly to the component cannot be inherited and Vue warns about it. Move the directive to an element inside the slot:
326
+
327
+ ```vue
328
+ <SbFloating>
329
+ <div v-my-directive>...</div>
330
+ </SbFloating>
331
+ ```
332
+
333
+ ---
334
+
335
+ ## 🛠️ Development
336
+
337
+ ```bash
338
+ npm install
339
+ npm run dev # playground at http://localhost:5173 (playground/App.vue)
340
+ npm run lint # ESLint
341
+ npm run check # type check of src/, playground/ and test/
342
+ npm test # Vitest + @vue/test-utils (happy-dom)
343
+ npm run test:coverage # tests with V8 coverage report in coverage/
344
+ npm run build # library build in dist/ (ES and UMD, with .d.ts)
345
+ ```
346
+
347
+ The playground imports the components from `src/`, so changes reload instantly. It is not part of the published package.
348
+
349
+ On every push to `main`, CI runs the tests with coverage and `scripts/coverage-badge.mjs` publishes the coverage badges (lines, statements, branches, functions) as SVG files on the `badges` branch, which the badges above read. No external service is involved.
350
+
351
+ ---
352
+
155
353
  ## 📄 License
156
354
 
157
- This project is licensed under the [MIT License](./LICENSE) © 2025 [Stefano Biddau](https://github.com/stefanBid)
355
+ [MIT License](https://github.com/stefanBid/sb-floating-panel-vue/blob/main/LICENSE) © [Stefano Biddau](https://www.stefano-biddau.com/)
@@ -0,0 +1,35 @@
1
+ import { SbContainerExposed, SbContainerProps, SbContainerSlotProps } from '../types';
2
+ declare const _default: typeof __VLS_export;
3
+ export default _default;
4
+ declare const __VLS_export: __VLS_WithSlots<import('vue').DefineComponent<Omit<SbContainerProps, "open"> & {
5
+ open?: boolean | undefined;
6
+ }, SbContainerExposed, {}, {}, {}, import('vue').ComponentOptionsMixin, import('vue').ComponentOptionsMixin, {
7
+ open: () => any;
8
+ "update:open": (value: boolean | undefined) => any;
9
+ close: () => any;
10
+ }, string, import('vue').PublicProps, Readonly<Omit<SbContainerProps, "open"> & {
11
+ open?: boolean | undefined;
12
+ }> & Readonly<{
13
+ onOpen?: (() => any) | undefined;
14
+ "onUpdate:open"?: ((value: boolean | undefined) => any) | undefined;
15
+ onClose?: (() => any) | undefined;
16
+ }>, {
17
+ placement: import('..').SbPlacement;
18
+ strategy: import('..').SbStrategy;
19
+ padding: number;
20
+ offset: number;
21
+ matchWidth: boolean | "min";
22
+ hideWhenDetached: boolean;
23
+ defaultOpen: boolean;
24
+ disabled: boolean;
25
+ trigger: import('..').SbTrigger;
26
+ closeOnEscape: boolean;
27
+ closeOnClickOutside: boolean;
28
+ }, {}, {}, {}, string, import('vue').ComponentProvideOptions, false, {}, any>, {
29
+ default?: (props: SbContainerSlotProps) => unknown;
30
+ }>;
31
+ type __VLS_WithSlots<T, S> = T & {
32
+ new (): {
33
+ $slots: S;
34
+ };
35
+ };
@@ -0,0 +1,28 @@
1
+ import { SbFloatingProps } from '../types';
2
+ declare var __VLS_22: {};
3
+ type __VLS_Slots = {} & {
4
+ default?: (props: typeof __VLS_22) => any;
5
+ };
6
+ declare const __VLS_base: import('vue').DefineComponent<SbFloatingProps, {}, {}, {}, {}, import('vue').ComponentOptionsMixin, import('vue').ComponentOptionsMixin, {
7
+ "after-enter": () => any;
8
+ "after-leave": () => any;
9
+ }, string, import('vue').PublicProps, Readonly<SbFloatingProps> & Readonly<{
10
+ "onAfter-enter"?: (() => any) | undefined;
11
+ "onAfter-leave"?: (() => any) | undefined;
12
+ }>, {
13
+ as: string;
14
+ role: string;
15
+ transition: string;
16
+ teleportTo: string | HTMLElement;
17
+ teleportDisabled: boolean;
18
+ zIndex: string | number;
19
+ autoFocus: boolean;
20
+ }, {}, {}, {}, string, import('vue').ComponentProvideOptions, false, {}, any>;
21
+ declare const __VLS_export: __VLS_WithSlots<typeof __VLS_base, __VLS_Slots>;
22
+ declare const _default: typeof __VLS_export;
23
+ export default _default;
24
+ type __VLS_WithSlots<T, S> = T & {
25
+ new (): {
26
+ $slots: S;
27
+ };
28
+ };
@@ -0,0 +1,17 @@
1
+ import { SbReferenceProps } from '../types';
2
+ declare var __VLS_10: {};
3
+ type __VLS_Slots = {} & {
4
+ default?: (props: typeof __VLS_10) => any;
5
+ };
6
+ declare const __VLS_base: import('vue').DefineComponent<SbReferenceProps, {}, {}, {}, {}, import('vue').ComponentOptionsMixin, import('vue').ComponentOptionsMixin, {}, string, import('vue').PublicProps, Readonly<SbReferenceProps> & Readonly<{}>, {
7
+ as: string;
8
+ ariaHaspopup: "dialog" | "menu" | "listbox" | "tree" | "grid" | "true" | "false";
9
+ }, {}, {}, {}, string, import('vue').ComponentProvideOptions, false, {}, any>;
10
+ declare const __VLS_export: __VLS_WithSlots<typeof __VLS_base, __VLS_Slots>;
11
+ declare const _default: typeof __VLS_export;
12
+ export default _default;
13
+ type __VLS_WithSlots<T, S> = T & {
14
+ new (): {
15
+ $slots: S;
16
+ };
17
+ };
@@ -0,0 +1,31 @@
1
+ import { ComputedRef, CSSProperties, MaybeRefOrGetter, Ref } from 'vue';
2
+ import { SbAlignment, SbPlacement, SbSide, SbStrategy } from '../types';
3
+ /**
4
+ * Options of the internal `useSbFloatingPanel` composable. Every option can be a value,
5
+ * a ref or a getter, so `SbContainer` props stay reactive.
6
+ */
7
+ export interface FloatingPanelOptions {
8
+ isOpen: MaybeRefOrGetter<boolean>;
9
+ placement: MaybeRefOrGetter<SbPlacement>;
10
+ strategy: MaybeRefOrGetter<SbStrategy>;
11
+ offset: MaybeRefOrGetter<number>;
12
+ padding: MaybeRefOrGetter<number>;
13
+ matchWidth: MaybeRefOrGetter<boolean | 'min'>;
14
+ hideWhenDetached: MaybeRefOrGetter<boolean>;
15
+ }
16
+ export interface FloatingPanel {
17
+ reference: Ref<HTMLElement | null>;
18
+ floating: Ref<HTMLElement | null>;
19
+ /** Positioning styles for the outer wrapper (Floating UI styles + `visibility` from `hide`). */
20
+ floatingStyles: ComputedRef<CSSProperties>;
21
+ /** Placement resolved after `flip`. */
22
+ placement: Readonly<Ref<SbPlacement>>;
23
+ side: ComputedRef<SbSide>;
24
+ align: ComputedRef<SbAlignment | undefined>;
25
+ isPositioned: Readonly<Ref<boolean>>;
26
+ update: () => void;
27
+ }
28
+ /**
29
+ * Wraps Floating UI for the components. Not part of the public API.
30
+ */
31
+ export declare function useSbFloatingPanel(options: FloatingPanelOptions): FloatingPanel;
@@ -0,0 +1,23 @@
1
+ import { InjectionKey, Ref } from 'vue';
2
+ import { FloatingPanel } from './composables/useSbFloatingPanel';
3
+ import { SbTrigger } from './types';
4
+ /**
5
+ * State shared by `SbContainer` with `SbReference` and `SbFloating`.
6
+ */
7
+ export interface SbFloatingContext extends FloatingPanel {
8
+ isOpen: Readonly<Ref<boolean>>;
9
+ disabled: Readonly<Ref<boolean>>;
10
+ trigger: Readonly<Ref<SbTrigger>>;
11
+ floatingId: string;
12
+ open: () => void;
13
+ close: () => void;
14
+ toggle: () => void;
15
+ /** Whether an event target is inside the reference, the panel or a nested container. */
16
+ contains: (target: EventTarget | null) => boolean;
17
+ /** Whether a nested container is open. */
18
+ hasOpenChild: () => boolean;
19
+ /** Registers a nested container; returns the function that unregisters it. */
20
+ registerChild: (child: SbFloatingContext) => () => void;
21
+ }
22
+ export declare const sbFloatingContextKey: InjectionKey<SbFloatingContext>;
23
+ export declare function useSbFloatingContext(component: string): SbFloatingContext;
package/dist/index.d.ts CHANGED
@@ -1 +1,4 @@
1
- export { useSbFloatingPanel } from './use-sb-floating-panel/useSbFloatingPanel';
1
+ export { default as SbContainer } from './components/SbContainer.vue';
2
+ export { default as SbReference } from './components/SbReference.vue';
3
+ export { default as SbFloating } from './components/SbFloating.vue';
4
+ export type { SbContainerProps, SbContainerSlotProps, SbContainerExposed, SbReferenceProps, SbFloatingProps, SbTrigger, SbPlacement, SbSide, SbAlignment, SbStrategy, } from './types';
@@ -1,45 +1,270 @@
1
- import { useFloating as m, autoUpdate as d, offset as w, flip as b, shift as y, arrow as z } from "@floating-ui/vue";
2
- import { ref as t, watch as f, onMounted as O, onUnmounted as R, nextTick as g } from "vue";
3
- function F(a) {
4
- const l = t(null), n = t(null), u = t(null), r = t(!1), o = t(null);
5
- let i = !1;
6
- const { floatingStyles: c, placement: v, middlewareData: p } = m(l, n, {
7
- open: r,
8
- placement: a.placement,
9
- strategy: a.strategy,
10
- transform: !1,
11
- middleware: [
12
- w(a.offsetValue),
13
- b(),
14
- y(),
15
- ...a.hasArrow ? [z({ element: u })] : []
16
- ],
17
- whileElementsMounted: d
18
- }), h = (e) => {
19
- r.value = e;
20
- }, s = () => {
21
- n.value && l.value && (n.value.style.width = `${l.value.offsetWidth}px`);
22
- };
23
- return f(r, async (e) => {
24
- e && a.hasResize && i && (await g(), s());
25
- }), f(l, (e) => {
26
- e && a.hasResize && !i && (i = !0, o.value = new ResizeObserver(s), o.value.observe(e));
27
- }), O(() => {
28
- a.hasResize && l.value && (i = !0, o.value = new ResizeObserver(s), o.value.observe(l.value));
29
- }), R(() => {
30
- var e;
31
- a.hasResize && ((e = o.value) == null || e.disconnect());
32
- }), {
33
- reference: l,
34
- floating: n,
35
- popperArrow: u,
36
- isOpen: r,
37
- floatingPosition: v,
38
- floatingStyles: c,
39
- floatingArrowStyles: p,
40
- changeFloatingVisibility: h
41
- };
1
+ import { Teleport as e, Transition as t, computed as n, createBlock as r, createCommentVNode as i, createElementBlock as a, createVNode as o, defineComponent as s, inject as c, mergeModels as l, mergeProps as u, nextTick as d, normalizeStyle as f, onScopeDispose as p, openBlock as m, provide as h, ref as g, renderSlot as _, resolveDynamicComponent as v, toRef as y, toValue as b, unref as x, useId as S, useModel as C, watch as w, withCtx as T } from "vue";
2
+ import { autoUpdate as E, flip as D, hide as O, limitShift as k, offset as A, shift as j, size as M, useFloating as N } from "@floating-ui/vue";
3
+ //#region src/composables/useSbFloatingPanel.ts
4
+ var P = (e) => `${Math.max(0, e)}px`;
5
+ function F(e) {
6
+ let t = g(null), r = g(null), i = n(() => {
7
+ let t = b(e.padding), n = b(e.matchWidth), r = b(e.placement).includes("-"), i = D({
8
+ padding: t,
9
+ crossAxis: "alignment",
10
+ fallbackAxisSideDirection: "end"
11
+ }), a = j({
12
+ padding: t,
13
+ limiter: k()
14
+ }), o = [
15
+ A(b(e.offset)),
16
+ ...r ? [i, a] : [a, i],
17
+ M({
18
+ padding: t,
19
+ apply({ rects: e, elements: t, availableWidth: r, availableHeight: i }) {
20
+ let { style: a } = t.floating, o = P(e.reference.width);
21
+ a.setProperty("--sbf-reference-width", o), a.setProperty("--sbf-available-width", P(r)), a.setProperty("--sbf-available-height", P(i)), a.width = n === !0 ? o : "", a.minWidth = n === "min" ? o : "";
22
+ }
23
+ })
24
+ ];
25
+ return b(e.hideWhenDetached) && o.push(O({ padding: t })), o;
26
+ }), { floatingStyles: a, middlewareData: o, placement: s, isPositioned: c, update: l } = N(t, r, {
27
+ open: () => b(e.isOpen),
28
+ placement: () => b(e.placement),
29
+ strategy: () => b(e.strategy),
30
+ middleware: i,
31
+ whileElementsMounted: E
32
+ });
33
+ return {
34
+ reference: t,
35
+ floating: r,
36
+ floatingStyles: n(() => ({
37
+ ...a.value,
38
+ ...o.value.hide?.referenceHidden ? { visibility: "hidden" } : {}
39
+ })),
40
+ placement: s,
41
+ side: n(() => s.value.split("-")[0]),
42
+ align: n(() => s.value.split("-")[1]),
43
+ isPositioned: c,
44
+ update: l
45
+ };
42
46
  }
43
- export {
44
- F as useSbFloatingPanel
45
- };
47
+ //#endregion
48
+ //#region src/context.ts
49
+ var I = Symbol("SbFloatingContext");
50
+ function L(e) {
51
+ let t = c(I, null);
52
+ if (!t) throw Error(`[sb-floating-panel-vue] <${e}> must be used inside <SbContainer>.`);
53
+ return t;
54
+ }
55
+ //#endregion
56
+ //#region src/components/SbContainer.vue?vue&type=script&setup=true&lang.ts
57
+ var R = /* @__PURE__ */ new WeakSet(), z = /* @__PURE__ */ s({
58
+ inheritAttrs: !1,
59
+ __name: "SbContainer",
60
+ props: /*@__PURE__*/ l({
61
+ placement: { default: "bottom-start" },
62
+ strategy: { default: "absolute" },
63
+ offset: { default: 8 },
64
+ padding: { default: 8 },
65
+ matchWidth: {
66
+ type: [Boolean, String],
67
+ default: !1
68
+ },
69
+ hideWhenDetached: {
70
+ type: Boolean,
71
+ default: !1
72
+ },
73
+ defaultOpen: {
74
+ type: Boolean,
75
+ default: !1
76
+ },
77
+ disabled: {
78
+ type: Boolean,
79
+ default: !1
80
+ },
81
+ trigger: { default: "click" },
82
+ closeOnEscape: {
83
+ type: Boolean,
84
+ default: !0
85
+ },
86
+ closeOnClickOutside: {
87
+ type: Boolean,
88
+ default: !0
89
+ }
90
+ }, {
91
+ open: {
92
+ type: Boolean,
93
+ default: void 0
94
+ },
95
+ openModifiers: {}
96
+ }),
97
+ emits: /*@__PURE__*/ l(["open", "close"], ["update:open"]),
98
+ setup(e, { expose: t, emit: r }) {
99
+ let i = e, a = C(e, "open"), o = r, s = n(() => !i.disabled && (a.value ?? i.defaultOpen)), l = (e) => {
100
+ e && i.disabled || e !== s.value && (a.value = e);
101
+ }, u = () => l(!0), d = () => l(!1), f = () => l(!s.value), m = F({
102
+ isOpen: s,
103
+ placement: () => i.placement,
104
+ strategy: () => i.strategy,
105
+ offset: () => i.offset,
106
+ padding: () => i.padding,
107
+ matchWidth: () => i.matchWidth,
108
+ hideWhenDetached: () => i.hideWhenDetached
109
+ }), g = /* @__PURE__ */ new Set(), v = {
110
+ ...m,
111
+ isOpen: s,
112
+ disabled: y(i, "disabled"),
113
+ trigger: y(i, "trigger"),
114
+ floatingId: `${S()}-floating`,
115
+ open: u,
116
+ close: d,
117
+ toggle: f,
118
+ contains: (e) => e instanceof Node ? m.reference.value?.contains(e) || m.floating.value?.contains(e) ? !0 : [...g].some((t) => t.contains(e)) : !1,
119
+ hasOpenChild: () => [...g].some((e) => e.isOpen.value || e.hasOpenChild()),
120
+ registerChild: (e) => (g.add(e), () => g.delete(e))
121
+ }, b = c(I, null);
122
+ b && p(b.registerChild(v)), h(I, v);
123
+ let T = (e) => {
124
+ e.key === "Escape" && i.closeOnEscape && !v.hasOpenChild() && (R.has(e) || (R.add(e), d()));
125
+ }, E = (e) => {
126
+ i.closeOnClickOutside && (v.contains(e.composedPath()[0] ?? e.target) || d());
127
+ };
128
+ return w(s, (e, t, n) => {
129
+ e && typeof document < "u" && (document.addEventListener("keydown", T), document.addEventListener("pointerdown", E, !0), n(() => {
130
+ document.removeEventListener("keydown", T), document.removeEventListener("pointerdown", E, !0);
131
+ }));
132
+ }, { immediate: !0 }), w(s, (e) => o(e ? "open" : "close")), w(() => i.disabled, (e) => {
133
+ e && a.value && (a.value = !1);
134
+ }), t({
135
+ open: u,
136
+ close: d,
137
+ toggle: f,
138
+ update: m.update
139
+ }), (e, t) => _(e.$slots, "default", {
140
+ isOpen: s.value,
141
+ open: u,
142
+ close: d,
143
+ toggle: f,
144
+ placement: x(m).placement.value,
145
+ side: x(m).side.value
146
+ });
147
+ }
148
+ }), B = /* @__PURE__ */ s({
149
+ __name: "SbReference",
150
+ props: {
151
+ as: { default: "button" },
152
+ ariaHaspopup: { default: "dialog" }
153
+ },
154
+ setup(e) {
155
+ let t = e, i = L("SbReference"), { isOpen: a, disabled: o, floatingId: s } = i, c = n(() => t.as === "button" || t.as === "a"), l = (e) => {
156
+ i.reference.value = e instanceof HTMLElement ? e : null;
157
+ }, u = () => {
158
+ i.trigger.value === "click" && i.toggle();
159
+ }, d = (e) => {
160
+ c.value || e.key !== "Enter" && e.key !== " " || (e.preventDefault(), u());
161
+ };
162
+ return (e, n) => (m(), r(v(t.as), {
163
+ ref: l,
164
+ type: t.as === "button" ? "button" : void 0,
165
+ role: c.value ? void 0 : "button",
166
+ tabindex: c.value ? void 0 : x(o) ? -1 : 0,
167
+ disabled: t.as === "button" && x(o) ? !0 : void 0,
168
+ "aria-disabled": !c.value && x(o) ? "true" : void 0,
169
+ "aria-haspopup": t.ariaHaspopup,
170
+ "aria-expanded": x(a) ? "true" : "false",
171
+ "aria-controls": x(a) ? x(s) : void 0,
172
+ "data-sbf-state": x(a) ? "open" : "closed",
173
+ onClick: u,
174
+ onKeydown: d
175
+ }, {
176
+ default: T(() => [_(e.$slots, "default")]),
177
+ _: 3
178
+ }, 40, [
179
+ "type",
180
+ "role",
181
+ "tabindex",
182
+ "disabled",
183
+ "aria-disabled",
184
+ "aria-haspopup",
185
+ "aria-expanded",
186
+ "aria-controls",
187
+ "data-sbf-state"
188
+ ]));
189
+ }
190
+ }), V = "a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex=\"-1\"]), [contenteditable=\"true\"]", H = /* @__PURE__ */ s({
191
+ inheritAttrs: !1,
192
+ __name: "SbFloating",
193
+ props: {
194
+ as: { default: "div" },
195
+ role: { default: "dialog" },
196
+ transition: { default: void 0 },
197
+ teleportTo: { default: "body" },
198
+ teleportDisabled: {
199
+ type: Boolean,
200
+ default: !1
201
+ },
202
+ zIndex: { default: void 0 },
203
+ autoFocus: {
204
+ type: Boolean,
205
+ default: !1
206
+ }
207
+ },
208
+ emits: ["after-enter", "after-leave"],
209
+ setup(s, { emit: c }) {
210
+ let l = s, p = c, h = L("SbFloating"), { isOpen: y, floatingId: b, side: S, align: C } = h, E = g(y.value), D = g(null), O = (e) => {
211
+ h.floating.value = e instanceof HTMLElement ? e : null;
212
+ }, k = (e) => {
213
+ D.value = e instanceof HTMLElement ? e : null;
214
+ }, A = n(() => ({
215
+ ...h.floatingStyles.value,
216
+ ...l.zIndex === void 0 ? {} : { zIndex: l.zIndex }
217
+ })), j = () => p("after-enter"), M = () => {
218
+ y.value || (E.value = !1), p("after-leave");
219
+ }, N = () => {
220
+ let e = D.value;
221
+ e && (e.querySelector("[autofocus]") ?? e.querySelector(V) ?? e).focus({ preventScroll: !0 });
222
+ };
223
+ return w(y, (e) => {
224
+ if (e) {
225
+ E.value = !0;
226
+ return;
227
+ }
228
+ let t = document.activeElement;
229
+ t && h.floating.value?.contains(t) && h.reference.value?.focus();
230
+ }), w(() => y.value && h.isPositioned.value, async (e) => {
231
+ e && l.autoFocus && (await d(), N());
232
+ }), (n, s) => (m(), r(e, {
233
+ to: l.teleportTo,
234
+ disabled: l.teleportDisabled
235
+ }, [E.value ? (m(), a("div", {
236
+ key: 0,
237
+ ref: O,
238
+ style: f(A.value)
239
+ }, [o(t, {
240
+ name: l.transition,
241
+ css: !!l.transition,
242
+ appear: "",
243
+ onAfterEnter: j,
244
+ onAfterLeave: M
245
+ }, {
246
+ default: T(() => [x(y) ? (m(), r(v(l.as), u({ key: 0 }, n.$attrs, {
247
+ id: x(b),
248
+ ref: k,
249
+ role: l.role,
250
+ tabindex: l.autoFocus ? -1 : void 0,
251
+ "data-sbf-state": x(y) ? "open" : "closed",
252
+ "data-sbf-side": x(S),
253
+ "data-sbf-align": x(C)
254
+ }), {
255
+ default: T(() => [_(n.$slots, "default")]),
256
+ _: 3
257
+ }, 16, [
258
+ "id",
259
+ "role",
260
+ "tabindex",
261
+ "data-sbf-state",
262
+ "data-sbf-side",
263
+ "data-sbf-align"
264
+ ])) : i("", !0)]),
265
+ _: 3
266
+ }, 8, ["name", "css"])], 4)) : i("", !0)], 8, ["to", "disabled"]));
267
+ }
268
+ });
269
+ //#endregion
270
+ export { z as SbContainer, H as SbFloating, B as SbReference };
@@ -1 +1 @@
1
- (function(i,e){typeof exports=="object"&&typeof module<"u"?e(exports,require("@floating-ui/vue"),require("vue")):typeof define=="function"&&define.amd?define(["exports","@floating-ui/vue","vue"],e):(i=typeof globalThis<"u"?globalThis:i||self,e(i.SbFloatingPanel={},i.FloatingUIVue,i.Vue))})(this,function(i,e,n){"use strict";function d(o){const a=n.ref(null),r=n.ref(null),c=n.ref(null),s=n.ref(!1),l=n.ref(null);let f=!1;const{floatingStyles:p,placement:h,middlewareData:m}=e.useFloating(a,r,{open:s,placement:o.placement,strategy:o.strategy,transform:!1,middleware:[e.offset(o.offsetValue),e.flip(),e.shift(),...o.hasArrow?[e.arrow({element:c})]:[]],whileElementsMounted:e.autoUpdate}),w=t=>{s.value=t},u=()=>{r.value&&a.value&&(r.value.style.width=`${a.value.offsetWidth}px`)};return n.watch(s,async t=>{t&&o.hasResize&&f&&(await n.nextTick(),u())}),n.watch(a,t=>{t&&o.hasResize&&!f&&(f=!0,l.value=new ResizeObserver(u),l.value.observe(t))}),n.onMounted(()=>{o.hasResize&&a.value&&(f=!0,l.value=new ResizeObserver(u),l.value.observe(a.value))}),n.onUnmounted(()=>{var t;o.hasResize&&((t=l.value)==null||t.disconnect())}),{reference:a,floating:r,popperArrow:c,isOpen:s,floatingPosition:h,floatingStyles:p,floatingArrowStyles:m,changeFloatingVisibility:w}}i.useSbFloatingPanel=d,Object.defineProperty(i,Symbol.toStringTag,{value:"Module"})});
1
+ (function(e,t){typeof exports==`object`&&typeof module<`u`?t(exports,require("vue"),require("@floating-ui/vue")):typeof define==`function`&&define.amd?define([`exports`,`vue`,`@floating-ui/vue`],t):(e=typeof globalThis<`u`?globalThis:e||self,t(e.SbFloatingPanelVue={},e.Vue,e.FloatingUIVue))})(this,function(e,t,n){Object.defineProperty(e,Symbol.toStringTag,{value:`Module`});var r=e=>`${Math.max(0,e)}px`;function i(e){let i=(0,t.ref)(null),a=(0,t.ref)(null),o=(0,t.computed)(()=>{let i=(0,t.toValue)(e.padding),a=(0,t.toValue)(e.matchWidth),o=(0,t.toValue)(e.placement).includes(`-`),s=(0,n.flip)({padding:i,crossAxis:`alignment`,fallbackAxisSideDirection:`end`}),c=(0,n.shift)({padding:i,limiter:(0,n.limitShift)()}),l=[(0,n.offset)((0,t.toValue)(e.offset)),...o?[s,c]:[c,s],(0,n.size)({padding:i,apply({rects:e,elements:t,availableWidth:n,availableHeight:i}){let{style:o}=t.floating,s=r(e.reference.width);o.setProperty(`--sbf-reference-width`,s),o.setProperty(`--sbf-available-width`,r(n)),o.setProperty(`--sbf-available-height`,r(i)),o.width=a===!0?s:``,o.minWidth=a===`min`?s:``}})];return(0,t.toValue)(e.hideWhenDetached)&&l.push((0,n.hide)({padding:i})),l}),{floatingStyles:s,middlewareData:c,placement:l,isPositioned:u,update:d}=(0,n.useFloating)(i,a,{open:()=>(0,t.toValue)(e.isOpen),placement:()=>(0,t.toValue)(e.placement),strategy:()=>(0,t.toValue)(e.strategy),middleware:o,whileElementsMounted:n.autoUpdate});return{reference:i,floating:a,floatingStyles:(0,t.computed)(()=>({...s.value,...c.value.hide?.referenceHidden?{visibility:`hidden`}:{}})),placement:l,side:(0,t.computed)(()=>l.value.split(`-`)[0]),align:(0,t.computed)(()=>l.value.split(`-`)[1]),isPositioned:u,update:d}}var a=Symbol(`SbFloatingContext`);function o(e){let n=(0,t.inject)(a,null);if(!n)throw Error(`[sb-floating-panel-vue] <${e}> must be used inside <SbContainer>.`);return n}var s=new WeakSet,c=(0,t.defineComponent)({inheritAttrs:!1,__name:`SbContainer`,props:(0,t.mergeModels)({placement:{default:`bottom-start`},strategy:{default:`absolute`},offset:{default:8},padding:{default:8},matchWidth:{type:[Boolean,String],default:!1},hideWhenDetached:{type:Boolean,default:!1},defaultOpen:{type:Boolean,default:!1},disabled:{type:Boolean,default:!1},trigger:{default:`click`},closeOnEscape:{type:Boolean,default:!0},closeOnClickOutside:{type:Boolean,default:!0}},{open:{type:Boolean,default:void 0},openModifiers:{}}),emits:(0,t.mergeModels)([`open`,`close`],[`update:open`]),setup(e,{expose:n,emit:r}){let o=e,c=(0,t.useModel)(e,`open`),l=r,u=(0,t.computed)(()=>!o.disabled&&(c.value??o.defaultOpen)),d=e=>{e&&o.disabled||e!==u.value&&(c.value=e)},f=()=>d(!0),p=()=>d(!1),m=()=>d(!u.value),h=i({isOpen:u,placement:()=>o.placement,strategy:()=>o.strategy,offset:()=>o.offset,padding:()=>o.padding,matchWidth:()=>o.matchWidth,hideWhenDetached:()=>o.hideWhenDetached}),g=new Set,_={...h,isOpen:u,disabled:(0,t.toRef)(o,`disabled`),trigger:(0,t.toRef)(o,`trigger`),floatingId:`${(0,t.useId)()}-floating`,open:f,close:p,toggle:m,contains:e=>e instanceof Node?h.reference.value?.contains(e)||h.floating.value?.contains(e)?!0:[...g].some(t=>t.contains(e)):!1,hasOpenChild:()=>[...g].some(e=>e.isOpen.value||e.hasOpenChild()),registerChild:e=>(g.add(e),()=>g.delete(e))},v=(0,t.inject)(a,null);v&&(0,t.onScopeDispose)(v.registerChild(_)),(0,t.provide)(a,_);let y=e=>{e.key===`Escape`&&o.closeOnEscape&&!_.hasOpenChild()&&(s.has(e)||(s.add(e),p()))},b=e=>{o.closeOnClickOutside&&(_.contains(e.composedPath()[0]??e.target)||p())};return(0,t.watch)(u,(e,t,n)=>{e&&typeof document<`u`&&(document.addEventListener(`keydown`,y),document.addEventListener(`pointerdown`,b,!0),n(()=>{document.removeEventListener(`keydown`,y),document.removeEventListener(`pointerdown`,b,!0)}))},{immediate:!0}),(0,t.watch)(u,e=>l(e?`open`:`close`)),(0,t.watch)(()=>o.disabled,e=>{e&&c.value&&(c.value=!1)}),n({open:f,close:p,toggle:m,update:h.update}),(e,n)=>(0,t.renderSlot)(e.$slots,`default`,{isOpen:u.value,open:f,close:p,toggle:m,placement:(0,t.unref)(h).placement.value,side:(0,t.unref)(h).side.value})}}),l=(0,t.defineComponent)({__name:`SbReference`,props:{as:{default:`button`},ariaHaspopup:{default:`dialog`}},setup(e){let n=e,r=o(`SbReference`),{isOpen:i,disabled:a,floatingId:s}=r,c=(0,t.computed)(()=>n.as===`button`||n.as===`a`),l=e=>{r.reference.value=e instanceof HTMLElement?e:null},u=()=>{r.trigger.value===`click`&&r.toggle()},d=e=>{c.value||e.key!==`Enter`&&e.key!==` `||(e.preventDefault(),u())};return(e,r)=>((0,t.openBlock)(),(0,t.createBlock)((0,t.resolveDynamicComponent)(n.as),{ref:l,type:n.as===`button`?`button`:void 0,role:c.value?void 0:`button`,tabindex:c.value?void 0:(0,t.unref)(a)?-1:0,disabled:n.as===`button`&&(0,t.unref)(a)?!0:void 0,"aria-disabled":!c.value&&(0,t.unref)(a)?`true`:void 0,"aria-haspopup":n.ariaHaspopup,"aria-expanded":(0,t.unref)(i)?`true`:`false`,"aria-controls":(0,t.unref)(i)?(0,t.unref)(s):void 0,"data-sbf-state":(0,t.unref)(i)?`open`:`closed`,onClick:u,onKeydown:d},{default:(0,t.withCtx)(()=>[(0,t.renderSlot)(e.$slots,`default`)]),_:3},40,[`type`,`role`,`tabindex`,`disabled`,`aria-disabled`,`aria-haspopup`,`aria-expanded`,`aria-controls`,`data-sbf-state`]))}}),u=`a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"]), [contenteditable="true"]`,d=(0,t.defineComponent)({inheritAttrs:!1,__name:`SbFloating`,props:{as:{default:`div`},role:{default:`dialog`},transition:{default:void 0},teleportTo:{default:`body`},teleportDisabled:{type:Boolean,default:!1},zIndex:{default:void 0},autoFocus:{type:Boolean,default:!1}},emits:[`after-enter`,`after-leave`],setup(e,{emit:n}){let r=e,i=n,a=o(`SbFloating`),{isOpen:s,floatingId:c,side:l,align:d}=a,f=(0,t.ref)(s.value),p=(0,t.ref)(null),m=e=>{a.floating.value=e instanceof HTMLElement?e:null},h=e=>{p.value=e instanceof HTMLElement?e:null},g=(0,t.computed)(()=>({...a.floatingStyles.value,...r.zIndex===void 0?{}:{zIndex:r.zIndex}})),_=()=>i(`after-enter`),v=()=>{s.value||(f.value=!1),i(`after-leave`)},y=()=>{let e=p.value;e&&(e.querySelector(`[autofocus]`)??e.querySelector(u)??e).focus({preventScroll:!0})};return(0,t.watch)(s,e=>{if(e){f.value=!0;return}let t=document.activeElement;t&&a.floating.value?.contains(t)&&a.reference.value?.focus()}),(0,t.watch)(()=>s.value&&a.isPositioned.value,async e=>{e&&r.autoFocus&&(await(0,t.nextTick)(),y())}),(e,n)=>((0,t.openBlock)(),(0,t.createBlock)(t.Teleport,{to:r.teleportTo,disabled:r.teleportDisabled},[f.value?((0,t.openBlock)(),(0,t.createElementBlock)(`div`,{key:0,ref:m,style:(0,t.normalizeStyle)(g.value)},[(0,t.createVNode)(t.Transition,{name:r.transition,css:!!r.transition,appear:``,onAfterEnter:_,onAfterLeave:v},{default:(0,t.withCtx)(()=>[(0,t.unref)(s)?((0,t.openBlock)(),(0,t.createBlock)((0,t.resolveDynamicComponent)(r.as),(0,t.mergeProps)({key:0},e.$attrs,{id:(0,t.unref)(c),ref:h,role:r.role,tabindex:r.autoFocus?-1:void 0,"data-sbf-state":(0,t.unref)(s)?`open`:`closed`,"data-sbf-side":(0,t.unref)(l),"data-sbf-align":(0,t.unref)(d)}),{default:(0,t.withCtx)(()=>[(0,t.renderSlot)(e.$slots,`default`)]),_:3},16,[`id`,`role`,`tabindex`,`data-sbf-state`,`data-sbf-side`,`data-sbf-align`])):(0,t.createCommentVNode)(``,!0)]),_:3},8,[`name`,`css`])],4)):(0,t.createCommentVNode)(``,!0)],8,[`to`,`disabled`]))}});e.SbContainer=c,e.SbFloating=d,e.SbReference=l});
@@ -0,0 +1,169 @@
1
+ /** Side of the reference the panel is placed on. */
2
+ export type SbSide = 'top' | 'right' | 'bottom' | 'left';
3
+ /** Alignment of the panel along the side. */
4
+ export type SbAlignment = 'start' | 'end';
5
+ /** Placement of the panel relative to the reference (same values as Floating UI). */
6
+ export type SbPlacement = SbSide | `${SbSide}-${SbAlignment}`;
7
+ /** CSS position strategy of the panel. */
8
+ export type SbStrategy = 'absolute' | 'fixed';
9
+ /**
10
+ * How the reference opens the floating panel.
11
+ * - `click`: clicking (or pressing Enter/Space on) `SbReference` toggles the panel.
12
+ * - `manual`: the panel opens only through `v-model:open`, the slot props or the exposed methods.
13
+ */
14
+ export type SbTrigger = 'click' | 'manual';
15
+ /**
16
+ * Props for the `SbContainer` component: positioning, open state and interaction.
17
+ */
18
+ export interface SbContainerProps {
19
+ /**
20
+ * Preferred placement of the floating panel relative to the reference.
21
+ * The panel flips to the opposite side when there is not enough space.
22
+ * @default 'bottom-start'
23
+ */
24
+ placement?: SbPlacement;
25
+ /**
26
+ * CSS position strategy of the floating panel.
27
+ * Use `fixed` when the reference lives inside a scroll container or a fixed layout.
28
+ * @default 'absolute'
29
+ */
30
+ strategy?: SbStrategy;
31
+ /**
32
+ * Distance in pixels between the reference and the floating panel.
33
+ * @default 8
34
+ */
35
+ offset?: number;
36
+ /**
37
+ * Minimum distance in pixels between the floating panel and the viewport edges.
38
+ * @default 8
39
+ */
40
+ padding?: number;
41
+ /**
42
+ * Makes the floating panel as wide as the reference.
43
+ * - `true`: sets `width` to the reference width.
44
+ * - `'min'`: sets `min-width` to the reference width, so the panel can grow wider.
45
+ * - `false`: no width is set; `--sbf-reference-width` is still available to your CSS.
46
+ * @default false
47
+ */
48
+ matchWidth?: boolean | 'min';
49
+ /**
50
+ * Hides the floating panel (`visibility: hidden`) when the reference is scrolled out of view.
51
+ * @default false
52
+ */
53
+ hideWhenDetached?: boolean;
54
+ /**
55
+ * Open state, bound with `v-model:open`. Leave it unset to let the container manage it.
56
+ */
57
+ open?: boolean;
58
+ /**
59
+ * Initial open state when `v-model:open` is not used.
60
+ * @default false
61
+ */
62
+ defaultOpen?: boolean;
63
+ /**
64
+ * Prevents the panel from opening and closes it if it is open.
65
+ * @default false
66
+ */
67
+ disabled?: boolean;
68
+ /**
69
+ * How the reference opens the panel.
70
+ * @default 'click'
71
+ */
72
+ trigger?: SbTrigger;
73
+ /**
74
+ * Closes the panel when the Escape key is pressed.
75
+ * @default true
76
+ */
77
+ closeOnEscape?: boolean;
78
+ /**
79
+ * Closes the panel on a pointer press outside the reference and the panel.
80
+ * @default true
81
+ */
82
+ closeOnClickOutside?: boolean;
83
+ }
84
+ /**
85
+ * Props passed to the default slot of `SbContainer`.
86
+ */
87
+ export interface SbContainerSlotProps {
88
+ /** Whether the panel is open. */
89
+ isOpen: boolean;
90
+ /** Opens the panel. */
91
+ open: () => void;
92
+ /** Closes the panel. */
93
+ close: () => void;
94
+ /** Toggles the panel. */
95
+ toggle: () => void;
96
+ /** Placement resolved by Floating UI, after `flip`. */
97
+ placement: SbPlacement;
98
+ /** Side of the resolved placement. */
99
+ side: SbSide;
100
+ }
101
+ /**
102
+ * Methods exposed by `SbContainer` through a template ref.
103
+ */
104
+ export interface SbContainerExposed {
105
+ /** Opens the panel. */
106
+ open: () => void;
107
+ /** Closes the panel. */
108
+ close: () => void;
109
+ /** Toggles the panel. */
110
+ toggle: () => void;
111
+ /** Recomputes the panel position. */
112
+ update: () => void;
113
+ }
114
+ /**
115
+ * Props for the `SbReference` component, the element that anchors and toggles the panel.
116
+ */
117
+ export interface SbReferenceProps {
118
+ /**
119
+ * HTML tag to render. A `button` gets `type="button"`; any tag other than `button` and `a`
120
+ * gets `role="button"`, `tabindex="0"` and Enter/Space handling.
121
+ * @default 'button'
122
+ */
123
+ as?: string;
124
+ /**
125
+ * Value of `aria-haspopup`, matching the `role` of `SbFloating`.
126
+ * @default 'dialog'
127
+ */
128
+ ariaHaspopup?: 'dialog' | 'menu' | 'listbox' | 'tree' | 'grid' | 'true' | 'false';
129
+ }
130
+ /**
131
+ * Props for the `SbFloating` component, the teleported floating panel.
132
+ */
133
+ export interface SbFloatingProps {
134
+ /**
135
+ * HTML tag of the panel element (the one that receives your `class`, `style` and attributes).
136
+ * @default 'div'
137
+ */
138
+ as?: string;
139
+ /**
140
+ * ARIA role of the panel.
141
+ * @default 'dialog'
142
+ */
143
+ role?: string;
144
+ /**
145
+ * Name of a Vue `<Transition>` defined by your CSS (`<name>-enter-active`, …).
146
+ * The classes are applied to the panel element. No animation when unset.
147
+ */
148
+ transition?: string;
149
+ /**
150
+ * Teleport target (CSS selector or element).
151
+ * @default 'body'
152
+ */
153
+ teleportTo?: string | HTMLElement;
154
+ /**
155
+ * Renders the panel in place instead of teleporting it.
156
+ * @default false
157
+ */
158
+ teleportDisabled?: boolean;
159
+ /**
160
+ * `z-index` of the positioned wrapper.
161
+ */
162
+ zIndex?: number | string;
163
+ /**
164
+ * Moves focus inside the panel once it is positioned: to the first element with
165
+ * `autofocus`, otherwise to the first focusable element, otherwise to the panel itself.
166
+ * @default false
167
+ */
168
+ autoFocus?: boolean;
169
+ }
package/package.json CHANGED
@@ -1,7 +1,37 @@
1
1
  {
2
2
  "name": "sb-floating-panel-vue",
3
- "version": "0.1.6",
4
- "description": "A lightweight Vue 3 composable hook built on top of @floating-ui/vue to manage floating panels with arrow and resize support.",
3
+ "version": "3.0.0",
4
+ "description": "Headless Vue 3 floating panel components (dropdowns, popovers, menus) powered by @floating-ui/vue: positioning, accessibility and open state handled, zero CSS shipped.",
5
+ "keywords": [
6
+ "vue",
7
+ "vue3",
8
+ "floating-ui",
9
+ "floating panel",
10
+ "popover",
11
+ "dropdown",
12
+ "menu",
13
+ "floating",
14
+ "positioning",
15
+ "headless",
16
+ "unstyled",
17
+ "teleport",
18
+ "accessibility",
19
+ "vue-components",
20
+ "vue-library"
21
+ ],
22
+ "author": {
23
+ "name": "Stefano Biddau",
24
+ "url": "https://www.stefano-biddau.com"
25
+ },
26
+ "license": "MIT",
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "https://github.com/stefanBid/sb-floating-panel-vue"
30
+ },
31
+ "bugs": {
32
+ "url": "https://github.com/stefanBid/sb-floating-panel-vue/issues"
33
+ },
34
+ "homepage": "https://github.com/stefanBid/sb-floating-panel-vue#readme",
5
35
  "main": "dist/sb-floating-panel.umd.js",
6
36
  "module": "dist/sb-floating-panel.es.js",
7
37
  "types": "dist/index.d.ts",
@@ -15,39 +45,39 @@
15
45
  "files": [
16
46
  "dist"
17
47
  ],
48
+ "sideEffects": false,
18
49
  "scripts": {
19
50
  "dev": "vite",
20
51
  "build": "vite build",
21
- "lint": "eslint . --ext .ts",
22
- "check": "vue-tsc --noEmit"
52
+ "lint": "eslint .",
53
+ "check": "vue-tsc --noEmit && vue-tsc --noEmit -p tsconfig.playground.json",
54
+ "test": "vitest run",
55
+ "test:coverage": "vitest run --coverage"
23
56
  },
24
- "keywords": [
25
- "vue",
26
- "floating-ui",
27
- "hook",
28
- "composable",
29
- "floating panel"
30
- ],
31
- "author": "Stefano Biddau",
32
- "license": "MIT",
33
57
  "peerDependencies": {
58
+ "@floating-ui/vue": "^2.0.1",
34
59
  "vue": "^3.5.13"
35
60
  },
36
- "dependencies": {
37
- "@floating-ui/vue": "^1.1.6"
38
- },
39
61
  "devDependencies": {
40
- "@types/node": "^22.15.17",
41
- "@typescript-eslint/eslint-plugin": "^8.32.0",
42
- "@typescript-eslint/parser": "^8.32.0",
43
- "@vitejs/plugin-vue": "^5.2.4",
44
- "eslint": "^9.26.0",
45
- "eslint-plugin-prettier": "^5.4.0",
46
- "eslint-plugin-vue": "^10.1.0",
47
- "prettier": "^3.5.3",
48
- "typescript": "~5.7.2",
49
- "vite": "^6.3.5",
50
- "vite-plugin-dts": "^4.5.3",
51
- "vue-tsc": "^2.2.10"
62
+ "@floating-ui/vue": "^2.0.1",
63
+ "@types/node": "^26.6.5",
64
+ "@typescript-eslint/eslint-plugin": "^8.71.2",
65
+ "@typescript-eslint/parser": "^8.71.2",
66
+ "@vitejs/plugin-vue": "^6.0.10",
67
+ "@vitest/coverage-v8": "^5.0.5",
68
+ "@vue/language-core": "^3.3.13",
69
+ "@vue/test-utils": "^2.5.1",
70
+ "eslint": "^10.13.0",
71
+ "eslint-plugin-prettier": "^5.5.7",
72
+ "eslint-plugin-vue": "^10.11.1",
73
+ "happy-dom": "^21.0.3",
74
+ "prettier": "^3.9.10",
75
+ "typescript": "^6.0.3",
76
+ "vite": "^8.3.4",
77
+ "vite-plugin-dts": "^5.1.2",
78
+ "vitest": "^5.0.5",
79
+ "vue": "^3.5.44",
80
+ "vue-eslint-parser": "10.4.1",
81
+ "vue-tsc": "^3.3.13"
52
82
  }
53
83
  }
@@ -1,31 +0,0 @@
1
- import { Placement, Strategy } from '@floating-ui/vue';
2
- interface FloatingSettings {
3
- placement: Placement;
4
- strategy: Strategy;
5
- offsetValue: number;
6
- hasArrow?: boolean;
7
- hasResize?: boolean;
8
- }
9
- export declare function useSbFloatingPanel(settings: FloatingSettings): {
10
- reference: import('vue').Ref<HTMLElement | null, HTMLElement | null>;
11
- floating: import('vue').Ref<HTMLElement | null, HTMLElement | null>;
12
- popperArrow: import('vue').Ref<HTMLElement | null, HTMLElement | null>;
13
- isOpen: import('vue').Ref<boolean, boolean>;
14
- floatingPosition: Readonly<import('vue').Ref<Placement, Placement>>;
15
- floatingStyles: Readonly<import('vue').Ref<{
16
- position: Strategy;
17
- top: string;
18
- left: string;
19
- transform?: string;
20
- willChange?: string;
21
- }, {
22
- position: Strategy;
23
- top: string;
24
- left: string;
25
- transform?: string;
26
- willChange?: string;
27
- }>>;
28
- floatingArrowStyles: Readonly<import('vue').Ref<import('@floating-ui/core').MiddlewareData, import('@floating-ui/core').MiddlewareData>>;
29
- changeFloatingVisibility: (newState: boolean) => void;
30
- };
31
- export {};