Initial commit
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
*.md
|
||||
node_modules
|
||||
dist
|
||||
public
|
||||
build
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"endOfLine": "lf",
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"bracketSpacing": true,
|
||||
"semi": true,
|
||||
"bracketSameLine": false,
|
||||
"jsxSingleQuote": false,
|
||||
"arrowParens": "always",
|
||||
"importOrderCaseSensitive": false
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
# [←](../../../docs/README.md) @crux-kit/keenicon
|
||||
|
||||
Документація для Keenicon реакт компонентів/іконок.
|
||||
|
||||
Компонент KeenIcon — це універсальний інструмент для відображення іконок системи KeenIcon. Він підтримує різні стилі (filled, outline, solid, duotone)
|
||||
|
||||
Приклади іконок знаходяться у:
|
||||
`/assets/duotone/demo.html`
|
||||
`/assets/filled/demo.html`
|
||||
`/assets/outline/demo.html`
|
||||
`/assets/solid/demo.html`
|
||||
|
||||
### 1. Підключення стилів
|
||||
|
||||
Перед використанням компонента потрібно імпортувати основні стилі іконок у головний файл (наприклад, app.tsx або main.tsx)
|
||||
|
||||
```jsx
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import '@crux-kit/keenicon/assets/styles.css'
|
||||
import "./css/styles.css";
|
||||
import { App } from "./app";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
);
|
||||
```
|
||||
|
||||
### 2. Використання компонента
|
||||
|
||||
Щоб використати іконку `ki-abstract-1` потрібно передали в проп `icon` значкення `abstract-1`.
|
||||
Щоб змінити колір іконки потрібно передати у `className` класс з проп `color: red`.
|
||||
|
||||
```css
|
||||
/*
|
||||
Tailwind
|
||||
className="text-destructive"
|
||||
*/
|
||||
.text-destructive {
|
||||
color: red;
|
||||
}
|
||||
```
|
||||
|
||||
```jsx
|
||||
import { KeenIcon } from './components/KeenIcon';
|
||||
|
||||
const MyComponent = () => {
|
||||
return (
|
||||
<div>
|
||||
<KeenIcon icon="abstract-1" />
|
||||
|
||||
<KeenIcon icon="abstract-1" variant="outline" size="2x" className="text-primary" />
|
||||
|
||||
<KeenIcon icon="abstract-1" variant="duotone" className="text-destructive" size="5x" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### 3. Налаштування
|
||||
|
||||
| Prop | Тип | За замовчуванням | Опис |
|
||||
| --------- | --------------- | ---------------- | --------------------------------------------------------- |
|
||||
| icon | string | Обов'язково | Назва іконки (наприклад, magnifier, abstract-1) |
|
||||
| variant | KeeniconVariant | 'filled' | Стиль іконки: duotone, filled, solid, outline |
|
||||
| size | KeeniconSize | 'md' | Розмір іконки від 12px до 64px |
|
||||
| className | string | '' | Додаткові CSS-класи (наприклад, для кольору чи відступів) |
|
||||
|
||||
### 4. Розмірна сітка
|
||||
|
||||
```css
|
||||
.ki-xs {
|
||||
font-size: 12px;
|
||||
}
|
||||
.ki-sm {
|
||||
font-size: 14px;
|
||||
}
|
||||
.ki-md {
|
||||
font-size: 16px;
|
||||
}
|
||||
.ki-lg {
|
||||
font-size: 18px;
|
||||
}
|
||||
.ki-xl {
|
||||
font-size: 20px;
|
||||
}
|
||||
.ki-2x {
|
||||
font-size: 24px;
|
||||
}
|
||||
.ki-3x {
|
||||
font-size: 32px;
|
||||
}
|
||||
.ki-4x {
|
||||
font-size: 48px;
|
||||
}
|
||||
.ki-5x {
|
||||
font-size: 64px;
|
||||
}
|
||||
.ki-6x {
|
||||
font-size: 80px;
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Використання у HTML
|
||||
|
||||
```html
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<link rel="stylesheet" href="/assets/keenicons/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<i class="ki-solid ki-abstract-2 ki-xs" />
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
Executable
+17
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "@crux-kit/keenicon",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"types": "src/index.ts",
|
||||
"private": true,
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./assets/styles.css": "./assets/styles.css"
|
||||
},
|
||||
"style": "assets/styles.css",
|
||||
"scripts": {
|
||||
"format": "prettier --write ."
|
||||
},
|
||||
"dependencies": {},
|
||||
"devDependencies": {}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
Open *demo.html* to see a list of all the glyphs in your font along with their codes/ligatures.
|
||||
|
||||
To use the generated font in desktop programs, you can install the TTF font. In order to copy the character associated with each icon, refer to the text box at the bottom right corner of each glyph in demo.html. The character inside this text box may be invisible; but it can still be copied. See this guide for more info: https://icomoon.io/docs/#local-fonts
|
||||
|
||||
You won't need any of the files located under the *demo-files* directory when including the generated font in your own projects.
|
||||
|
||||
You can import *selection.json* back to the IcoMoon app using the *Import Icons* button (or via Main Menu → Manage Projects) to retrieve your icon selection.
|
||||
@@ -0,0 +1,160 @@
|
||||
body {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font-family: sans-serif;
|
||||
font-size: 1em;
|
||||
line-height: 1.5;
|
||||
color: #555;
|
||||
background: #fff;
|
||||
}
|
||||
h1 {
|
||||
font-size: 1.5em;
|
||||
font-weight: normal;
|
||||
}
|
||||
small {
|
||||
font-size: 0.66666667em;
|
||||
}
|
||||
a {
|
||||
color: #e74c3c;
|
||||
text-decoration: none;
|
||||
}
|
||||
a:hover,
|
||||
a:focus {
|
||||
box-shadow: 0 1px #e74c3c;
|
||||
}
|
||||
.bshadow0,
|
||||
input {
|
||||
box-shadow: inset 0 -2px #e7e7e7;
|
||||
}
|
||||
input:hover {
|
||||
box-shadow: inset 0 -2px #ccc;
|
||||
}
|
||||
input,
|
||||
fieldset {
|
||||
font-family: sans-serif;
|
||||
font-size: 1em;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
input {
|
||||
color: inherit;
|
||||
line-height: 1.5;
|
||||
height: 1.5em;
|
||||
padding: 0.25em 0;
|
||||
}
|
||||
input:focus {
|
||||
outline: none;
|
||||
box-shadow: inset 0 -2px #449fdb;
|
||||
}
|
||||
.glyph {
|
||||
font-size: 16px;
|
||||
width: 15em;
|
||||
padding-bottom: 1em;
|
||||
margin-right: 4em;
|
||||
margin-bottom: 1em;
|
||||
float: left;
|
||||
overflow: hidden;
|
||||
}
|
||||
.liga {
|
||||
width: 80%;
|
||||
width: calc(100% - 2.5em);
|
||||
}
|
||||
.talign-right {
|
||||
text-align: right;
|
||||
}
|
||||
.talign-center {
|
||||
text-align: center;
|
||||
}
|
||||
.bgc1 {
|
||||
background: #f1f1f1;
|
||||
}
|
||||
.fgc1 {
|
||||
color: #999;
|
||||
}
|
||||
.fgc0 {
|
||||
color: #000;
|
||||
}
|
||||
p {
|
||||
margin-top: 1em;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
.mvm {
|
||||
margin-top: 0.75em;
|
||||
margin-bottom: 0.75em;
|
||||
}
|
||||
.mtn {
|
||||
margin-top: 0;
|
||||
}
|
||||
.mtl,
|
||||
.mal {
|
||||
margin-top: 1.5em;
|
||||
}
|
||||
.mbl,
|
||||
.mal {
|
||||
margin-bottom: 1.5em;
|
||||
}
|
||||
.mal,
|
||||
.mhl {
|
||||
margin-left: 1.5em;
|
||||
margin-right: 1.5em;
|
||||
}
|
||||
.mhmm {
|
||||
margin-left: 1em;
|
||||
margin-right: 1em;
|
||||
}
|
||||
.mls {
|
||||
margin-left: 0.25em;
|
||||
}
|
||||
.ptl {
|
||||
padding-top: 1.5em;
|
||||
}
|
||||
.pbs,
|
||||
.pvs {
|
||||
padding-bottom: 0.25em;
|
||||
}
|
||||
.pvs,
|
||||
.pts {
|
||||
padding-top: 0.25em;
|
||||
}
|
||||
.unit {
|
||||
float: left;
|
||||
}
|
||||
.unitRight {
|
||||
float: right;
|
||||
}
|
||||
.size1of2 {
|
||||
width: 50%;
|
||||
}
|
||||
.size1of1 {
|
||||
width: 100%;
|
||||
}
|
||||
.clearfix:before,
|
||||
.clearfix:after {
|
||||
content: ' ';
|
||||
display: table;
|
||||
}
|
||||
.clearfix:after {
|
||||
clear: both;
|
||||
}
|
||||
.hidden-true {
|
||||
display: none;
|
||||
}
|
||||
.textbox0 {
|
||||
width: 3em;
|
||||
background: #f1f1f1;
|
||||
padding: 0.25em 0.5em;
|
||||
line-height: 1.5;
|
||||
height: 1.5em;
|
||||
}
|
||||
#testDrive {
|
||||
display: block;
|
||||
padding-top: 24px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.fs0 {
|
||||
font-size: 16px;
|
||||
}
|
||||
.fs1 {
|
||||
font-size: 24px;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
if (!('boxShadow' in document.body.style)) {
|
||||
document.body.setAttribute('class', 'noBoxShadow');
|
||||
}
|
||||
|
||||
document.body.addEventListener('click', function (e) {
|
||||
var target = e.target;
|
||||
if (target.tagName === 'INPUT' && target.getAttribute('class').indexOf('liga') === -1) {
|
||||
target.select();
|
||||
}
|
||||
});
|
||||
|
||||
(function () {
|
||||
var fontSize = document.getElementById('fontSize'),
|
||||
testDrive = document.getElementById('testDrive'),
|
||||
testText = document.getElementById('testText');
|
||||
function updateTest() {
|
||||
testDrive.innerHTML = testText.value || String.fromCharCode(160);
|
||||
if (window.icomoonLiga) {
|
||||
window.icomoonLiga(testDrive);
|
||||
}
|
||||
}
|
||||
function updateSize() {
|
||||
testDrive.style.fontSize = fontSize.value + 'px';
|
||||
}
|
||||
fontSize.addEventListener('change', updateSize, false);
|
||||
testText.addEventListener('input', updateTest, false);
|
||||
testText.addEventListener('change', updateTest, false);
|
||||
updateSize();
|
||||
})();
|
||||
+12414
File diff suppressed because it is too large
Load Diff
+1109
File diff suppressed because it is too large
Load Diff
|
After Width: | Height: | Size: 803 KiB |
BIN
Binary file not shown.
BIN
Binary file not shown.
+14109
File diff suppressed because it is too large
Load Diff
+4931
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,7 @@
|
||||
Open *demo.html* to see a list of all the glyphs in your font along with their codes/ligatures.
|
||||
|
||||
To use the generated font in desktop programs, you can install the TTF font. In order to copy the character associated with each icon, refer to the text box at the bottom right corner of each glyph in demo.html. The character inside this text box may be invisible; but it can still be copied. See this guide for more info: https://icomoon.io/docs/#local-fonts
|
||||
|
||||
You won't need any of the files located under the *demo-files* directory when including the generated font in your own projects.
|
||||
|
||||
You can import *selection.json* back to the IcoMoon app using the *Import Icons* button (or via Main Menu → Manage Projects) to retrieve your icon selection.
|
||||
@@ -0,0 +1,160 @@
|
||||
body {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font-family: sans-serif;
|
||||
font-size: 1em;
|
||||
line-height: 1.5;
|
||||
color: #555;
|
||||
background: #fff;
|
||||
}
|
||||
h1 {
|
||||
font-size: 1.5em;
|
||||
font-weight: normal;
|
||||
}
|
||||
small {
|
||||
font-size: 0.66666667em;
|
||||
}
|
||||
a {
|
||||
color: #e74c3c;
|
||||
text-decoration: none;
|
||||
}
|
||||
a:hover,
|
||||
a:focus {
|
||||
box-shadow: 0 1px #e74c3c;
|
||||
}
|
||||
.bshadow0,
|
||||
input {
|
||||
box-shadow: inset 0 -2px #e7e7e7;
|
||||
}
|
||||
input:hover {
|
||||
box-shadow: inset 0 -2px #ccc;
|
||||
}
|
||||
input,
|
||||
fieldset {
|
||||
font-family: sans-serif;
|
||||
font-size: 1em;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
input {
|
||||
color: inherit;
|
||||
line-height: 1.5;
|
||||
height: 1.5em;
|
||||
padding: 0.25em 0;
|
||||
}
|
||||
input:focus {
|
||||
outline: none;
|
||||
box-shadow: inset 0 -2px #449fdb;
|
||||
}
|
||||
.glyph {
|
||||
font-size: 16px;
|
||||
width: 15em;
|
||||
padding-bottom: 1em;
|
||||
margin-right: 4em;
|
||||
margin-bottom: 1em;
|
||||
float: left;
|
||||
overflow: hidden;
|
||||
}
|
||||
.liga {
|
||||
width: 80%;
|
||||
width: calc(100% - 2.5em);
|
||||
}
|
||||
.talign-right {
|
||||
text-align: right;
|
||||
}
|
||||
.talign-center {
|
||||
text-align: center;
|
||||
}
|
||||
.bgc1 {
|
||||
background: #f1f1f1;
|
||||
}
|
||||
.fgc1 {
|
||||
color: #999;
|
||||
}
|
||||
.fgc0 {
|
||||
color: #000;
|
||||
}
|
||||
p {
|
||||
margin-top: 1em;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
.mvm {
|
||||
margin-top: 0.75em;
|
||||
margin-bottom: 0.75em;
|
||||
}
|
||||
.mtn {
|
||||
margin-top: 0;
|
||||
}
|
||||
.mtl,
|
||||
.mal {
|
||||
margin-top: 1.5em;
|
||||
}
|
||||
.mbl,
|
||||
.mal {
|
||||
margin-bottom: 1.5em;
|
||||
}
|
||||
.mal,
|
||||
.mhl {
|
||||
margin-left: 1.5em;
|
||||
margin-right: 1.5em;
|
||||
}
|
||||
.mhmm {
|
||||
margin-left: 1em;
|
||||
margin-right: 1em;
|
||||
}
|
||||
.mls {
|
||||
margin-left: 0.25em;
|
||||
}
|
||||
.ptl {
|
||||
padding-top: 1.5em;
|
||||
}
|
||||
.pbs,
|
||||
.pvs {
|
||||
padding-bottom: 0.25em;
|
||||
}
|
||||
.pvs,
|
||||
.pts {
|
||||
padding-top: 0.25em;
|
||||
}
|
||||
.unit {
|
||||
float: left;
|
||||
}
|
||||
.unitRight {
|
||||
float: right;
|
||||
}
|
||||
.size1of2 {
|
||||
width: 50%;
|
||||
}
|
||||
.size1of1 {
|
||||
width: 100%;
|
||||
}
|
||||
.clearfix:before,
|
||||
.clearfix:after {
|
||||
content: ' ';
|
||||
display: table;
|
||||
}
|
||||
.clearfix:after {
|
||||
clear: both;
|
||||
}
|
||||
.hidden-true {
|
||||
display: none;
|
||||
}
|
||||
.textbox0 {
|
||||
width: 3em;
|
||||
background: #f1f1f1;
|
||||
padding: 0.25em 0.5em;
|
||||
line-height: 1.5;
|
||||
height: 1.5em;
|
||||
}
|
||||
#testDrive {
|
||||
display: block;
|
||||
padding-top: 24px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.fs0 {
|
||||
font-size: 16px;
|
||||
}
|
||||
.fs1 {
|
||||
font-size: 24px;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
if (!('boxShadow' in document.body.style)) {
|
||||
document.body.setAttribute('class', 'noBoxShadow');
|
||||
}
|
||||
|
||||
document.body.addEventListener('click', function (e) {
|
||||
var target = e.target;
|
||||
if (target.tagName === 'INPUT' && target.getAttribute('class').indexOf('liga') === -1) {
|
||||
target.select();
|
||||
}
|
||||
});
|
||||
|
||||
(function () {
|
||||
var fontSize = document.getElementById('fontSize'),
|
||||
testDrive = document.getElementById('testDrive'),
|
||||
testText = document.getElementById('testText');
|
||||
function updateTest() {
|
||||
testDrive.innerHTML = testText.value || String.fromCharCode(160);
|
||||
if (window.icomoonLiga) {
|
||||
window.icomoonLiga(testDrive);
|
||||
}
|
||||
}
|
||||
function updateSize() {
|
||||
testDrive.style.fontSize = fontSize.value + 'px';
|
||||
}
|
||||
fontSize.addEventListener('change', updateSize, false);
|
||||
testText.addEventListener('input', updateTest, false);
|
||||
testText.addEventListener('change', updateTest, false);
|
||||
updateSize();
|
||||
})();
|
||||
+12360
File diff suppressed because it is too large
Load Diff
+1082
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 1.6 MiB |
BIN
Binary file not shown.
BIN
Binary file not shown.
+14054
File diff suppressed because one or more lines are too long
+4769
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,7 @@
|
||||
Open *demo.html* to see a list of all the glyphs in your font along with their codes/ligatures.
|
||||
|
||||
To use the generated font in desktop programs, you can install the TTF font. In order to copy the character associated with each icon, refer to the text box at the bottom right corner of each glyph in demo.html. The character inside this text box may be invisible; but it can still be copied. See this guide for more info: https://icomoon.io/docs/#local-fonts
|
||||
|
||||
You won't need any of the files located under the *demo-files* directory when including the generated font in your own projects.
|
||||
|
||||
You can import *selection.json* back to the IcoMoon app using the *Import Icons* button (or via Main Menu → Manage Projects) to retrieve your icon selection.
|
||||
@@ -0,0 +1,160 @@
|
||||
body {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font-family: sans-serif;
|
||||
font-size: 1em;
|
||||
line-height: 1.5;
|
||||
color: #555;
|
||||
background: #fff;
|
||||
}
|
||||
h1 {
|
||||
font-size: 1.5em;
|
||||
font-weight: normal;
|
||||
}
|
||||
small {
|
||||
font-size: 0.66666667em;
|
||||
}
|
||||
a {
|
||||
color: #e74c3c;
|
||||
text-decoration: none;
|
||||
}
|
||||
a:hover,
|
||||
a:focus {
|
||||
box-shadow: 0 1px #e74c3c;
|
||||
}
|
||||
.bshadow0,
|
||||
input {
|
||||
box-shadow: inset 0 -2px #e7e7e7;
|
||||
}
|
||||
input:hover {
|
||||
box-shadow: inset 0 -2px #ccc;
|
||||
}
|
||||
input,
|
||||
fieldset {
|
||||
font-family: sans-serif;
|
||||
font-size: 1em;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
input {
|
||||
color: inherit;
|
||||
line-height: 1.5;
|
||||
height: 1.5em;
|
||||
padding: 0.25em 0;
|
||||
}
|
||||
input:focus {
|
||||
outline: none;
|
||||
box-shadow: inset 0 -2px #449fdb;
|
||||
}
|
||||
.glyph {
|
||||
font-size: 16px;
|
||||
width: 15em;
|
||||
padding-bottom: 1em;
|
||||
margin-right: 4em;
|
||||
margin-bottom: 1em;
|
||||
float: left;
|
||||
overflow: hidden;
|
||||
}
|
||||
.liga {
|
||||
width: 80%;
|
||||
width: calc(100% - 2.5em);
|
||||
}
|
||||
.talign-right {
|
||||
text-align: right;
|
||||
}
|
||||
.talign-center {
|
||||
text-align: center;
|
||||
}
|
||||
.bgc1 {
|
||||
background: #f1f1f1;
|
||||
}
|
||||
.fgc1 {
|
||||
color: #999;
|
||||
}
|
||||
.fgc0 {
|
||||
color: #000;
|
||||
}
|
||||
p {
|
||||
margin-top: 1em;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
.mvm {
|
||||
margin-top: 0.75em;
|
||||
margin-bottom: 0.75em;
|
||||
}
|
||||
.mtn {
|
||||
margin-top: 0;
|
||||
}
|
||||
.mtl,
|
||||
.mal {
|
||||
margin-top: 1.5em;
|
||||
}
|
||||
.mbl,
|
||||
.mal {
|
||||
margin-bottom: 1.5em;
|
||||
}
|
||||
.mal,
|
||||
.mhl {
|
||||
margin-left: 1.5em;
|
||||
margin-right: 1.5em;
|
||||
}
|
||||
.mhmm {
|
||||
margin-left: 1em;
|
||||
margin-right: 1em;
|
||||
}
|
||||
.mls {
|
||||
margin-left: 0.25em;
|
||||
}
|
||||
.ptl {
|
||||
padding-top: 1.5em;
|
||||
}
|
||||
.pbs,
|
||||
.pvs {
|
||||
padding-bottom: 0.25em;
|
||||
}
|
||||
.pvs,
|
||||
.pts {
|
||||
padding-top: 0.25em;
|
||||
}
|
||||
.unit {
|
||||
float: left;
|
||||
}
|
||||
.unitRight {
|
||||
float: right;
|
||||
}
|
||||
.size1of2 {
|
||||
width: 50%;
|
||||
}
|
||||
.size1of1 {
|
||||
width: 100%;
|
||||
}
|
||||
.clearfix:before,
|
||||
.clearfix:after {
|
||||
content: ' ';
|
||||
display: table;
|
||||
}
|
||||
.clearfix:after {
|
||||
clear: both;
|
||||
}
|
||||
.hidden-true {
|
||||
display: none;
|
||||
}
|
||||
.textbox0 {
|
||||
width: 3em;
|
||||
background: #f1f1f1;
|
||||
padding: 0.25em 0.5em;
|
||||
line-height: 1.5;
|
||||
height: 1.5em;
|
||||
}
|
||||
#testDrive {
|
||||
display: block;
|
||||
padding-top: 24px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.fs0 {
|
||||
font-size: 16px;
|
||||
}
|
||||
.fs1 {
|
||||
font-size: 24px;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
if (!('boxShadow' in document.body.style)) {
|
||||
document.body.setAttribute('class', 'noBoxShadow');
|
||||
}
|
||||
|
||||
document.body.addEventListener('click', function (e) {
|
||||
var target = e.target;
|
||||
if (target.tagName === 'INPUT' && target.getAttribute('class').indexOf('liga') === -1) {
|
||||
target.select();
|
||||
}
|
||||
});
|
||||
|
||||
(function () {
|
||||
var fontSize = document.getElementById('fontSize'),
|
||||
testDrive = document.getElementById('testDrive'),
|
||||
testText = document.getElementById('testText');
|
||||
function updateTest() {
|
||||
testDrive.innerHTML = testText.value || String.fromCharCode(160);
|
||||
if (window.icomoonLiga) {
|
||||
window.icomoonLiga(testDrive);
|
||||
}
|
||||
}
|
||||
function updateSize() {
|
||||
testDrive.style.fontSize = fontSize.value + 'px';
|
||||
}
|
||||
fontSize.addEventListener('change', updateSize, false);
|
||||
testText.addEventListener('input', updateTest, false);
|
||||
testText.addEventListener('change', updateTest, false);
|
||||
updateSize();
|
||||
})();
|
||||
+11346
File diff suppressed because it is too large
Load Diff
+575
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 1.2 MiB |
BIN
Binary file not shown.
BIN
Binary file not shown.
+13054
File diff suppressed because one or more lines are too long
+1721
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,43 @@
|
||||
/* KeenIcon Size Utilities */
|
||||
.ki-xs {
|
||||
font-size: 12px !important;
|
||||
line-height: 1;
|
||||
}
|
||||
.ki-sm {
|
||||
font-size: 14px !important;
|
||||
line-height: 1;
|
||||
}
|
||||
.ki-md {
|
||||
font-size: 16px !important;
|
||||
line-height: 1;
|
||||
} /* Base size */
|
||||
.ki-lg {
|
||||
font-size: 18px !important;
|
||||
line-height: 1;
|
||||
}
|
||||
.ki-xl {
|
||||
font-size: 20px !important;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Larger Display Sizes */
|
||||
.ki-2x {
|
||||
font-size: 24px !important;
|
||||
line-height: 1;
|
||||
}
|
||||
.ki-3x {
|
||||
font-size: 32px !important;
|
||||
line-height: 1;
|
||||
}
|
||||
.ki-4x {
|
||||
font-size: 40px !important;
|
||||
line-height: 1;
|
||||
}
|
||||
.ki-5x {
|
||||
font-size: 48px !important;
|
||||
line-height: 1;
|
||||
}
|
||||
.ki-6x {
|
||||
font-size: 64px !important;
|
||||
line-height: 1;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
Open *demo.html* to see a list of all the glyphs in your font along with their codes/ligatures.
|
||||
|
||||
To use the generated font in desktop programs, you can install the TTF font. In order to copy the character associated with each icon, refer to the text box at the bottom right corner of each glyph in demo.html. The character inside this text box may be invisible; but it can still be copied. See this guide for more info: https://icomoon.io/docs/#local-fonts
|
||||
|
||||
You won't need any of the files located under the *demo-files* directory when including the generated font in your own projects.
|
||||
|
||||
You can import *selection.json* back to the IcoMoon app using the *Import Icons* button (or via Main Menu → Manage Projects) to retrieve your icon selection.
|
||||
@@ -0,0 +1,160 @@
|
||||
body {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font-family: sans-serif;
|
||||
font-size: 1em;
|
||||
line-height: 1.5;
|
||||
color: #555;
|
||||
background: #fff;
|
||||
}
|
||||
h1 {
|
||||
font-size: 1.5em;
|
||||
font-weight: normal;
|
||||
}
|
||||
small {
|
||||
font-size: 0.66666667em;
|
||||
}
|
||||
a {
|
||||
color: #e74c3c;
|
||||
text-decoration: none;
|
||||
}
|
||||
a:hover,
|
||||
a:focus {
|
||||
box-shadow: 0 1px #e74c3c;
|
||||
}
|
||||
.bshadow0,
|
||||
input {
|
||||
box-shadow: inset 0 -2px #e7e7e7;
|
||||
}
|
||||
input:hover {
|
||||
box-shadow: inset 0 -2px #ccc;
|
||||
}
|
||||
input,
|
||||
fieldset {
|
||||
font-family: sans-serif;
|
||||
font-size: 1em;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
input {
|
||||
color: inherit;
|
||||
line-height: 1.5;
|
||||
height: 1.5em;
|
||||
padding: 0.25em 0;
|
||||
}
|
||||
input:focus {
|
||||
outline: none;
|
||||
box-shadow: inset 0 -2px #449fdb;
|
||||
}
|
||||
.glyph {
|
||||
font-size: 16px;
|
||||
width: 15em;
|
||||
padding-bottom: 1em;
|
||||
margin-right: 4em;
|
||||
margin-bottom: 1em;
|
||||
float: left;
|
||||
overflow: hidden;
|
||||
}
|
||||
.liga {
|
||||
width: 80%;
|
||||
width: calc(100% - 2.5em);
|
||||
}
|
||||
.talign-right {
|
||||
text-align: right;
|
||||
}
|
||||
.talign-center {
|
||||
text-align: center;
|
||||
}
|
||||
.bgc1 {
|
||||
background: #f1f1f1;
|
||||
}
|
||||
.fgc1 {
|
||||
color: #999;
|
||||
}
|
||||
.fgc0 {
|
||||
color: #000;
|
||||
}
|
||||
p {
|
||||
margin-top: 1em;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
.mvm {
|
||||
margin-top: 0.75em;
|
||||
margin-bottom: 0.75em;
|
||||
}
|
||||
.mtn {
|
||||
margin-top: 0;
|
||||
}
|
||||
.mtl,
|
||||
.mal {
|
||||
margin-top: 1.5em;
|
||||
}
|
||||
.mbl,
|
||||
.mal {
|
||||
margin-bottom: 1.5em;
|
||||
}
|
||||
.mal,
|
||||
.mhl {
|
||||
margin-left: 1.5em;
|
||||
margin-right: 1.5em;
|
||||
}
|
||||
.mhmm {
|
||||
margin-left: 1em;
|
||||
margin-right: 1em;
|
||||
}
|
||||
.mls {
|
||||
margin-left: 0.25em;
|
||||
}
|
||||
.ptl {
|
||||
padding-top: 1.5em;
|
||||
}
|
||||
.pbs,
|
||||
.pvs {
|
||||
padding-bottom: 0.25em;
|
||||
}
|
||||
.pvs,
|
||||
.pts {
|
||||
padding-top: 0.25em;
|
||||
}
|
||||
.unit {
|
||||
float: left;
|
||||
}
|
||||
.unitRight {
|
||||
float: right;
|
||||
}
|
||||
.size1of2 {
|
||||
width: 50%;
|
||||
}
|
||||
.size1of1 {
|
||||
width: 100%;
|
||||
}
|
||||
.clearfix:before,
|
||||
.clearfix:after {
|
||||
content: ' ';
|
||||
display: table;
|
||||
}
|
||||
.clearfix:after {
|
||||
clear: both;
|
||||
}
|
||||
.hidden-true {
|
||||
display: none;
|
||||
}
|
||||
.textbox0 {
|
||||
width: 3em;
|
||||
background: #f1f1f1;
|
||||
padding: 0.25em 0.5em;
|
||||
line-height: 1.5;
|
||||
height: 1.5em;
|
||||
}
|
||||
#testDrive {
|
||||
display: block;
|
||||
padding-top: 24px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.fs0 {
|
||||
font-size: 16px;
|
||||
}
|
||||
.fs1 {
|
||||
font-size: 24px;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
if (!('boxShadow' in document.body.style)) {
|
||||
document.body.setAttribute('class', 'noBoxShadow');
|
||||
}
|
||||
|
||||
document.body.addEventListener('click', function (e) {
|
||||
var target = e.target;
|
||||
if (target.tagName === 'INPUT' && target.getAttribute('class').indexOf('liga') === -1) {
|
||||
target.select();
|
||||
}
|
||||
});
|
||||
|
||||
(function () {
|
||||
var fontSize = document.getElementById('fontSize'),
|
||||
testDrive = document.getElementById('testDrive'),
|
||||
testText = document.getElementById('testText');
|
||||
function updateTest() {
|
||||
testDrive.innerHTML = testText.value || String.fromCharCode(160);
|
||||
if (window.icomoonLiga) {
|
||||
window.icomoonLiga(testDrive);
|
||||
}
|
||||
}
|
||||
function updateSize() {
|
||||
testDrive.style.fontSize = fontSize.value + 'px';
|
||||
}
|
||||
fontSize.addEventListener('change', updateSize, false);
|
||||
testText.addEventListener('input', updateTest, false);
|
||||
testText.addEventListener('change', updateTest, false);
|
||||
updateSize();
|
||||
})();
|
||||
+11346
File diff suppressed because it is too large
Load Diff
+575
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 878 KiB |
Binary file not shown.
BIN
Binary file not shown.
+13048
File diff suppressed because one or more lines are too long
+1721
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,6 @@
|
||||
@import './duotone/style.css';
|
||||
@import './outline/style.css';
|
||||
@import './filled/style.css';
|
||||
@import './solid/style.css';
|
||||
|
||||
@import './sizing.css';
|
||||
Executable
+2
@@ -0,0 +1,2 @@
|
||||
export * from './types';
|
||||
export * from './keenicon';
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
import { forwardRef } from 'react';
|
||||
import type { KeeniconsProps } from './types';
|
||||
|
||||
function joinClasses(...args: string[]): string {
|
||||
return args.join(' ');
|
||||
}
|
||||
|
||||
export const KeenIcon = forwardRef<HTMLElement, KeeniconsProps>((props, ref) => {
|
||||
const { icon, variant = 'filled', className = '', size = 'md', ...rest } = props;
|
||||
|
||||
return (
|
||||
<i
|
||||
ref={ref}
|
||||
{...rest}
|
||||
className={joinClasses(`ki-${variant}`, `ki-${icon}`, `ki-${size}`, className)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
KeenIcon.displayName = 'KeenIcon';
|
||||
Executable
+9
@@ -0,0 +1,9 @@
|
||||
export type KeeniconVariant = 'duotone' | 'filled' | 'solid' | 'outline';
|
||||
export type KeeniconSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2x' | '3x' | '4x' | '5x' | '6x';
|
||||
|
||||
export interface KeeniconsProps {
|
||||
icon: string;
|
||||
variant?: KeeniconVariant;
|
||||
className?: string;
|
||||
size?: KeeniconSize;
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2023", "DOM"],
|
||||
"jsx": "react-jsx",
|
||||
"skipLibCheck": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"composite": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
*.md
|
||||
node_modules
|
||||
dist
|
||||
public
|
||||
build
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"printWidth": 150,
|
||||
"tabWidth": 2,
|
||||
"endOfLine": "lf",
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"bracketSpacing": true,
|
||||
"semi": true,
|
||||
"bracketSameLine": false,
|
||||
"jsxSingleQuote": false,
|
||||
"arrowParens": "always",
|
||||
"importOrderCaseSensitive": false
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
# [←](../../docs/README.md) @crux-kit/hooks
|
||||
|
||||
A collection of reusable React hooks for common UI and browser interaction patterns.
|
||||
|
||||
Each hook is client-side only and tree-shakeable. Detailed documentation for each hook lives alongside its source file.
|
||||
|
||||
## Available Hooks
|
||||
|
||||
| Hook | Description | Docs |
|
||||
| -------------------- | ----------------------------------------------------------------------- | ----------------------------------------------- |
|
||||
| `useBodyClass` | Adds CSS classes to `document.body` while a component is mounted | [Readme.md](./src/useBodyClass/Readme.md) |
|
||||
| `useCopyToClipboard` | Copies text to the clipboard with a timed `isCopied` flag | [Readme.md](./src/useCopyToClipboard/Readme.md) |
|
||||
| `useCountdown` | Countdown timer that fires a callback on completion | [Readme.md](./src/useCountdown/Readme.md) |
|
||||
| `useDebounceSearch` | Debounced search input with a minimum character threshold | [Readme.md](./src/useDebounceSearch/Readme.md) |
|
||||
| `useDisclosure` | Boolean open/closed state with `open`, `close`, and `toggle` helpers | [Readme.md](./src/useDisclosure/Readme.md) |
|
||||
| `useEffectOnce` | Runs a side-effect exactly once after the initial render | [Readme.md](./src/useEffectOnce/Readme.md) |
|
||||
| `useEventListener` | Declarative event listener with automatic cleanup | [Readme.md](./src/useEventListener/Readme.md) |
|
||||
| `useIsMobile` | Returns `true` when the viewport is below a breakpoint | [Readme.md](./src/useIsMobile/Readme.md) |
|
||||
| `useMounted` | Returns `true` after the component has mounted (SSR-safe) | [Readme.md](./src/useMounted/Readme.md) |
|
||||
| `useOutsideClick` | Fires a callback when a click/touch occurs outside a referenced element | [Readme.md](./src/useOutsideClick/Readme.md) |
|
||||
| `usePageMeta` | Sets page `<title>` and `<meta name="description">` while mounted | [Readme.md](./src/usePageMeta/Readme.md) |
|
||||
| `useScrollPosition` | Tracks vertical scroll position of the window or a specific element | [Readme.md](./src/useScrollPosition/Readme.md) |
|
||||
| `useViewport` | Returns current viewport `[height, width]`, updates on resize | [Readme.md](./src/useViewport/Readme.md) |
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "@crux-kit/hooks",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"types": "src/index.ts",
|
||||
"private": true,
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {},
|
||||
"dependencies": {
|
||||
"@crux-kit/types": "*"
|
||||
},
|
||||
"devDependencies": {},
|
||||
"peerDependencies": {}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export * from './useCountdown';
|
||||
export * from './useEffectOnce';
|
||||
export * from './useEventListener';
|
||||
export * from './useCopyToClipboard';
|
||||
export * from './useBodyClass';
|
||||
export * from './useDisclosure';
|
||||
export * from './useIsMobile';
|
||||
export * from './useMounted';
|
||||
export * from './useOutsideClick';
|
||||
export * from './useScrollPosition';
|
||||
export * from './useDebounceSearch';
|
||||
export * from './useViewport';
|
||||
export * from './usePageMeta';
|
||||
@@ -0,0 +1,31 @@
|
||||
# useBodyClass
|
||||
|
||||
Adds one or more CSS classes to `document.body` while the component is mounted, and removes them on unmount.
|
||||
|
||||
## Signature
|
||||
|
||||
```ts
|
||||
function useBodyClass(className: string): void;
|
||||
```
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| ----------- | -------- | ------------------------------------------------------- |
|
||||
| `className` | `string` | Space-separated class name(s) to add to `document.body` |
|
||||
|
||||
## Usage
|
||||
|
||||
```tsx
|
||||
export function ModalLayout({ children }: { children: React.ReactNode }) {
|
||||
useBodyClass('overflow-hidden bg-overlay');
|
||||
|
||||
return <div>{children}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
While `ModalLayout` is mounted, `document.body` will have the classes `overflow-hidden` and `bg-overlay`. Both are removed automatically when the component unmounts.
|
||||
|
||||
## Notes
|
||||
|
||||
- Accepts a space-separated string — multiple classes are handled individually so partial removal is safe.
|
||||
- Re-applies classes if the `className` prop changes.
|
||||
- Client-only (`'use client'`). Does not run during SSR.
|
||||
@@ -0,0 +1 @@
|
||||
export * from './useBodyClass';
|
||||
@@ -0,0 +1,22 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export function useBodyClass(className: string) {
|
||||
useEffect(() => {
|
||||
// Split classNames by spaces, including multi-line support
|
||||
const classList = className.split(/\s+/).filter(Boolean);
|
||||
|
||||
// Add each class to the body element when the component mounts
|
||||
classList.forEach((className) => {
|
||||
document.body.classList.add(className);
|
||||
});
|
||||
|
||||
// Cleanup function to remove classes when the component unmounts
|
||||
return () => {
|
||||
classList.forEach((className) => {
|
||||
document.body.classList.remove(className);
|
||||
});
|
||||
};
|
||||
}, [className]); // Re-run the effect if classNames changes
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
# useCopyToClipboard
|
||||
|
||||
Copies a string to the clipboard and exposes a temporary `isCopied` flag that resets after a configurable timeout.
|
||||
|
||||
## Signature
|
||||
|
||||
```ts
|
||||
function useCopyToClipboard(options?: { timeout?: number; onCopy?: () => void }): {
|
||||
isCopied: boolean;
|
||||
copyToClipboard: (value: string) => void;
|
||||
};
|
||||
```
|
||||
|
||||
### Options
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
| --------- | ------------ | ------- | -------------------------------------------------- |
|
||||
| `timeout` | `number` | `2000` | Milliseconds before `isCopied` resets to `false` |
|
||||
| `onCopy` | `() => void` | — | Callback fired immediately after a successful copy |
|
||||
|
||||
### Returns
|
||||
|
||||
| Property | Type | Description |
|
||||
| ----------------- | ------------------------- | --------------------------------------- |
|
||||
| `isCopied` | `boolean` | `true` for `timeout` ms after each copy |
|
||||
| `copyToClipboard` | `(value: string) => void` | Writes `value` to the clipboard |
|
||||
|
||||
## Usage
|
||||
|
||||
```tsx
|
||||
function CopyButton({ code }: { code: string }) {
|
||||
const { isCopied, copyToClipboard } = useCopyToClipboard({ timeout: 1500 });
|
||||
|
||||
return <button onClick={() => copyToClipboard(code)}>{isCopied ? 'Copied!' : 'Copy'}</button>;
|
||||
}
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Uses the `navigator.clipboard` API — no-ops silently in non-secure contexts or environments without clipboard support.
|
||||
- Client-only (`'use client'`). Does not run during SSR.
|
||||
@@ -0,0 +1 @@
|
||||
export * from './useCopyToClipboard';
|
||||
@@ -0,0 +1,35 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
export function useCopyToClipboard({
|
||||
timeout = 2000,
|
||||
onCopy,
|
||||
}: {
|
||||
timeout?: number;
|
||||
onCopy?: () => void;
|
||||
} = {}) {
|
||||
const [isCopied, setIsCopied] = React.useState(false);
|
||||
|
||||
const copyToClipboard = (value: string) => {
|
||||
if (typeof window === 'undefined' || !navigator.clipboard.writeText) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!value) return;
|
||||
|
||||
navigator.clipboard.writeText(value).then(() => {
|
||||
setIsCopied(true);
|
||||
|
||||
if (onCopy) {
|
||||
onCopy();
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
setIsCopied(false);
|
||||
}, timeout);
|
||||
}, console.error);
|
||||
};
|
||||
|
||||
return { isCopied, copyToClipboard };
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
# useCountdown Hook
|
||||
|
||||
Створює таймер зворотного відліку.
|
||||
Автоматично викликає `onTimeOut` після завершення зворотного відліку.
|
||||
По замовчуванню `delay` 10s
|
||||
|
||||
Output:
|
||||
|
||||
```ts
|
||||
export type CountdownController = {
|
||||
start: () => void;
|
||||
dismiss: () => void;
|
||||
remaining: number | null;
|
||||
};
|
||||
```
|
||||
|
||||
```ts
|
||||
|
||||
export const EmailVerification: React.FC<{
|
||||
initialValues: FormValues;
|
||||
onSubmit: (values: FormValues) => void;
|
||||
}> = ({ initialValues, onSubmit }) => {
|
||||
const form = useForm<FormValues>({
|
||||
defaultValues: initialValues,
|
||||
resolver: zodResolver(verifySchema),
|
||||
});
|
||||
|
||||
const sendCodeMutation = useSendVerificationCode();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [countdownFinished, setCountdownFinished] = React.useState(false);
|
||||
const { start, remaining } = useCountdown({
|
||||
onTimeOut: () => setCountdownFinished(true),
|
||||
delay: 59 * 1000,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
start();
|
||||
}, []);
|
||||
|
||||
const handleResendCode = () => {
|
||||
const email = form.getValues().email;
|
||||
|
||||
if (!email) return;
|
||||
|
||||
sendCodeMutation.mutate(
|
||||
{ email },
|
||||
{
|
||||
onSuccess() {
|
||||
toast.info(t('Verification code sent to email'));
|
||||
start();
|
||||
setCountdownFinished(false);
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Form></Form>
|
||||
);
|
||||
};
|
||||
|
||||
```
|
||||
@@ -0,0 +1 @@
|
||||
export * from './useCountdown';
|
||||
@@ -0,0 +1,67 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
export type UseCountdownParams = {
|
||||
onTimeOut: () => void;
|
||||
delay?: number;
|
||||
};
|
||||
|
||||
export type CountdownController = {
|
||||
start: () => void;
|
||||
dismiss: () => void;
|
||||
remaining: number | null;
|
||||
};
|
||||
|
||||
export const useCountdown = ({ onTimeOut, delay = 10000 }: UseCountdownParams): CountdownController => {
|
||||
const timeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const intervalRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [remaining, setRemaining] = React.useState<number | null>(null);
|
||||
|
||||
const start = React.useCallback(() => {
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
|
||||
const totalSeconds = Math.ceil(delay / 1000);
|
||||
setRemaining(totalSeconds);
|
||||
|
||||
intervalRef.current = setInterval(() => {
|
||||
setRemaining((prev) => {
|
||||
if (prev === null || prev <= 1) {
|
||||
clearInterval(intervalRef.current!);
|
||||
return null;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
onTimeOut();
|
||||
timeoutRef.current = null;
|
||||
setRemaining(null);
|
||||
}, delay);
|
||||
}, [delay, onTimeOut]);
|
||||
|
||||
const dismiss = React.useCallback(() => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
|
||||
setRemaining(null);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { start, dismiss, remaining };
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
# useDebounceSearch
|
||||
|
||||
Controls a search input with debouncing and a minimum character threshold. Only fires the `onTimeOut` callback after the user stops typing and the query meets the minimum length.
|
||||
|
||||
## Signature
|
||||
|
||||
```ts
|
||||
function useDebounceSearch(params: UseDebounceSearchParams): DebounceSearchController;
|
||||
```
|
||||
|
||||
### Params
|
||||
|
||||
```ts
|
||||
type UseDebounceSearchParams = {
|
||||
onTimeOut: (query: string) => void;
|
||||
minQueryNumber?: number;
|
||||
initialQuery?: string | null;
|
||||
delay?: number;
|
||||
};
|
||||
```
|
||||
|
||||
| Param | Type | Default | Description |
|
||||
| ---------------- | ------------------------- | ------- | ------------------------------------------------------------ |
|
||||
| `onTimeOut` | `(query: string) => void` | — | Called with the current query after `delay` ms of inactivity |
|
||||
| `minQueryNumber` | `number` | `3` | Minimum characters required before `onTimeOut` is invoked |
|
||||
| `initialQuery` | `string \| null` | `''` | Initial value of the controlled input |
|
||||
| `delay` | `number` | `800` | Debounce delay in milliseconds |
|
||||
|
||||
### Returns
|
||||
|
||||
```ts
|
||||
type DebounceSearchController = {
|
||||
query: string;
|
||||
handleChange: (value: string) => void;
|
||||
};
|
||||
```
|
||||
|
||||
| Property | Type | Description |
|
||||
| -------------- | ------------------------- | -------------------------------------- |
|
||||
| `query` | `string` | Current value of the search input |
|
||||
| `handleChange` | `(value: string) => void` | Pass to the input's `onChange` handler |
|
||||
|
||||
## Usage
|
||||
|
||||
```tsx
|
||||
function SearchBox() {
|
||||
const { query, handleChange } = useDebounceSearch({
|
||||
onTimeOut: (q) => fetchResults(q),
|
||||
minQueryNumber: 2,
|
||||
delay: 500,
|
||||
});
|
||||
|
||||
return <input value={query} onChange={(e) => handleChange(e.target.value)} placeholder="Search…" />;
|
||||
}
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Queries shorter than `minQueryNumber` (but not empty) cancel any pending debounced call.
|
||||
- Uses `throttle-debounce` under the hood via a stable `useRef` to avoid resetting the timer on each render.
|
||||
- Client-only (`'use client'`).
|
||||
@@ -0,0 +1 @@
|
||||
export * from './useDebounceSearch';
|
||||
@@ -0,0 +1,38 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, useState } from 'react';
|
||||
import { debounce } from 'throttle-debounce';
|
||||
|
||||
export type UseDebounceSearchParams = {
|
||||
onTimeOut: (query: string) => void;
|
||||
minQueryNumber?: number;
|
||||
initialQuery?: string | null;
|
||||
delay?: number;
|
||||
};
|
||||
|
||||
export type DebounceSearchController = {
|
||||
query: string;
|
||||
handleChange: (value: string) => void;
|
||||
};
|
||||
|
||||
export const useDebounceSearch = (params: UseDebounceSearchParams): DebounceSearchController => {
|
||||
const { onTimeOut, minQueryNumber = 3, delay = 800, initialQuery } = params;
|
||||
|
||||
const [query, setQuery] = useState(initialQuery ?? '');
|
||||
|
||||
const throttled = useRef(debounce(delay, (callback: () => void) => callback()));
|
||||
const bind = (value: string) => throttled.current(onTimeOut.bind(null, value));
|
||||
const unbind = () => throttled.current(() => {});
|
||||
|
||||
const handleChange = (value: string) => {
|
||||
setQuery(value);
|
||||
|
||||
if (value.length < minQueryNumber && value.length !== 0) {
|
||||
return unbind();
|
||||
}
|
||||
|
||||
bind(value);
|
||||
};
|
||||
|
||||
return { query, handleChange };
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
# useDisclosure
|
||||
|
||||
Manages a boolean open/closed state — useful for modals, drawers, dropdowns, and any show/hide UI pattern.
|
||||
|
||||
## Signature
|
||||
|
||||
```ts
|
||||
function useDisclosure(initial?: boolean): UseDisclosureControl;
|
||||
```
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
| --------- | --------- | ------- | ------------------------- |
|
||||
| `initial` | `boolean` | `false` | Initial open/closed state |
|
||||
|
||||
### Returns
|
||||
|
||||
```ts
|
||||
interface UseDisclosureControl {
|
||||
isOpen: boolean;
|
||||
open: () => void;
|
||||
close: () => void;
|
||||
toggle: () => void;
|
||||
}
|
||||
```
|
||||
|
||||
| Property | Type | Description |
|
||||
| -------- | ------------ | ------------------------- |
|
||||
| `isOpen` | `boolean` | Current open/closed state |
|
||||
| `open` | `() => void` | Sets `isOpen` to `true` |
|
||||
| `close` | `() => void` | Sets `isOpen` to `false` |
|
||||
| `toggle` | `() => void` | Flips the current state |
|
||||
|
||||
## Usage
|
||||
|
||||
```tsx
|
||||
function ConfirmDialog() {
|
||||
const { isOpen, open, close } = useDisclosure();
|
||||
|
||||
return (
|
||||
<>
|
||||
<button onClick={open}>Delete account</button>
|
||||
<Dialog open={isOpen} onClose={close}>
|
||||
<p>Are you sure?</p>
|
||||
<button onClick={close}>Cancel</button>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- `open`, `close`, and `toggle` are stable references (`useCallback`) and safe to pass as props without causing unnecessary re-renders.
|
||||
- Client-only (`'use client'`).
|
||||
@@ -0,0 +1 @@
|
||||
export * from './useDisclosure';
|
||||
@@ -0,0 +1,20 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
export interface UseDisclosureControl {
|
||||
isOpen: boolean;
|
||||
open: () => void;
|
||||
close: () => void;
|
||||
toggle: () => void;
|
||||
}
|
||||
|
||||
export const useDisclosure = (initial = false): UseDisclosureControl => {
|
||||
const [isOpen, setIsOpen] = useState(initial);
|
||||
|
||||
const open = useCallback(() => setIsOpen(true), []);
|
||||
const close = useCallback(() => setIsOpen(false), []);
|
||||
const toggle = useCallback(() => setIsOpen((state) => !state), []);
|
||||
|
||||
return { isOpen, open, close, toggle };
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
# useEffectOnce Hook
|
||||
|
||||
`useEffectOnce` is a custom hook in React designed to mimic the behavior of `componentDidMount` and `componentWillUnmount` lifecycle methods in class components. It's a modified version of `useEffect` that runs only once when the component mounts.
|
||||
|
||||
## Features
|
||||
|
||||
- **Single Execution:** The hook executes the provided effect function once upon the component's initial render.
|
||||
- **Cleanup Capability:** It supports an optional cleanup function, returned from the effect, which is called when the component unmounts.
|
||||
- **SSR Compatibility:** As an extension of `useEffect`, it is naturally compatible with server-side rendering environments.
|
||||
|
||||
## Usage
|
||||
|
||||
```typescript
|
||||
import React from 'react';
|
||||
import { useEffectOnce } from '@crux-kit/hooks';
|
||||
|
||||
const Component = () => {
|
||||
const [fibonacciSequence, setFibonacciSequence] = useState([]);
|
||||
|
||||
const calculateFibonacci = (n) => {
|
||||
let sequence = [0, 1];
|
||||
for (let i = 2; i < n; i++) {
|
||||
sequence[i] = sequence[i - 1] + sequence[i - 2];
|
||||
}
|
||||
return sequence.slice(0, n);
|
||||
};
|
||||
|
||||
useEffectOnce(() => {
|
||||
const sequence = calculateFibonacci(5);
|
||||
setFibonacciSequence(sequence);
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p>First {5} numbers in the Fibonacci sequence:</p>
|
||||
<ul>
|
||||
{fibonacciSequence.map((number, index) => (
|
||||
<li key={index}>{number}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
In this example, `useEffectOnce` is used to perform actions at the mounting and unmounting phases of `Component`.
|
||||
|
||||
## API Reference
|
||||
|
||||
### Parameters
|
||||
|
||||
- `effect`: A function that will be executed once when the component mounts. This function can optionally return a cleanup function, which will be executed when the component unmounts.
|
||||
|
||||
## Use Cases
|
||||
|
||||
- **Initial Setup**: Perform setup operations like fetching initial data or setting up listeners.
|
||||
- **One-time Calculations**: Compute values needed only once during the component's lifecycle.
|
||||
- **Single API Calls**: Make a single API call when a component is rendered for the first time.
|
||||
- **Non-Recurring Subscriptions**: Subscribe to a service or event listener that should only be initialized once.
|
||||
@@ -0,0 +1 @@
|
||||
export * from './useEffectOnce';
|
||||
@@ -0,0 +1,22 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
* `useEffectOnce` is a custom hook that runs a side-effect only once after the initial render.
|
||||
* It's similar to useEffect, but it guarantees that the effect runs only once.
|
||||
*
|
||||
* @param effect - The effect function to run. Can optionally return a cleanup function.
|
||||
*/
|
||||
|
||||
export function useEffectOnce(effect: () => void | (() => void | undefined)) {
|
||||
React.useEffect(() => {
|
||||
const cleanupFunction = effect();
|
||||
|
||||
return () => {
|
||||
if (typeof cleanupFunction === 'function') {
|
||||
cleanupFunction();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
# Documentation: useEventListener Hook
|
||||
|
||||
The useEventListener hook provides a declarative way to manage event listeners in React. It handles the manual setup, cleanup (removal), and potential memory leaks automatically while providing full TypeScript type-safety.
|
||||
|
||||
## Why are there 3 function signatures?
|
||||
|
||||
In TypeScript, this is called **Function Overloading**. It allows the hook to "shape-shift" based on what arguments you pass it. This ensures that the `event` object inside your callback has the correct properties (e.g., `.key` for keyboard events or `.clientX` for mouse events).
|
||||
|
||||
| Overload | Target | Use Case |
|
||||
| ---------- | -------------- | ------------------------------------------------------------------- |
|
||||
| Overload 1 | `window` | Global events like `resize`, `scroll`, or `keydown` |
|
||||
| Overload 2 | `HTMLElement` | Specific element events like clicking a button or scrolling a `div` |
|
||||
| Overload 3 | Implementation | The actual logic |
|
||||
|
||||
### 1. Window Events (Global)
|
||||
|
||||
This version is used when you don't provide a third argument. It defaults the listener to the global window object.
|
||||
|
||||
**Example: Detecting the "/" key for search**
|
||||
|
||||
```ts
|
||||
useEventListener('keydown', (event) => {
|
||||
if (event.key === '/') {
|
||||
console.log('Search shortcut triggered!');
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**Description:** Useful for global shortcuts, tracking window resizing, or detecting if the user has scrolled the entire page.
|
||||
|
||||
### 2. Element Events (Targeted)
|
||||
|
||||
This version is used when you pass a specific element (usually via a ref) as the third argument.
|
||||
|
||||
**Example: Clicking a specific button**
|
||||
|
||||
```ts
|
||||
const btnRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEventListener(
|
||||
'click',
|
||||
(event) => {
|
||||
console.log('Coordinates:', event.clientX, event.clientY);
|
||||
},
|
||||
btnRef.current,
|
||||
);
|
||||
```
|
||||
|
||||
**Description:** Perfect for tracking interactions with specific parts of your UI, such as custom dropdowns, drag-and-drop zones, or scrollable containers.
|
||||
|
||||
### 3. The Implementation Signature
|
||||
|
||||
This is the internal part of the code that makes everything work. It uses `useRef` to store your handler so that the event listener doesn't have to be reset every time your component re-renders.
|
||||
|
||||
```tsx
|
||||
const savedHandler = useRef(handler);
|
||||
|
||||
useEffect(() => {
|
||||
const target = element ?? window;
|
||||
const listener = (e) => savedHandler.current(e);
|
||||
|
||||
target.addEventListener(eventName, listener);
|
||||
return () => target.removeEventListener(eventName, listener);
|
||||
}, [eventName, element]);
|
||||
```
|
||||
|
||||
**Description:** This "engine" ensures that even if your component state changes frequently, the event listener remains attached and efficient without causing performance bottlenecks.
|
||||
@@ -0,0 +1 @@
|
||||
export * from './useEventListener';
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
/**
|
||||
* A custom hook to manage event listeners.
|
||||
* @param eventName - The name of the event (e.g., 'scroll', 'click', 'keydown')
|
||||
* @param handler - The callback function to execute
|
||||
* @param element - The target element (defaults to window)
|
||||
*/
|
||||
function useEventListener<K extends keyof WindowEventMap>(eventName: K, handler: (event: WindowEventMap[K]) => void, element?: undefined): void;
|
||||
|
||||
function useEventListener<K extends keyof HTMLElementEventMap, T extends HTMLElement>(
|
||||
eventName: K,
|
||||
handler: (event: HTMLElementEventMap[K]) => void,
|
||||
element: T | null,
|
||||
): void;
|
||||
|
||||
function useEventListener<KW extends keyof WindowEventMap, KH extends keyof HTMLElementEventMap, T extends HTMLElement>(
|
||||
eventName: KW | KH,
|
||||
handler: (event: WindowEventMap[KW] | HTMLElementEventMap[KH] | Event) => void,
|
||||
element?: T | Window | null,
|
||||
) {
|
||||
const savedHandler = useRef(handler);
|
||||
|
||||
useEffect(() => {
|
||||
savedHandler.current = handler;
|
||||
}, [handler]);
|
||||
|
||||
useEffect(() => {
|
||||
const targetElement: T | Window = element ?? window;
|
||||
|
||||
if (!(targetElement && targetElement.addEventListener)) return;
|
||||
|
||||
const eventListener: typeof handler = (event) => savedHandler.current(event);
|
||||
|
||||
targetElement.addEventListener(eventName, eventListener);
|
||||
|
||||
return () => {
|
||||
targetElement.removeEventListener(eventName, eventListener);
|
||||
};
|
||||
}, [eventName, element]);
|
||||
}
|
||||
|
||||
export { useEventListener };
|
||||
@@ -0,0 +1,34 @@
|
||||
# useIsMobile
|
||||
|
||||
Returns `true` when the viewport width is below a configurable breakpoint, and re-evaluates reactively on resize.
|
||||
|
||||
## Signature
|
||||
|
||||
```ts
|
||||
function useIsMobile(mobileBreakpoint?: number): boolean;
|
||||
```
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
| ------------------ | -------- | ------- | ------------------------------------------------------------ |
|
||||
| `mobileBreakpoint` | `number` | `1024` | Viewport width (px) at which the device is considered mobile |
|
||||
|
||||
## Usage
|
||||
|
||||
```tsx
|
||||
function Navbar() {
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
return isMobile ? <MobileNav /> : <DesktopNav />;
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// Custom breakpoint
|
||||
const isTablet = useIsMobile(768);
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Uses `window.matchMedia` for the resize listener (more efficient than `resize` event polling).
|
||||
- Returns `false` during SSR (initial state is `undefined`, coerced via `!!`).
|
||||
- Client-only (`'use client'`).
|
||||
@@ -0,0 +1 @@
|
||||
export * from './useIsMobile';
|
||||
@@ -0,0 +1,19 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
export function useIsMobile(mobileBreakpoint: number = 1024) {
|
||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined);
|
||||
|
||||
React.useEffect(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${mobileBreakpoint - 1}px)`);
|
||||
const onChange = () => {
|
||||
setIsMobile(window.innerWidth < mobileBreakpoint);
|
||||
};
|
||||
mql.addEventListener('change', onChange);
|
||||
setIsMobile(window.innerWidth < mobileBreakpoint);
|
||||
return () => mql.removeEventListener('change', onChange);
|
||||
}, []);
|
||||
|
||||
return !!isMobile;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
# useMounted
|
||||
|
||||
Returns `true` after the component has mounted on the client. Useful for deferring client-only rendering to avoid SSR hydration mismatches.
|
||||
|
||||
## Signature
|
||||
|
||||
```ts
|
||||
function useMounted(): boolean;
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```tsx
|
||||
function ThemeToggle() {
|
||||
const mounted = useMounted();
|
||||
|
||||
if (!mounted) return null; // avoid SSR/hydration mismatch
|
||||
|
||||
return <button>{getCurrentTheme()}</button>;
|
||||
}
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Always returns `false` on the first render (SSR and initial client render), then `true` after `useEffect` fires.
|
||||
- Client-only (`'use client'`).
|
||||
@@ -0,0 +1 @@
|
||||
export * from './useMounted';
|
||||
@@ -0,0 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
export function useMounted() {
|
||||
const [mounted, setMounted] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
return mounted;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
# useOutsideClick
|
||||
|
||||
Detects clicks and touches outside a referenced element and calls a callback.
|
||||
|
||||
## Signature
|
||||
|
||||
```ts
|
||||
function useOutsideClick(callback: () => void): React.RefObject<HTMLDivElement>;
|
||||
```
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| ---------- | ------------ | ------------------------------------------------ |
|
||||
| `callback` | `() => void` | Called when a click/touch occurs outside the ref |
|
||||
|
||||
Returns a `ref` to attach to the element you want to monitor.
|
||||
|
||||
## Usage
|
||||
|
||||
```tsx
|
||||
function Dropdown({ onClose }: { onClose: () => void }) {
|
||||
const ref = useOutsideClick(onClose);
|
||||
|
||||
return (
|
||||
<div ref={ref}>
|
||||
<p>Dropdown content</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Listens to both `mouseup` and `touchend` to cover desktop and mobile.
|
||||
- If `callback` changes on every render, wrap it in `useCallback` to avoid resetting listeners unnecessarily.
|
||||
- Client-only (`'use client'`).
|
||||
@@ -0,0 +1 @@
|
||||
export * from './useOutsideClick';
|
||||
@@ -0,0 +1,25 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
export const useOutsideClick = (callback: () => void) => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent | TouchEvent) => {
|
||||
if (ref.current && !ref.current.contains(event.target as Node)) {
|
||||
callback();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mouseup', handleClickOutside);
|
||||
document.addEventListener('touchend', handleClickOutside);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mouseup', handleClickOutside);
|
||||
document.removeEventListener('touchend', handleClickOutside);
|
||||
};
|
||||
}, [callback]);
|
||||
|
||||
return ref;
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
# usePageMeta
|
||||
|
||||
Sets the page `<title>` and/or `<meta name="description">` while a component is mounted, then restores the previous values on unmount.
|
||||
|
||||
## Signature
|
||||
|
||||
```ts
|
||||
function usePageMeta(params: UsePageMetaParams): void;
|
||||
|
||||
interface UsePageMetaParams {
|
||||
title?: string;
|
||||
description?: string;
|
||||
}
|
||||
```
|
||||
|
||||
| Param | Type | Description |
|
||||
| ------------- | -------- | ------------------------------------------- |
|
||||
| `title` | `string` | Value to set as `document.title` |
|
||||
| `description` | `string` | Value to set in `<meta name="description">` |
|
||||
|
||||
Both params are optional — pass only the ones you need.
|
||||
|
||||
## Usage
|
||||
|
||||
```tsx
|
||||
function ArticlePage({ article }: { article: Article }) {
|
||||
usePageMeta({
|
||||
title: article.title,
|
||||
description: article.summary,
|
||||
});
|
||||
|
||||
return <main>{article.content}</main>;
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// Title only
|
||||
function DashboardPage() {
|
||||
usePageMeta({ title: 'Dashboard' });
|
||||
return <div />;
|
||||
}
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Restores the previous `document.title` and `<meta>` content when the component unmounts, so nested or sequential usage is safe.
|
||||
- If no `<meta name="description">` tag exists in the document, one is created and removed on cleanup.
|
||||
- Client-only (`'use client'`). Does not run during SSR.
|
||||
@@ -0,0 +1 @@
|
||||
export * from './usePageMeta';
|
||||
@@ -0,0 +1,45 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export interface UsePageMetaParams {
|
||||
title?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export function usePageMeta({ title, description }: UsePageMetaParams) {
|
||||
useEffect(() => {
|
||||
if (!title) return;
|
||||
|
||||
const previousTitle = document.title;
|
||||
document.title = title;
|
||||
|
||||
return () => {
|
||||
document.title = previousTitle;
|
||||
};
|
||||
}, [title]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!description) return;
|
||||
|
||||
let tag = document.querySelector<HTMLMetaElement>('meta[name="description"]');
|
||||
|
||||
const created = !tag;
|
||||
if (!tag) {
|
||||
tag = document.createElement('meta');
|
||||
tag.name = 'description';
|
||||
document.head.appendChild(tag);
|
||||
}
|
||||
|
||||
const previousContent = tag.content;
|
||||
tag.content = description;
|
||||
|
||||
return () => {
|
||||
if (created) {
|
||||
tag.remove();
|
||||
} else {
|
||||
tag.content = previousContent;
|
||||
}
|
||||
};
|
||||
}, [description]);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
# useScrollPosition
|
||||
|
||||
Tracks the vertical scroll position of the window or a specific scrollable element.
|
||||
|
||||
## Signature
|
||||
|
||||
```ts
|
||||
function useScrollPosition(options?: { targetRef?: RefObject<HTMLElement | Document> }): number;
|
||||
```
|
||||
|
||||
### Options
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
| ----------- | ------------------------------------ | ---------- | ------------------------------------------------------- |
|
||||
| `targetRef` | `RefObject<HTMLElement \| Document>` | `document` | Ref to a scrollable element; falls back to the document |
|
||||
|
||||
Returns the current `scrollY` (window) or `scrollTop` (element) as a number.
|
||||
|
||||
## Usage
|
||||
|
||||
```tsx
|
||||
// Track window scroll
|
||||
function ScrollProgress() {
|
||||
const scrollY = useScrollPosition();
|
||||
|
||||
return <div>Scrolled: {scrollY}px</div>;
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// Track a specific scrollable container
|
||||
function VirtualList() {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const scrollTop = useScrollPosition({ targetRef: containerRef });
|
||||
|
||||
return (
|
||||
<div ref={containerRef} style={{ overflowY: 'auto', height: 400 }}>
|
||||
{/* items */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Initial position is read synchronously on mount so there is no flash to `0`.
|
||||
- Automatically switches between `window.scrollY` and `element.scrollTop` based on the target.
|
||||
- Client-only (`'use client'`).
|
||||
@@ -0,0 +1 @@
|
||||
export * from './useScrollPosition';
|
||||
@@ -0,0 +1,34 @@
|
||||
'use client';
|
||||
|
||||
import { RefObject, useEffect, useState } from 'react';
|
||||
|
||||
interface UseScrollPositionProps {
|
||||
targetRef?: RefObject<HTMLElement | Document | undefined>; // Ref to the scrollable element
|
||||
}
|
||||
|
||||
export function useScrollPosition({ targetRef }: UseScrollPositionProps = {}): number {
|
||||
const [scrollPosition, setScrollPosition] = useState<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
// If the ref is not provided or its current value is null, fall back to document
|
||||
const target = targetRef?.current || document;
|
||||
const scrollable = target === document ? window : target;
|
||||
|
||||
const updatePosition = () => {
|
||||
// Determine if we're scrolling the document or a specific element
|
||||
const scrollY = target === document ? window.scrollY : (target as HTMLElement).scrollTop;
|
||||
setScrollPosition(scrollY);
|
||||
};
|
||||
|
||||
scrollable.addEventListener('scroll', updatePosition);
|
||||
|
||||
// Set the initial position
|
||||
updatePosition();
|
||||
|
||||
return () => {
|
||||
scrollable.removeEventListener('scroll', updatePosition);
|
||||
};
|
||||
}, [targetRef]);
|
||||
|
||||
return scrollPosition;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
# useViewport
|
||||
|
||||
Returns the current viewport dimensions `[height, width]` and updates them on window resize.
|
||||
|
||||
## Signature
|
||||
|
||||
```ts
|
||||
function useViewport(): [number, number];
|
||||
```
|
||||
|
||||
Returns a tuple `[height, width]` in pixels.
|
||||
|
||||
## Usage
|
||||
|
||||
```tsx
|
||||
function ResponsiveCanvas() {
|
||||
const [height, width] = useViewport();
|
||||
|
||||
return <canvas height={height} width={width} />;
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// Destructure with descriptive names
|
||||
const [viewportHeight, viewportWidth] = useViewport();
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Reads `window.innerHeight` and `window.innerWidth` — includes the scrollbar area.
|
||||
- Initialized from the current window size, so there is no flash to `[0, 0]`.
|
||||
- Client-only (`'use client'`). Will throw during SSR if accessed before mount — pair with `useMounted` if SSR is a concern.
|
||||
@@ -0,0 +1 @@
|
||||
export * from './useViewport';
|
||||
@@ -0,0 +1,23 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
type UseViewport = [number, number];
|
||||
|
||||
export function useViewport(): UseViewport {
|
||||
const [dimensions, setDimensions] = useState<UseViewport>([window.innerHeight, window.innerWidth]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = (): void => {
|
||||
setDimensions([window.innerHeight, window.innerWidth]);
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return dimensions;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2023", "DOM"],
|
||||
"skipLibCheck": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"composite": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
*.md
|
||||
node_modules
|
||||
dist
|
||||
public
|
||||
build
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"printWidth": 150,
|
||||
"tabWidth": 2,
|
||||
"endOfLine": "lf",
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"bracketSpacing": true,
|
||||
"semi": true,
|
||||
"bracketSameLine": false,
|
||||
"jsxSingleQuote": false,
|
||||
"arrowParens": "always",
|
||||
"importOrderCaseSensitive": false
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
# [←](../../../docs/README.md) @crux-kit/intl-date
|
||||
|
||||
Конфігурація форматів дат, парсинг, форматування
|
||||
|
||||
## Як використовувати
|
||||
|
||||
### Ініціалізація сервісу
|
||||
|
||||
```ts
|
||||
import { intlDateService, EN_LOCALE, UK_LOCALE } from '@crux-kit/intl-date';
|
||||
export const IntlDate = intlDateService(
|
||||
{
|
||||
localeMap: {
|
||||
en: {
|
||||
code: "enUS",
|
||||
locale: EN_LOCALE,
|
||||
displayFormats: {
|
||||
DATE: 'yyyy-MM-dd',
|
||||
...
|
||||
}
|
||||
},
|
||||
uk: {
|
||||
code: "uk",
|
||||
locale: UK_LOCALE,
|
||||
},
|
||||
},
|
||||
language: "uk",
|
||||
},
|
||||
console
|
||||
);
|
||||
```
|
||||
|
||||
1. `localeMap` - Налаштовує локалі
|
||||
|
||||
```ts
|
||||
/*
|
||||
код береться з бібліотеки date-fns
|
||||
import { enUS } from "date-fns/locale";
|
||||
code: enUS.code
|
||||
*/
|
||||
code: "enUS",
|
||||
/*
|
||||
locale береться з бібліотеки date-fns
|
||||
import { enUS } from "date-fns/locale";
|
||||
locale: enUS
|
||||
*/
|
||||
locale: EN_LOCALE,
|
||||
/*
|
||||
displayFormats перезаписує дефолтні формати відображення дати для конкретної мови
|
||||
*/
|
||||
displayFormats: {
|
||||
DATE: 'yyyy-MM-dd',
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
2. `language` - дефолтна мова
|
||||
|
||||
3. `displayFormat` - базовий формат відображення дати. Можна перезаписувати лише окремі поля
|
||||
|
||||
```ts
|
||||
export default {
|
||||
DAY_WITH_STRING_MONTH: 'dd MMMM',
|
||||
DAY_WITH_STRING_SHORT_MONTH: 'dd MMM',
|
||||
DATE: 'dd.MM.yyyy',
|
||||
DATE_WITH_STRING_SHORT_MONTH: 'dd MMM yyyy',
|
||||
DATE_WITH_STRING_MONTH: 'dd MMMM yyyy',
|
||||
DATE_TIME: 'dd.MM.yyyy hh:mm a',
|
||||
DATE_TIME_WITH_SECONDS: 'dd.MM.yyyy HH:mm:ss',
|
||||
DATE_TIME_WITH_STRING_MONTH: 'dd MMMM yyyy HH:mm',
|
||||
DATE_TIME_WITH_STRING_MONTH_WITH_SECONDS: 'dd MMMM yyyy HH:mm:ss',
|
||||
DATE_TIME_WITH_STRING_SHORT_MONTH: 'dd MMM yyyy HH:mm',
|
||||
DATE_TIME_WITH_STRING_SHORT_MONTH_WITH_SECONDS: 'dd MMM yyyy HH:mm:ss',
|
||||
TIME: 'HH:mm',
|
||||
TIME_WITH_SECONDS: 'HH:mm:ss',
|
||||
YEAR: 'yyyy',
|
||||
HOURS: 'HH',
|
||||
SHORT_HOURS: 'H',
|
||||
MINUTES: 'mm',
|
||||
SHORT_MINUTES: 'm',
|
||||
UNIVERSAL_ISO: 'UNIVERSAL_ISO',
|
||||
};
|
||||
```
|
||||
|
||||
4. `sourceFormat` - базові формати, що використовуються при парсингу зовнішніх даних API, form values і тд
|
||||
|
||||
```ts
|
||||
export default {
|
||||
DATE: 'yyyy-MM-dd',
|
||||
UNIVERSAL_ISO: 'UNIVERSAL_ISO',
|
||||
TIME: 'HH:mm',
|
||||
TIME_WITH_SECONDS: 'HH:mm:ss',
|
||||
DATE_TIME: 'yyyy-MM-dd HH:mm',
|
||||
DATE_TIME_WITH_SECONDS: 'yyyy-MM-dd HH:mm:ss',
|
||||
MINUTES_WITH_SECONDS: 'mm:ss',
|
||||
};
|
||||
```
|
||||
|
||||
### Форматування дати
|
||||
|
||||
```ts
|
||||
const date = '2025-11-27T13:17:02.174Z';
|
||||
|
||||
// "27 листопада 2025 13:17:02"
|
||||
IntlDate.parseSource(date, 'UNIVERSAL_ISO').formatToDisplay('DATE_TIME_WITH_STRING_MONTH_WITH_SECONDS');
|
||||
|
||||
// "27 листопада 2025 15:17:02"
|
||||
IntlDate.parseSource(date, 'UNIVERSAL_ISO').formatToDisplay('DATE_TIME_WITH_STRING_MONTH_WITH_SECONDS', { displayLocal: true });
|
||||
```
|
||||
|
||||
### Зміна мови
|
||||
|
||||
```ts
|
||||
IntlDate.setLocaleGlobally('en');
|
||||
```
|
||||
|
||||
Усі формати дати після цього підуть по новій локалі.
|
||||
Потрібно щоб локаль була у `localeMap`!!!
|
||||
|
||||
### Додаткові можливості
|
||||
|
||||
#### Мутація дати
|
||||
|
||||
додати/відняти дату/час від поточної дати
|
||||
|
||||
```ts
|
||||
const display = IntlDate.parseSource(date, 'UNIVERSAL_ISO')
|
||||
.subtract(2, 'years')
|
||||
.add(2, 'days')
|
||||
.formatToDisplay('DATE_TIME_WITH_STRING_MONTH_WITH_SECONDS', {
|
||||
displayLocal: true,
|
||||
});
|
||||
|
||||
type ManipulateUnit = 'years' | 'months' | 'weeks' | 'days' | 'hours' | 'minutes' | 'seconds';
|
||||
```
|
||||
|
||||
#### Мова
|
||||
|
||||
Попередньо налаштовані Укранська, Польська, Англійська мови
|
||||
|
||||
```ts
|
||||
import { EN_LOCALE_CONFIG, UK_LOCALE_CONFIG, PL_LOCALE_CONFIG } from '@crux-kit/intl-date';
|
||||
```
|
||||
Executable
+17
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "@crux-kit/intl-date",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"types": "src/index.ts",
|
||||
"private": true,
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"format": "prettier --write ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@crux-kit/types": "*"
|
||||
},
|
||||
"devDependencies": {}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
export const INTL_DATE_DISPLAY_FORMAT = {
|
||||
/**
|
||||
* @example 29 December
|
||||
*/
|
||||
DAY_WITH_STRING_MONTH: 'DAY_WITH_STRING_MONTH',
|
||||
/**
|
||||
* @example 29 Dec
|
||||
*/
|
||||
DAY_WITH_STRING_SHORT_MONTH: 'DAY_WITH_STRING_SHORT_MONTH',
|
||||
/**
|
||||
* @example 29.12.2024
|
||||
*/
|
||||
DATE: 'DATE',
|
||||
/**
|
||||
* @example 29 Dec 2024
|
||||
*/
|
||||
DATE_WITH_STRING_SHORT_MONTH: 'DATE_WITH_STRING_SHORT_MONTH',
|
||||
/**
|
||||
* @example 29 December 2000
|
||||
*/
|
||||
DATE_WITH_STRING_MONTH: 'DATE_WITH_STRING_MONTH',
|
||||
/**
|
||||
* @example 29.12.2024 20:30
|
||||
*/
|
||||
DATE_TIME: 'DATE_TIME',
|
||||
/**
|
||||
* @example 29.12.2024 20:30:43
|
||||
*/
|
||||
DATE_TIME_WITH_SECONDS: 'DATE_TIME_WITH_SECONDS',
|
||||
/**
|
||||
* @example 29 December 2024 20:30
|
||||
*/
|
||||
DATE_TIME_WITH_STRING_MONTH: 'DATE_TIME_WITH_STRING_MONTH',
|
||||
/**
|
||||
* @example 29 December 2024 20:30:23
|
||||
*/
|
||||
DATE_TIME_WITH_STRING_MONTH_WITH_SECONDS: 'DATE_TIME_WITH_STRING_MONTH_WITH_SECONDS',
|
||||
/**
|
||||
* @example 29 Dec 2024 20:30
|
||||
*/
|
||||
DATE_TIME_WITH_STRING_SHORT_MONTH: 'DATE_TIME_WITH_STRING_SHORT_MONTH',
|
||||
/**
|
||||
* @example 29 Dec 2024 20:30:23
|
||||
*/
|
||||
DATE_TIME_WITH_STRING_SHORT_MONTH_WITH_SECONDS: 'DATE_TIME_WITH_STRING_SHORT_MONTH_WITH_SECONDS',
|
||||
/**
|
||||
* @example 20:30
|
||||
*/
|
||||
TIME: 'TIME',
|
||||
/**
|
||||
* @example 20:30:59
|
||||
*/
|
||||
TIME_WITH_SECONDS: 'TIME_WITH_SECONDS',
|
||||
/**
|
||||
* @example 2024
|
||||
*/
|
||||
YEAR: 'YEAR',
|
||||
/**
|
||||
* @example 23
|
||||
*/
|
||||
HOURS: 'HOURS',
|
||||
/**
|
||||
* @example 5
|
||||
*/
|
||||
SHORT_HOURS: 'SHORT_HOURS',
|
||||
/**
|
||||
* @example 04
|
||||
*/
|
||||
MINUTES: 'MINUTES',
|
||||
/**
|
||||
* @example 1
|
||||
*/
|
||||
SHORT_MINUTES: 'SHORT_MINUTES',
|
||||
/**
|
||||
* @example 2024-06-03T12:47:04.000000Z
|
||||
*/
|
||||
UNIVERSAL_ISO: 'UNIVERSAL_ISO',
|
||||
} as const;
|
||||
|
||||
export type IntlDateDisplayFormatEnum = (typeof INTL_DATE_DISPLAY_FORMAT)[keyof typeof INTL_DATE_DISPLAY_FORMAT];
|
||||
|
||||
export const INTL_DATE_SOURCE_FORMAT = {
|
||||
/**
|
||||
* @example 2023-11-27
|
||||
*/
|
||||
DATE: 'DATE',
|
||||
/**
|
||||
* @example 2024-06-03T12:47:04.000000Z
|
||||
*/
|
||||
UNIVERSAL_ISO: 'UNIVERSAL_ISO',
|
||||
/**
|
||||
* @example 20:40
|
||||
*/
|
||||
TIME: 'TIME',
|
||||
/**
|
||||
* @example 20:40:33
|
||||
*/
|
||||
TIME_WITH_SECONDS: 'TIME_WITH_SECONDS',
|
||||
/**
|
||||
* @example 2023-11-27 20:40
|
||||
*/
|
||||
DATE_TIME: 'DATE_TIME',
|
||||
/**
|
||||
* @example 2023-11-27 20:40:33
|
||||
*/
|
||||
DATE_TIME_WITH_SECONDS: 'DATE_TIME_WITH_SECONDS',
|
||||
// 40:59
|
||||
/**
|
||||
* @example 40:59
|
||||
*/
|
||||
MINUTES_WITH_SECONDS: 'MINUTES_WITH_SECONDS',
|
||||
} as const;
|
||||
|
||||
export type IntlDateSourceFormatEnum = (typeof INTL_DATE_SOURCE_FORMAT)[keyof typeof INTL_DATE_SOURCE_FORMAT];
|
||||
@@ -0,0 +1,6 @@
|
||||
import SOURCE_FORMAT from './source-format';
|
||||
import UNITS_MAP from './units-map';
|
||||
|
||||
export * from './enums';
|
||||
export * from './system-timezone';
|
||||
export { SOURCE_FORMAT, UNITS_MAP };
|
||||
@@ -0,0 +1,9 @@
|
||||
export default {
|
||||
DATE: 'yyyy-MM-dd',
|
||||
UNIVERSAL_ISO: 'UNIVERSAL_ISO',
|
||||
TIME: 'HH:mm',
|
||||
TIME_WITH_SECONDS: 'HH:mm:ss',
|
||||
DATE_TIME: 'yyyy-MM-dd HH:mm',
|
||||
DATE_TIME_WITH_SECONDS: 'yyyy-MM-dd HH:mm:ss',
|
||||
MINUTES_WITH_SECONDS: 'mm:ss',
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export const SYSTEM_TZ = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
@@ -0,0 +1,9 @@
|
||||
export default {
|
||||
years: undefined,
|
||||
months: undefined,
|
||||
weeks: undefined,
|
||||
days: undefined,
|
||||
hours: undefined,
|
||||
minutes: undefined,
|
||||
seconds: undefined,
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user