Skip to content

Commit

Permalink
feat: @svelte-put/popover
Browse files Browse the repository at this point in the history
  • Loading branch information
vnphanquang committed Jul 3, 2024
1 parent a108a52 commit 7395fab
Show file tree
Hide file tree
Showing 29 changed files with 1,371 additions and 525 deletions.
5 changes: 5 additions & 0 deletions .changeset/rude-dolphins-clap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@svelte-put/popover': major
---

First complete implementation inspired by `@svelte-put/tooltip` and Popover API
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
"dts-buddy": "^0.5.0",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-jsdoc": "^48.4.0",
"eslint-plugin-jsdoc": "^48.5.0",
"eslint-plugin-svelte": "^2.41.0",
"husky": "^9.0.11",
"lint-staged": "^15.2.7",
Expand All @@ -48,9 +48,9 @@
"stylelint-config-html": "^1.1.0",
"stylelint-config-standard": "^36.0.1",
"svelte": "5.0.0-next.166",
"svelte-check": "^3.8.2",
"svelte-check": "^3.8.4",
"tslib": "^2.6.3",
"turbo": "^2.0.5",
"turbo": "^2.0.6",
"typescript": "^5.5.2"
},
"packageManager": "[email protected]",
Expand Down
2 changes: 2 additions & 0 deletions packages/popover/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Changelog

47 changes: 47 additions & 0 deletions packages/popover/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<div align="center">

# `@svelte-put/popover`

[![npm.badge]][npm] [![bundlephobia.badge]][bundlephobia] [![docs.badge]][docs]

Idiomatic Svelte enhancements to [Popover API](https://developer.mozilla.org/en-US/docs/Web/API/Popover_API)

</div>

## `svelte-put`

This package is part of the [@svelte-put][github.monorepo] family. For contributing guideline and more, refer to its [readme][github.monorepo].

## Usage & Documentation

[See the dedicated documentation page here][docs].

## Quick Start

```html
<script lang="ts">
import { Popover } from '@svelte-put/popover';
WIP
</script>
```

## [Changelog][github.changelog]

<!-- github specifics -->

[github.monorepo]: https://github.com/vnphanquang/svelte-put
[github.changelog]: https://github.com/vnphanquang/svelte-put/blob/main/packages/popover/CHANGELOG.md
[github.issues]: https://github.com/vnphanquang/svelte-put/issues?q=

<!-- heading badge -->

[npm.badge]: https://img.shields.io/npm/v/@svelte-put/popover
[npm]: https://www.npmjs.com/package/@svelte-put/popover
[bundlephobia.badge]: https://img.shields.io/bundlephobia/minzip/@svelte-put/popover?label=minzipped
[bundlephobia]: https://bundlephobia.com/package/@svelte-put/popover
[repl]: https://svelte.dev/repl/9e5f9ee41c2c45aa8523993e357f6e78
[repl.badge]: https://img.shields.io/static/v1?label=&message=Svelte+REPL&logo=svelte&logoColor=fff&color=ff3e00
[docs]: https://svelte-put.vnphanquang.com/docs/popover
[docs.badge]: https://img.shields.io/badge/-Docs%20Site-blue

62 changes: 62 additions & 0 deletions packages/popover/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
{
"name": "@svelte-put/popover",
"version": "1.0.0-next.0",
"description": "Minimal and ssr-friendly enhancements to Popover API with idiomatic Svelte",
"main": "src/index.js",
"module": "src/index.js",
"types": "types/index.d.ts",
"type": "module",
"exports": {
".": {
"types": "./types/index.d.ts",
"import": "./src/index.js"
}
},
"publishConfig": {
"access": "public"
},
"files": [
"src",
"types"
],
"scripts": {
"lint": "eslint . --ignore-path=\"../../.eslintignore\"",
"format": "prettier --ignore-path ../../.prettierignore --write .",
"dts": "dts-buddy --write && publint",
"prepublishOnly": "turbo run dts --filter=@svelte-put/popover"
},
"keywords": [
"svelte",
"tooltip",
"progressive",
"enhance",
"ssr",
"popover",
"overlay"
],
"author": {
"email": "[email protected]",
"name": "Quang Phan",
"url": "https://github.com/vnphanquang"
},
"license": "MIT",
"homepage": "https://svelte-put.vnphanquang.com/docs/popover",
"repository": {
"type": "git",
"url": "git+https://github.com/vnphanquang/svelte-put.git",
"directory": "packages/popover"
},
"bugs": {
"url": "https://github.com/vnphanquang/svelte-put/issues"
},
"devDependencies": {
"@internals/tsconfig": "workspace:*"
},
"peerDependencies": {
"svelte": "^5.0.0-next.166"
},
"volta": {
"extends": "../../package.json"
}
}

3 changes: 3 additions & 0 deletions packages/popover/src/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { Popover } from './popover.svelte.js';
export * from './public.js';

243 changes: 243 additions & 0 deletions packages/popover/src/popover.svelte.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
import { on } from 'svelte/events';

/**
* Enhance Popover API with idiomatic Svelte
*/
export class Popover {
/** @type {ReturnType<typeof setTimeout> | undefined} */
#hideTimeoutId = undefined;
/** @type {ReturnType<typeof setTimeout> | undefined} */
#showTimeoutId = undefined;

/** @type {HTMLButtonElement | null} */
#controlEl = null;

/** @type {HTMLElement | null} */
#targetEl = null;

// public API
open = $state(false);

/** @type {import('./public').PopoverConfig} */
config;
/** @type {import('./public').PopoverControl} */
control;
/** @type {import('./public').PopoverTarget} */
target;

/**
* @param {import('./public').PopoverInit} [init]
*/
constructor(init = {}) {
this.config = {
id: init.id
? init.id
: // eslint-disable-next-line no-undef
'crypto' in globalThis
? crypto.randomUUID()
: Math.random().toString(36).substring(2),
inertWhenHidden: init.inertWhenHidden ?? true,
triggers: {
hover: {
enabled: false,
timeoutMs: 1000,
delayMs: 0,
},
focus: {
enabled: false,
timeoutMs: 1000,
delayMs: 0,
},
},
plugins: [],
};
if (init.triggers) {
const keys = /** @type {Array<keyof import('./public').PopoverConfig['triggers']>} */ (
Object.keys(this.config.triggers)
);
for (const key of keys) {
if (key in init.triggers) {
if (typeof init.triggers[key] === 'boolean') {
this.config.triggers[key].enabled = init.triggers[key];
} else {
this.config.triggers[key] = {
...this.config.triggers[key],
...init.triggers[key],
};
}
}
}
}

this.config.plugins = init.plugins
? Array.isArray(init.plugins)
? init.plugins
: [init.plugins]
: [];

const rPlugins = this.config.plugins.map(p => p(this.config));
const plugined = {
control: {
attributes: rPlugins
.map((p) => p.control?.attributes ?? {})
.reduce((acc, val) => ({ ...acc, ...val }), {}),
actions: rPlugins.map((p) => p.control?.actions ?? []).flat(),
},
target: {
attributes: rPlugins
.map((p) => p.target?.attributes ?? {})
.reduce((acc, val) => ({ ...acc, ...val }), {}),
actions: rPlugins.map((p) => p.target?.actions ?? []).flat(),
},
};

this.control = {
attributes: {
popovertarget: this.config.id,
popovertargetaction: 'show',
...(plugined.control?.attributes ?? {}),
},
actions: (node) => {
this.#controlEl = node;

/** @type {Array<() => void>} */
const offs = [];

if (this.config.triggers.focus.enabled) {
offs.push(
on(node, 'focusin', () => this.show(this.config.triggers.focus.delayMs)),
on(node, 'focusout', () => this.hide(this.config.triggers.focus.timeoutMs)),
);
}

if (this.config.triggers.hover.enabled) {
offs.push(
on(node, 'mouseenter', () => this.show(this.config.triggers.focus.delayMs)),
on(node, 'mouseleave', () => this.hide(this.config.triggers.focus.timeoutMs)),
);
}

// additional actions
const actions = plugined.control?.actions ?? [];
const actionReturns = actions.map((action) => action(node, this));

return {
update: () => {
actionReturns.forEach((actionReturn) => actionReturn?.update?.(this));
},
destroy: () => {
offs.forEach((off) => off());
actionReturns.forEach((actionReturn) => actionReturn?.destroy?.());
},
};
},
};

this.target = {
attributes: {
popover: 'auto',
id: this.config.id,
...(plugined.target?.attributes ?? {}),
},
actions: (node) => {
this.#targetEl = node;

$effect.root(() => {
$effect(() => {
// make popover inert when not open
node.inert = !this.open;
});
});

const offs = [
on(node, 'toggle', (e) => {
this.open = /** @type {ToggleEvent} */ (e).newState === 'open';
}),
];

if (this.config.triggers.focus.enabled) {
offs.push(
on(node, 'focusin', () => this.open && this.show(this.config.triggers.focus.delayMs)),
on(
node,
'focusout',
() => this.open && this.hide(this.config.triggers.focus.timeoutMs),
),
);
}

if (this.config.triggers.hover.enabled) {
offs.push(
/**
* For touch devices, popover is opened by pressing on the control element (button)
* which gives it focus. So when the target element is touched within, control element
* loses focus, causing the `focusout` event to fire AFTER `touchstart`. The `setTimeout`
* call is to work around this.
*/
on(
node,
'touchstart',
() => this.open && this.show(this.config.triggers.focus.delayMs + 100),
),
on(
node,
'mouseenter',
() => this.open && this.show(this.config.triggers.focus.delayMs),
),
on(
node,
'mouseleave',
() => this.open && this.hide(this.config.triggers.focus.timeoutMs),
),
);
}

// additional actions
const actions = plugined.target?.actions ?? [];
const actionReturns = actions.map((action) => action(node, this));

return {
update: () => {
actionReturns.forEach((actionReturn) => actionReturn?.update?.(this));
},
destroy: () => {
offs.forEach((off) => off());
actionReturns.forEach((actionReturn) => actionReturn?.destroy?.());
},
};
},
};
}

/**
* @param {number} [ms] - milliseconds to delay until showing
*/
show = (ms = 0) => {
clearTimeout(this.#hideTimeoutId);
clearTimeout(this.#showTimeoutId);
if (this.open) return;
this.#showTimeoutId = setTimeout(() => {
this.#showTimeoutId = undefined;
this.#targetEl?.showPopover();
}, ms);
};

/**
* @param {number} [ms] - milliseconds to delay until hiding
*/
hide = (ms = 0) => {
clearTimeout(this.#hideTimeoutId);
clearTimeout(this.#showTimeoutId);
if (!this.open) return;
this.#hideTimeoutId = setTimeout(() => {
this.#hideTimeoutId = undefined;
this.#targetEl?.hidePopover();
}, ms);
};

toggle = () => {
if (this.open) return this.hide();
this.show();
};
}

Loading

0 comments on commit 7395fab

Please sign in to comment.