@@ -0,0 +1,11 @@ | |||||
root = true | |||||
[*] | |||||
charset = utf-8 | |||||
end_of_line = lf | |||||
indent_size = 2 | |||||
indent_style = space | |||||
insert_final_newline = true | |||||
max_line_length = 120 | |||||
tab_width = 8 | |||||
trim_trailing_whitespace = true |
@@ -0,0 +1,92 @@ | |||||
.DS_Store | |||||
.AppleDouble | |||||
.LSOverride | |||||
._* | |||||
.DocumentRevisions-V100 | |||||
.fseventsd | |||||
.Spotlight-V100 | |||||
.TemporaryItems | |||||
.Trashes | |||||
.VolumeIcon.icns | |||||
.com.apple.timemachine.donotpresent | |||||
.AppleDB | |||||
.AppleDesktop | |||||
Network Trash Folder | |||||
Temporary Items | |||||
.apdisk | |||||
.idea/ | |||||
cmake-build-*/ | |||||
*.iws | |||||
out/ | |||||
.idea_modules/ | |||||
atlassian-ide-plugin.xml | |||||
com_crashlytics_export_strings.xml | |||||
crashlytics.properties | |||||
crashlytics-build.properties | |||||
fabric.properties | |||||
.vscode/ | |||||
*.code-workspace | |||||
.history/ | |||||
Thumbs.db | |||||
Thumbs.db:encryptable | |||||
ehthumbs.db | |||||
ehthumbs_vista.db | |||||
*.stackdump | |||||
[Dd]esktop.ini | |||||
$RECYCLE.BIN/ | |||||
*.cab | |||||
*.msi | |||||
*.msix | |||||
*.msm | |||||
*.msp | |||||
*.lnk | |||||
logs | |||||
*.log | |||||
npm-debug.log* | |||||
yarn-debug.log* | |||||
yarn-error.log* | |||||
lerna-debug.log* | |||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json | |||||
pids | |||||
*.pid | |||||
*.seed | |||||
*.pid.lock | |||||
lib-cov | |||||
coverage | |||||
*.lcov | |||||
.nyc_output | |||||
.grunt | |||||
bower_components | |||||
.lock-wscript | |||||
build/Release | |||||
node_modules/ | |||||
jspm_packages/ | |||||
web_modules/ | |||||
*.tsbuildinfo | |||||
.npm | |||||
.eslintcache | |||||
.rpt2_cache/ | |||||
.rts2_cache_cjs/ | |||||
.rts2_cache_es/ | |||||
.rts2_cache_umd/ | |||||
.node_repl_history | |||||
*.tgz | |||||
.yarn-integrity | |||||
.env | |||||
.env.test | |||||
.cache | |||||
.parcel-cache | |||||
.next | |||||
.nuxt | |||||
dist | |||||
.cache/ | |||||
.vuepress/dist | |||||
.serverless/ | |||||
.fusebox/ | |||||
.dynamodb/ | |||||
.tern-port | |||||
.vscode-test | |||||
.yarn/cache | |||||
.yarn/unplugged | |||||
.yarn/build-state.yml | |||||
.pnp.* |
@@ -0,0 +1,11 @@ | |||||
{ | |||||
"printWidth": 120, | |||||
"semi": false, | |||||
"singleQuote": true, | |||||
"jsxSingleQuote": false, | |||||
"trailingComma": "all", | |||||
"arrowParens": "always", | |||||
"jsxBracketSameLine": false, | |||||
"quoteProps": "as-needed", | |||||
"endOfLine": "lf" | |||||
} |
@@ -0,0 +1,24 @@ | |||||
module.exports = { | |||||
stories: ['../src/**/*.stories.(ts|tsx)'], | |||||
addons: ['@storybook/addon-actions', '@storybook/addon-links', '@storybook/addon-docs'], | |||||
webpackFinal: async (config) => { | |||||
config.module.rules.push({ | |||||
test: /\.(ts|tsx)$/, | |||||
use: [ | |||||
{ | |||||
loader: require.resolve('ts-loader'), | |||||
options: { | |||||
transpileOnly: true, | |||||
}, | |||||
}, | |||||
{ | |||||
loader: require.resolve('react-docgen-typescript-loader'), | |||||
}, | |||||
], | |||||
}); | |||||
config.resolve.extensions.push('.ts', '.tsx'); | |||||
return config; | |||||
}, | |||||
}; |
@@ -0,0 +1,21 @@ | |||||
MIT License | |||||
Copyright (c) 2020 TheoryOfNekomata | |||||
Permission is hereby granted, free of charge, to any person obtaining a copy | |||||
of this software and associated documentation files (the "Software"), to deal | |||||
in the Software without restriction, including without limitation the rights | |||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | |||||
copies of the Software, and to permit persons to whom the Software is | |||||
furnished to do so, subject to the following conditions: | |||||
The above copyright notice and this permission notice shall be included in all | |||||
copies or substantial portions of the Software. | |||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | |||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | |||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | |||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | |||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | |||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | |||||
SOFTWARE. |
@@ -0,0 +1,35 @@ | |||||
# Musical Keyboard | |||||
Musical keyboard component written in React. | |||||
## Installation | |||||
```shell script | |||||
yarn add @theoryofnekomata/react-musical-keyboard | |||||
``` | |||||
## Usage | |||||
```jsx harmony | |||||
import * as React from 'react' | |||||
import ReactDOM from 'react-dom' | |||||
import Keyboard from '@theoryofnekomata/react-musical-keyboard' | |||||
const App = () => { | |||||
return ( | |||||
<div> | |||||
<Keyboard startKey={21} endKey={108}/> | |||||
</div> | |||||
) | |||||
} | |||||
const container = window.document.createElement('div') | |||||
ReactDOM.render(<App />, container) | |||||
window.document.body.appendChild(container) | |||||
``` | |||||
## License | |||||
MIT. See [License file](./LICENSE) for details. |
@@ -0,0 +1,59 @@ | |||||
{ | |||||
"version": "0.0.0", | |||||
"license": "MIT", | |||||
"main": "dist/index.js", | |||||
"typings": "dist/index.d.ts", | |||||
"files": [ | |||||
"dist", | |||||
"src" | |||||
], | |||||
"engines": { | |||||
"node": ">=10" | |||||
}, | |||||
"scripts": { | |||||
"start": "tsdx watch", | |||||
"build": "tsdx build", | |||||
"test": "tsdx test", | |||||
"lint": "tsdx lint", | |||||
"prepare": "tsdx build", | |||||
"storybook": "start-storybook -p 6006", | |||||
"build-storybook": "build-storybook" | |||||
}, | |||||
"peerDependencies": { | |||||
"react": ">=16" | |||||
}, | |||||
"husky": { | |||||
"hooks": { | |||||
"pre-commit": "tsdx lint" | |||||
} | |||||
}, | |||||
"name": "@theoryofnekomata/react-musical-keyboard", | |||||
"author": "TheoryOfNekomata <allan.crisostomo@outlook.com>", | |||||
"module": "dist/react-musical-keyboard.esm.js", | |||||
"devDependencies": { | |||||
"@babel/core": "^7.11.1", | |||||
"@storybook/addon-actions": "^5.3.19", | |||||
"@storybook/addon-docs": "^5.3.19", | |||||
"@storybook/addon-info": "^5.3.19", | |||||
"@storybook/addon-links": "^5.3.19", | |||||
"@storybook/addons": "^5.3.19", | |||||
"@storybook/react": "^5.3.19", | |||||
"@types/prop-types": "^15.7.3", | |||||
"@types/react": "^16.9.44", | |||||
"@types/react-dom": "^16.9.8", | |||||
"@types/styled-components": "^5.1.2", | |||||
"babel-loader": "^8.1.0", | |||||
"fast-check": "^2.0.0", | |||||
"husky": "^4.2.5", | |||||
"prop-types": "^15.7.2", | |||||
"react": "^16.13.1", | |||||
"react-docgen-typescript-loader": "^3.7.2", | |||||
"react-dom": "^16.13.1", | |||||
"react-is": "^16.13.1", | |||||
"styled-components": "^5.1.1", | |||||
"ts-loader": "^8.0.2", | |||||
"tsdx": "^0.13.2", | |||||
"tslib": "^2.0.0", | |||||
"typescript": "^3.9.7" | |||||
} | |||||
} |
@@ -0,0 +1,13 @@ | |||||
import * as React from 'react' | |||||
import * as PropTypes from 'prop-types' | |||||
import Keyboard, { propTypes, } from './Keyboard' | |||||
export default { | |||||
title: 'Keyboard', | |||||
} | |||||
type Props = PropTypes.InferProps<typeof propTypes> | |||||
// By passing optional props to this story, you can control the props of the component when | |||||
// you consume the story in a test. | |||||
export const Default = (props?: Partial<Props>) => <Keyboard {...props} />; |
@@ -0,0 +1,9 @@ | |||||
import * as React from 'react' | |||||
import * as ReactDOM from 'react-dom' | |||||
import { Default as Keyboard } from './Keyboard.stories'; | |||||
it('renders without crashing', () => { | |||||
const div = document.createElement('div'); | |||||
ReactDOM.render(<Keyboard startKey={21} endKey={108}/>, div); | |||||
ReactDOM.unmountComponentAtNode(div); | |||||
}); |
@@ -0,0 +1,141 @@ | |||||
import * as React from 'react' | |||||
import * as PropTypes from 'prop-types' | |||||
import styled from 'styled-components' | |||||
import isNaturalKey from '../services/isNaturalKey' | |||||
import getKeyWidth from '../services/getKeyWidth' | |||||
import getKeyLeft from '../services/getKeyLeft' | |||||
import generateKeys from '../services/generateKeys' | |||||
const Base = styled('div')({ | |||||
position: 'relative', | |||||
}) | |||||
const Key = styled('div')({ | |||||
position: 'absolute', | |||||
border: '1px solid', | |||||
boxSizing: 'border-box', | |||||
top: 0, | |||||
}) | |||||
const NaturalKey = styled(Key)({ | |||||
zIndex: 0, | |||||
backgroundColor: 'transparent', | |||||
}) | |||||
const AccidentalKey = styled(Key)({ | |||||
zIndex: 2, | |||||
backgroundColor: 'currentColor', | |||||
}) | |||||
const Highlight = styled('div')({ | |||||
width: '100%', | |||||
height: '100%', | |||||
}) | |||||
export const propTypes = { | |||||
startKey: PropTypes.number.isRequired, | |||||
endKey: PropTypes.number.isRequired, | |||||
//octaveDivision: PropTypes.number, | |||||
accidentalKeyLengthRatio: PropTypes.number, | |||||
keyChannels: PropTypes.arrayOf(PropTypes.shape({ | |||||
channel: PropTypes.number.isRequired, | |||||
key: PropTypes.number.isRequired, | |||||
velocity: PropTypes.number.isRequired, | |||||
})), | |||||
channelColors: PropTypes.arrayOf(PropTypes.string), | |||||
width: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), | |||||
height: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), | |||||
} | |||||
const DEFAULT_CHANNEL_COLORS = [ | |||||
'#ff5555', | |||||
'#ffff00', | |||||
'#00aa00', | |||||
'#0055aa', | |||||
'#aa00ff', | |||||
'#aa0000', | |||||
'#aa5500', | |||||
'#ffaa00', | |||||
'#00ff00', | |||||
'#00aaaa', | |||||
'#00ffff', | |||||
'#ff00aa', | |||||
'#aaaa00', | |||||
'#555500', | |||||
'#5500aa', | |||||
'#ff55ff', | |||||
] | |||||
type Props = PropTypes.InferProps<typeof propTypes> | |||||
const Keyboard: React.FC<Props> = ({ | |||||
startKey, | |||||
endKey, | |||||
//octaveDivision = 12, | |||||
accidentalKeyLengthRatio = 0.65, | |||||
keyChannels = [], | |||||
channelColors = DEFAULT_CHANNEL_COLORS, | |||||
width = '100%', | |||||
height = 64, | |||||
}) => { | |||||
const [keys, setKeys, ] = React.useState<number[]>([]) | |||||
React.useEffect(() => { | |||||
setKeys(generateKeys(startKey!, endKey!)) | |||||
}, [startKey, endKey, ]) | |||||
return ( | |||||
<Base | |||||
style={{ | |||||
width: width!, | |||||
height: height!, | |||||
}} | |||||
> | |||||
{keys.map(k => { | |||||
const isNatural = isNaturalKey(k) | |||||
const Component = isNatural ? NaturalKey : AccidentalKey | |||||
const width = getKeyWidth(startKey!, endKey!)(k) | |||||
const height = isNatural ? 100 : 100 * accidentalKeyLengthRatio! | |||||
const left = getKeyLeft(startKey!, endKey!)(k) | |||||
const currentKeyChannels = ( | |||||
Array.isArray(keyChannels!) | |||||
? keyChannels.filter(kc => kc!.key === k) | |||||
: null | |||||
) | |||||
return ( | |||||
<Component | |||||
style={{ | |||||
width: width + '%', | |||||
height: height + '%', | |||||
left: left + '%', | |||||
}} | |||||
> | |||||
{ | |||||
Array.isArray(currentKeyChannels) | |||||
&& currentKeyChannels.map(c => ( | |||||
<Highlight | |||||
key={c!.channel} | |||||
style={{ | |||||
backgroundColor: channelColors![c!.channel] as string, | |||||
}} | |||||
/> | |||||
)) | |||||
} | |||||
</Component> | |||||
) | |||||
})} | |||||
</Base> | |||||
) | |||||
} | |||||
Keyboard.propTypes = propTypes | |||||
export default Keyboard |
@@ -0,0 +1,3 @@ | |||||
import Keyboard from './components/Keyboard' | |||||
export default Keyboard |
@@ -0,0 +1,36 @@ | |||||
/* | |||||
4 | |||||
+-----------------------------------+ | |||||
3 | |||||
+--------------------------+ | |||||
* * * * * * * * * * ** | |||||
+----+-----+----+-----+----+---+-----+----+-----+----+-----+---+ | |||||
| | | | | | | | | | | | | | |||||
| | | | | | | | | | | | | | |||||
| | | | | | | | | | | | | | |||||
| | | | | | | | | | | | | | |||||
| | | | | | | | | | | | | | |||||
| +---+-+ +-+---+ | +----++ +--+--+ ++---++ | | |||||
| | | | | | | | | |||||
| | | | | | | | | |||||
| | | | | | | | | |||||
+--------+--------+--------+--------+--------+--------+--------+ | |||||
*/ | |||||
export default [ | |||||
0, // C | |||||
3 / 7 / 5, // C# | |||||
1 / 7, // D | |||||
3 / 7 / 5 * 3, // D# | |||||
2 / 7, // E | |||||
3 / 7, // F | |||||
(3 / 7) + (4 / 7 / 7), // F# | |||||
4 / 7, // G | |||||
(3 / 7) + (4 / 7 / 7 * 3), // G# | |||||
5 / 7, // A | |||||
(3 / 7) + (4 / 7 / 7 * 5), // A# | |||||
6 / 7, // B | |||||
] |
@@ -0,0 +1,11 @@ | |||||
interface GenerateKeys { | |||||
(startKey: number, endKey: number): number[], | |||||
} | |||||
const generateKeys: GenerateKeys = (startKey, endKey) => ( | |||||
Array(endKey! - startKey! + 1) | |||||
.fill(0) | |||||
.map((_, i) => startKey! + i) | |||||
) | |||||
export default generateKeys |
@@ -0,0 +1,49 @@ | |||||
import getKeyXOffset from './getKeyXOffset' | |||||
import getOctaveCount from './getOctaveCount' | |||||
import generateKeys from './generateKeys' | |||||
import groupKeysIntoOctaves from './groupKeysIntoOctaves' | |||||
import getOctaveCompleteness from './getOctaveCompleteness' | |||||
interface GetKeyLeft { | |||||
(k: number): number, | |||||
} | |||||
interface GetKeyLeftDecorator { | |||||
(startKey: number, endKey: number): GetKeyLeft, | |||||
} | |||||
const getKeyLeftDecorator: GetKeyLeftDecorator = (startKey, endKey): GetKeyLeft => (k) => { | |||||
const dummyKeys = generateKeys(startKey, endKey) | |||||
const keysGroupedIntoOctaves = groupKeysIntoOctaves(dummyKeys) | |||||
const octaveCompleteness = Object | |||||
.entries(keysGroupedIntoOctaves) | |||||
.map<number[]>(([octave, keys]) => [ | |||||
octave as unknown as number, | |||||
keys[0], | |||||
keys.slice(-1)[0], | |||||
]) | |||||
.reduce<Record<number, number>>( | |||||
(theOctaveCompleteness, [octave, firstKey, lastKey]) => ({ | |||||
...theOctaveCompleteness, | |||||
[octave]: getOctaveCompleteness(firstKey, lastKey), | |||||
}), | |||||
{} | |||||
) | |||||
const fractionalOctaveCount = Object | |||||
.values(octaveCompleteness) | |||||
.reduce( | |||||
(a, b) => a + b, | |||||
0 | |||||
) | |||||
const octaveCount = getOctaveCount(startKey, endKey) | |||||
const startOctave = Math.floor(startKey! / 12) | |||||
const octave = Math.floor(k / 12) | |||||
const octaveOffset = (100 * octaveCount / fractionalOctaveCount) / octaveCount * (octave - startOctave) | |||||
const theKeyOffset = octaveOffset + (100 * octaveCount / fractionalOctaveCount / octaveCount * getKeyXOffset(k)) | |||||
const firstKeyOffset = (100 * octaveCount / fractionalOctaveCount / octaveCount * getKeyXOffset(startKey + 12)) | |||||
return theKeyOffset - firstKeyOffset | |||||
} | |||||
export default getKeyLeftDecorator |
@@ -0,0 +1,46 @@ | |||||
import isNaturalKey from './isNaturalKey' | |||||
import groupKeysIntoOctaves from './groupKeysIntoOctaves' | |||||
import getOctaveCompleteness from './getOctaveCompleteness' | |||||
import getOctaveCount from './getOctaveCount' | |||||
import generateKeys from './generateKeys' | |||||
interface GetKeyWidth { | |||||
(k: number): number, | |||||
} | |||||
interface GetKeyWidthDecorator { | |||||
(startKey: number, endKey: number): GetKeyWidth, | |||||
} | |||||
const ACCIDENTAL_KEY_TO_NATURAL_KEY_WIDTH_RATIO = 18 / 36 | |||||
const getKeyWidthDecorator: GetKeyWidthDecorator = (startKey, endKey): GetKeyWidth => (k) => { | |||||
const dummyKeys = generateKeys(startKey, endKey) | |||||
const keysGroupedIntoOctaves = groupKeysIntoOctaves(dummyKeys) | |||||
const octaveCompleteness = Object | |||||
.entries(keysGroupedIntoOctaves) | |||||
.map<number[]>(([octave, keys]) => [ | |||||
octave as unknown as number, | |||||
keys[0], | |||||
keys.slice(-1)[0], | |||||
]) | |||||
.reduce<Record<number, number>>( | |||||
(theOctaveCompleteness, [octave, firstKey, lastKey]) => ({ | |||||
...theOctaveCompleteness, | |||||
[octave]: getOctaveCompleteness(firstKey, lastKey), | |||||
}), | |||||
{} | |||||
) | |||||
const fractionalOctaveCount = Object | |||||
.values(octaveCompleteness) | |||||
.reduce( | |||||
(a, b) => a + b, | |||||
0 | |||||
) | |||||
const octaveCount = getOctaveCount(startKey, endKey) | |||||
const naturalKeyWidth = 100 * (octaveCount / fractionalOctaveCount) / (octaveCount * 7) | |||||
return isNaturalKey(k) ? naturalKeyWidth : naturalKeyWidth * ACCIDENTAL_KEY_TO_NATURAL_KEY_WIDTH_RATIO // naturalKeyWidth * 13.7 / 23.5} | |||||
} | |||||
export default getKeyWidthDecorator |
@@ -0,0 +1,5 @@ | |||||
import KEY_OFFSETS from './constants/keyOffsets' | |||||
export default (k: number): number => { | |||||
return KEY_OFFSETS[k % 12] | |||||
} |
@@ -0,0 +1,10 @@ | |||||
import getKeyXOffset from './getKeyXOffset' | |||||
import isNaturalKey from './isNaturalKey' | |||||
// expect firstKey and lastKey within the same octave | |||||
export default (firstKey: number, lastKey: number) => ( | |||||
// see if there are missing higher notes | |||||
getKeyXOffset(lastKey) + (isNaturalKey(lastKey) ? 1 / 7 : 1 / 7 * 18/36) | |||||
// see if there are missing lower notes | |||||
- getKeyXOffset(firstKey) | |||||
) |
@@ -0,0 +1,11 @@ | |||||
interface GetOctaveCount { | |||||
(startKey: number, endKey: number): number, | |||||
} | |||||
const getOctaveCount: GetOctaveCount = (startKey, endKey) => { | |||||
const startOctave = Math.floor(startKey / 12) | |||||
const endOctave = Math.floor(endKey / 12) | |||||
return endOctave - startOctave + 1 | |||||
} | |||||
export default getOctaveCount |
@@ -0,0 +1,15 @@ | |||||
export default (dummyKeys: number[]): Record<number, number[]> => ( | |||||
dummyKeys | |||||
.map(k => [k, Math.floor(k / 12)]) | |||||
.reduce<Record<number, number[]>>( | |||||
(theOctaves, [key, keyOctave, ]) => ({ | |||||
...theOctaves, | |||||
[keyOctave]: ( | |||||
Array.isArray(theOctaves[keyOctave]) | |||||
? [...theOctaves[keyOctave], key] | |||||
: [key] | |||||
) | |||||
}), | |||||
{} | |||||
) | |||||
) |
@@ -0,0 +1,76 @@ | |||||
import * as fc from 'fast-check' | |||||
import isNaturalKey from './isNaturalKey' | |||||
it('should exist', () => { | |||||
expect(isNaturalKey).toBeDefined() | |||||
}) | |||||
it('should be a callable', () => { | |||||
expect(typeof isNaturalKey).toBe('function') | |||||
}) | |||||
it('should accept 1 parameter', () => { | |||||
expect(isNaturalKey).toHaveLength(1) | |||||
}) | |||||
it('should throw TypeError upon passing invalid types', () => { | |||||
fc.assert( | |||||
fc.property( | |||||
fc.anything().filter(anything => typeof anything !== 'number'), | |||||
anything => { | |||||
expect(() => isNaturalKey(anything as number)).toThrowError(TypeError) | |||||
} | |||||
) | |||||
) | |||||
}) | |||||
it('should throw RangeError upon passing NaN', () => { | |||||
expect(() => isNaturalKey(NaN)).toThrowError(RangeError) | |||||
}) | |||||
it('should throw RangeError upon passing negative numbers', () => { | |||||
fc.assert( | |||||
fc.property( | |||||
fc.anything().filter(anything => ( | |||||
typeof anything! === 'number' | |||||
&& !isNaN(anything) | |||||
&& anything < 0 | |||||
)), | |||||
negativeValue => { | |||||
expect(() => isNaturalKey(negativeValue as number)).toThrowError(RangeError) | |||||
} | |||||
) | |||||
) | |||||
}) | |||||
describe('upon passing a positive number or zero', () => { | |||||
it('should not throw any error', () => { | |||||
fc.assert( | |||||
fc.property( | |||||
fc.anything().filter(anything => ( | |||||
typeof anything! === 'number' | |||||
&& !isNaN(anything) | |||||
&& anything >= 0 | |||||
)), | |||||
value => { | |||||
expect(() => isNaturalKey(value as number)).not.toThrow() | |||||
} | |||||
) | |||||
) | |||||
}) | |||||
it('should return a boolean', () => { | |||||
fc.assert( | |||||
fc.property( | |||||
fc.anything().filter(anything => ( | |||||
typeof anything! === 'number' | |||||
&& !isNaN(anything) | |||||
&& anything >= 0 | |||||
)), | |||||
value => { | |||||
expect(typeof isNaturalKey(value as number)).toBe('boolean') | |||||
} | |||||
) | |||||
) | |||||
}) | |||||
}) |
@@ -0,0 +1,15 @@ | |||||
const NATURAL_KEYS = [0, 2, 4, 5, 7, 9, 11] | |||||
export default (k: number): boolean => { | |||||
const type = typeof (k as unknown) | |||||
if (type as string !== 'number') { | |||||
throw TypeError(`Invalid value type passed to isNaturalKey, expected 'number', got ${type}.`) | |||||
} | |||||
if (isNaN(k)) { | |||||
throw RangeError('Value passed is NaN.') | |||||
} | |||||
if (k < 0) { | |||||
throw RangeError('Value must be positive.') | |||||
} | |||||
return NATURAL_KEYS.includes(Math.floor(k) % 12) | |||||
} |
@@ -0,0 +1,21 @@ | |||||
{ | |||||
"include": ["src", "types"], | |||||
"compilerOptions": { | |||||
"module": "esnext", | |||||
"lib": ["dom", "esnext"], | |||||
"importHelpers": true, | |||||
"declaration": true, | |||||
"sourceMap": true, | |||||
"rootDir": "./src", | |||||
"strict": true, | |||||
"noUnusedLocals": true, | |||||
"noUnusedParameters": true, | |||||
"noImplicitReturns": true, | |||||
"noFallthroughCasesInSwitch": true, | |||||
"allowSyntheticDefaultImports": true, | |||||
"moduleResolution": "node", | |||||
"baseUrl": "./", | |||||
"jsx": "react", | |||||
"esModuleInterop": true | |||||
} | |||||
} |