diff --git a/.storybook/DocsContainer.tsx b/.storybook/DocsContainer.tsx new file mode 100644 index 0000000..9f7aa4d --- /dev/null +++ b/.storybook/DocsContainer.tsx @@ -0,0 +1,23 @@ +import { ComponentProps, useEffect, useState } from 'react'; +import { DocsContainer as BaseDocsContainer } from '@storybook/addon-docs/blocks'; +import { themes } from 'storybook/theming'; + +const DARK_THEME_KEYS = ['dark', 'midnight']; + +const getIsDark = () => ( + DARK_THEME_KEYS.includes(document.documentElement.getAttribute('data-theme') ?? '') +); + +export const DocsContainer = (props: ComponentProps) => { + const [isDark, setIsDark] = useState(getIsDark); + + useEffect(() => { + const observer = new MutationObserver(() => setIsDark(getIsDark())); + observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] }); + return () => observer.disconnect(); + }, []); + + return ( + + ); +}; diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 9b05862..41e84bf 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -2,6 +2,7 @@ import type { Preview } from '@storybook/react'; import { SYSTEM_THEME_KEY, ThemeProvider } from '../src/components/ThemeProvider'; +import { DocsContainer } from './DocsContainer'; import '../src/style/index.scss'; import './preview.css'; @@ -36,6 +37,9 @@ const preview: Preview = { }, ], parameters: { + docs: { + container: DocsContainer, + }, controls: { matchers: { color: /(background|color)$/i, diff --git a/.vscode/settings.json b/.vscode/settings.json index 092b24f..710a9f2 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -5,6 +5,7 @@ "colorjs", "mapbox", "maplibre", + "nums", "oklch" ] } diff --git a/package-lock.json b/package-lock.json index 1bc3577..4f31332 100644 --- a/package-lock.json +++ b/package-lock.json @@ -69,26 +69,6 @@ "react-dom": ">=18.0.0" } }, - "../eslint-config": { - "name": "@ianpaschal/eslint-config", - "version": "1.0.0", - "extraneous": true, - "license": "MIT", - "dependencies": { - "@eslint/js": "^9.0.0", - "@stylistic/eslint-plugin": "^4.0.0", - "eslint-plugin-import-newlines": "^1.4.0", - "eslint-plugin-react": "^7.37.5", - "eslint-plugin-react-hooks": "^5.0.0", - "eslint-plugin-react-refresh": "^0.4.7", - "eslint-plugin-simple-import-sort": "^12.1.1", - "globals": "^16.0.0", - "typescript-eslint": "^8.0.0" - }, - "peerDependencies": { - "eslint": "^9.0.0" - } - }, "node_modules/@adobe/css-tools": { "version": "4.4.4", "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", @@ -181,7 +161,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" }, @@ -205,7 +184,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" } @@ -275,7 +253,6 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -675,7 +652,6 @@ "integrity": "sha512-eohl3hKTiVyD1ilYdw9T0OiB4hnjef89e3dMYKz+mVKDzj+5IteTseASUsOB+EU9Tf6VNTCjDePcP6wkDGmLKQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@keyv/serialize": "^1.1.1" } @@ -737,7 +713,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -786,7 +761,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -842,8 +816,7 @@ "version": "1.4.1", "resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.4.1.tgz", "integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@dual-bundle/import-meta-resolve": { "version": "4.2.1", @@ -2996,7 +2969,6 @@ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -3559,7 +3531,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -3715,7 +3688,6 @@ "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -3731,7 +3703,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -3743,7 +3714,6 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -3803,7 +3773,6 @@ "integrity": "sha512-30ScMRHIAD33JJQkgfGW1t8CURZtjc2JpTrq5n2HFhOefbAhb7ucc7xJwdWcrEtqUIYJ73Nybpsggii6GtAHjA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.57.2", "@typescript-eslint/types": "8.57.2", @@ -4517,7 +4486,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5017,7 +4985,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.11.20", "caniuse-lite": "^1.0.30001810", @@ -5531,7 +5498,6 @@ "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/kossnocorp" @@ -5769,7 +5735,8 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/dompurify": { "version": "3.4.15", @@ -6050,7 +6017,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -6115,7 +6081,6 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -6732,14 +6697,6 @@ "node": ">=14.14" } }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true, - "license": "ISC", - "optional": true - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -7387,27 +7344,6 @@ "node": ">=8" } }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "dev": true, - "license": "ISC", - "optional": true, - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "node_modules/inherits": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", - "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==", - "dev": true, - "license": "ISC", - "optional": true - }, "node_modules/ini": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", @@ -8397,6 +8333,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -8464,7 +8401,6 @@ "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-6.9.1.tgz", "integrity": "sha512-piG2jNSq7crSTzj67UWDlTaxDjWvhFpfubZUuaofmaMcQKWw+kYn7mCfeh6S/Egr8+q8prBWcDqEaa6DdIQ6pw==", "license": "BSD-3-Clause", - "peer": true, "dependencies": { "@mapbox/point-geometry": "^1.1.0", "@mapbox/tiny-sdf": "^2.2.0", @@ -9485,17 +9421,6 @@ "node": ">=12.20.0" } }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, - "license": "ISC", - "optional": true, - "dependencies": { - "wrappy": "1" - } - }, "node_modules/open": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", @@ -9683,17 +9608,6 @@ "node": ">=8" } }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -9852,7 +9766,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.18", "picocolors": "^1.1.1", @@ -9936,7 +9849,6 @@ "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -9984,6 +9896,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -9999,6 +9912,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -10011,7 +9925,8 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/prop-types": { "version": "15.8.1", @@ -10113,7 +10028,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -10180,7 +10094,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -10504,7 +10417,6 @@ "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -10648,7 +10560,6 @@ "integrity": "sha512-N+7WK20/wOr7CzA2snJcUSSNTCzeCGUTFY3OgeQP3mZ1aj9NMQ0mSTXwlrnd89j33zzQJGqIN52GIOmYrfq46A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.0.2", @@ -11077,7 +10988,6 @@ "integrity": "sha512-tMoRAts9EVqf+mEMPLC6z1DPyHbcPe+CV1MhLN55IKsl0HxNjvVGK44rVPSePbltPE6vIsn4bdRj6CCUt8SJwQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@storybook/global": "^5.0.0", "@storybook/icons": "^2.0.1", @@ -11335,7 +11245,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4", @@ -11575,106 +11484,6 @@ "node": ">=8" } }, - "node_modules/stylus": { - "version": "0.62.0", - "resolved": "https://registry.npmjs.org/stylus/-/stylus-0.62.0.tgz", - "integrity": "sha512-v3YCf31atbwJQIMtPNX8hcQ+okD4NQaTuKGUWfII8eaqn+3otrbttGL1zSMZAAtiPsBztQnujVBugg/cXFUpyg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@adobe/css-tools": "~4.3.1", - "debug": "^4.3.2", - "glob": "^7.1.6", - "sax": "~1.3.0", - "source-map": "^0.7.3" - }, - "bin": { - "stylus": "bin/stylus" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://opencollective.com/stylus" - } - }, - "node_modules/stylus/node_modules/@adobe/css-tools": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.3.3.tgz", - "integrity": "sha512-rE0Pygv0sEZ4vBWHlAgJLGDU7Pm8xoO6p3wsEceb7GYAjScrOHpEo8KK/eVkAcnSM+slAEtXjA2JpdjLp4fJQQ==", - "dev": true, - "license": "MIT", - "optional": true - }, - "node_modules/stylus/node_modules/brace-expansion": { - "version": "1.1.21", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", - "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/stylus/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", - "dev": true, - "license": "ISC", - "optional": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/stylus/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "optional": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/stylus/node_modules/sax": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/sax/-/sax-1.3.0.tgz", - "integrity": "sha512-0s+oAmw9zLl1V1cS9BtZN7JAd0cW5e0QH4W3LWEK6a4LaLEA2OTpGYWDY+6XasBLtz6wkm3u1xRw95mRuJ59WA==", - "dev": true, - "license": "ISC", - "optional": true - }, - "node_modules/stylus/node_modules/source-map": { - "version": "0.7.6", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", - "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", - "dev": true, - "license": "BSD-3-Clause", - "optional": true, - "engines": { - "node": ">= 12" - } - }, "node_modules/supercluster": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-9.1.0.tgz", @@ -11979,7 +11788,6 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -12091,7 +11899,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -12401,7 +12208,6 @@ "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", @@ -12504,7 +12310,6 @@ "integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.1.8", "@vitest/mocker": "4.1.8", @@ -12869,14 +12674,6 @@ "node": ">=0.10.0" } }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, - "license": "ISC", - "optional": true - }, "node_modules/write-file-atomic": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-5.0.1.tgz", diff --git a/src/components/CheckboxGroup/CheckboxGroup.tsx b/src/components/CheckboxGroup/CheckboxGroup.tsx index 928698a..689e0ee 100644 --- a/src/components/CheckboxGroup/CheckboxGroup.tsx +++ b/src/components/CheckboxGroup/CheckboxGroup.tsx @@ -1,4 +1,5 @@ import { + ComponentPropsWithoutRef, ElementRef, forwardRef, ReactNode, @@ -19,7 +20,15 @@ export type CheckboxOption = { disabled?: boolean; }; -export interface CheckboxGroupProps { +export interface CheckboxGroupProps extends Omit, + 'children' | + 'className' | + 'defaultValue' | + 'disabled' | + 'onChange' | + 'onValueChange' | + 'value' +> { className?: string; defaultValue?: string[]; disabled?: boolean; @@ -35,8 +44,10 @@ export const CheckboxGroup = forwardRef, Ch onChange, options, value, + ...props }, ref): JSX.Element => ( = { + title: 'Inputs/InputCurrency', + component: InputCurrency, + parameters: { + layout: 'centered', + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + tags: ['autodocs'], + argTypes: { + border: { + control: 'boolean', + description: 'Whether the input has a border.', + table: { category: 'Appearance' }, + }, + currencies: { table: { disable: true } }, + decimals: { + control: { type: 'number', min: 0, max: 4 }, + description: 'How many digits are after the separator. Use 0 for currencies without a fraction.', + table: { category: 'Behavior' }, + }, + defaultValue: { table: { disable: true } }, + disabled: { + control: 'boolean', + description: 'Whether the input is disabled.', + table: { category: 'Behavior' }, + }, + intent: { + control: 'select', + options: ['primary', 'secondary', 'danger', 'warning', 'success', 'info'], + description: 'The color of the input.', + table: { category: 'Appearance' }, + }, + onChange: { table: { disable: true } }, + value: { table: { disable: true } }, + variant: { + control: 'select', + options: ['solid', 'shaded', 'ghost', 'surface'], + description: 'The variant of the input.', + table: { category: 'Appearance' }, + }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + name: 'Default', + args: { + border: true, + decimals: 2, + disabled: false, + intent: 'secondary', + variant: 'ghost', + }, + render: (args) => , +}; + +export const NoDecimals: Story = { + name: 'No Decimals', + args: { + ...Default.args, + decimals: 0, + }, + render: (args) => , +}; + +export const Disabled: Story = { + name: 'Disabled', + args: { + ...Default.args, + disabled: true, + }, + render: (args) => , +}; diff --git a/src/components/InputCurrency/InputCurrency.tsx b/src/components/InputCurrency/InputCurrency.tsx new file mode 100644 index 0000000..c6d1903 --- /dev/null +++ b/src/components/InputCurrency/InputCurrency.tsx @@ -0,0 +1,160 @@ +import { + ChangeEvent, + forwardRef, + ReactElement, + useState, +} from 'react'; +import clsx from 'clsx'; + +import { getCalculatorCaretProps } from '../../utils/getCalculatorCaretProps'; +import { parseCalculatorDigits } from '../../utils/parseCalculatorDigits'; +import { InputMasked, InputMaskedProps } from '../InputMasked'; +import { + Select, + SelectOption, + SelectValue, +} from '../Select'; +import { CURRENCY_OPTIONS } from './InputCurrency.constants'; +import { + getAmountDigits, + getAmountFromDigits, + getAmountParts, + MAX_DIGITS, +} from './InputCurrency.utils'; + +import styles from './InputCurrency.module.scss'; + +export type CurrencyValue = { + + /** The amount in the currency's smallest unit (e.g. cents). */ + amount: number; + currency: string; +}; + +export interface InputCurrencyProps extends Omit { + + /** The currencies which can be chosen. */ + currencies?: SelectOption[]; + + /** How many digits are after the separator. Use 0 for currencies without a fraction (e.g. yen). */ + decimals?: number; + defaultValue?: CurrencyValue; + onChange?: (value: CurrencyValue) => void; + value?: CurrencyValue; +} + +export const InputCurrency = forwardRef(({ + border = true, + className, + corners = 'normal', + currencies = CURRENCY_OPTIONS, + decimals = 2, + defaultValue, + disabled, + intent = 'secondary', + loading = false, + onChange, + value, + variant = 'ghost', + ...props +}, ref): ReactElement => { + const caretProps = getCalculatorCaretProps(props); + const [internalValue, setInternalValue] = useState(defaultValue); + const current = value ?? internalValue; + const amount = current?.amount ?? 0; + const currency = current?.currency ?? `${currencies[0]?.value ?? ''}`; + + const commit = (next: CurrencyValue): void => { + setInternalValue(next); + onChange?.(next); + }; + + const handleAmountChange = (e: ChangeEvent): void => { + const digits = parseCalculatorDigits(e.target.value, MAX_DIGITS); + commit({ amount: getAmountFromDigits(digits), currency }); + }; + + const handleCurrencyChange = (next: SelectValue | null): void => { + if (next !== null) { + commit({ amount, currency: `${next}` }); + } + }; + + const { fraction, integer } = getAmountParts(getAmountDigits(amount), decimals); + const amountText = decimals > 0 ? `${integer}.${fraction}` : integer; + + return ( +
+ + - : - - - )}
); diff --git a/src/components/InputDuration/InputDuration.module.scss b/src/components/InputDuration/InputDuration.module.scss new file mode 100644 index 0000000..94f69ba --- /dev/null +++ b/src/components/InputDuration/InputDuration.module.scss @@ -0,0 +1,35 @@ +@use "../../style/layers"; + +@layer components { + .input-duration { + // Leaves room after the digits for the last unit's label + --input-padding-end: 1.5rem; + + // A unit's label hangs off the top right of its digits. It takes no room, so every unit but the + // last leaves space for it. The last one's label sits in the end padding, after the caret. + &-unit { + position: relative; + display: inline-flex; + + &:not(:last-child) { + margin-right: 1rem; + } + } + + &-digit[data-leading-zero] { + opacity: 0.4; + } + + &-label { + position: absolute; + top: 0; + left: 100%; + + margin-left: 0.125rem; + + font-size: 0.75em; + + opacity: 0.6; + } + } +} diff --git a/src/components/InputDuration/InputDuration.module.scss.d.ts b/src/components/InputDuration/InputDuration.module.scss.d.ts new file mode 100644 index 0000000..5408f8e --- /dev/null +++ b/src/components/InputDuration/InputDuration.module.scss.d.ts @@ -0,0 +1,4 @@ +export declare const inputDuration: string; +export declare const inputDurationDigit: string; +export declare const inputDurationLabel: string; +export declare const inputDurationUnit: string; diff --git a/src/components/InputDuration/InputDuration.stories.tsx b/src/components/InputDuration/InputDuration.stories.tsx new file mode 100644 index 0000000..b49d0e5 --- /dev/null +++ b/src/components/InputDuration/InputDuration.stories.tsx @@ -0,0 +1,119 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { Timer } from 'lucide-react'; + +import { ControlledStory } from './stories/ControlledStory'; +import { InputDuration } from './InputDuration'; + +const meta: Meta = { + title: 'Inputs/InputDuration', + component: InputDuration, + parameters: { + layout: 'centered', + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + tags: ['autodocs'], + argTypes: { + border: { + control: 'boolean', + description: 'Whether the input has a border.', + table: { category: 'Appearance' }, + }, + defaultValue: { table: { disable: true } }, + icon: { table: { disable: true } }, + disabled: { + control: 'boolean', + description: 'Whether the input is disabled.', + table: { category: 'Behavior' }, + }, + intent: { + control: 'select', + options: ['primary', 'secondary', 'danger', 'warning', 'success', 'info'], + description: 'The color of the input.', + table: { category: 'Appearance' }, + }, + maxUnit: { + control: 'select', + options: ['m', 'h', 'd', 'w'], + description: 'The largest unit shown. It has no upper limit.', + table: { category: 'Behavior' }, + }, + minUnit: { + control: 'select', + options: ['s', 'm', 'h', 'd'], + description: 'The smallest unit shown, and what the last typed digit means.', + table: { category: 'Behavior' }, + }, + onChange: { table: { disable: true } }, + step: { + control: { type: 'number', min: 1 }, + description: 'How many of the smallest unit the arrow keys change the value by.', + table: { category: 'Behavior' }, + }, + value: { table: { disable: true } }, + variant: { + control: 'select', + options: ['solid', 'shaded', 'ghost', 'surface'], + description: 'The variant of the input.', + table: { category: 'Appearance' }, + }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + name: 'Default', + args: { + border: true, + disabled: false, + intent: 'secondary', + maxUnit: 'h', + minUnit: 'm', + step: 1, + variant: 'ghost', + }, + render: (args) => , +}; + +export const WithSeconds: Story = { + name: 'With Seconds', + args: { + ...Default.args, + minUnit: 's', + }, + render: (args) => , +}; + +export const MinutesOnly: Story = { + name: 'Minutes Only', + args: { + ...Default.args, + maxUnit: 'm', + }, + render: (args) => , +}; + +export const WithIcon: Story = { + name: 'With Icon', + args: { + ...Default.args, + icon: , + }, + render: (args) => , +}; + +export const Disabled: Story = { + name: 'Disabled', + args: { + ...Default.args, + disabled: true, + }, + render: (args) => , +}; diff --git a/src/components/InputDuration/InputDuration.tsx b/src/components/InputDuration/InputDuration.tsx new file mode 100644 index 0000000..46a284d --- /dev/null +++ b/src/components/InputDuration/InputDuration.tsx @@ -0,0 +1,164 @@ +import { + ChangeEvent, + FocusEvent, + forwardRef, + KeyboardEvent, + ReactElement, + useState, +} from 'react'; +import clsx from 'clsx'; + +import { getCalculatorCaretProps } from '../../utils/getCalculatorCaretProps'; +import { parseCalculatorDigits } from '../../utils/parseCalculatorDigits'; +import { InputMasked, InputMaskedProps } from '../InputMasked'; +import { + DurationTypedDigits, + DurationUnit, + getDigitParts, + getDurationDescription, + getDurationDigits, + getDurationFromDigits, + getEffectiveMaxUnit, + getMaxDigits, + UNIT_MS, +} from './InputDuration.utils'; + +import styles from './InputDuration.module.scss'; + +export interface InputDurationProps extends Omit { + + // Un-Controlled usage: + + /** The default value (a length of time in milliseconds). */ + defaultValue?: number; + + // Controlled Usage: + + /** The current value (a length of time in milliseconds). */ + value?: number; + + /** Handles the value change (in milliseconds). */ + onChange?: (value: number) => void; + + // Configuration: + + /** The largest unit shown. It has no upper limit. */ + maxUnit: DurationUnit; + + /** The smallest unit shown, and what the last typed digit means. */ + minUnit: DurationUnit; + + /** Smallest unit step when changed with up and down arrow keys. */ + step?: number; +} + +export const InputDuration = forwardRef(({ + className, + defaultValue, + maxUnit, + minUnit, + onChange, + step = 1, + value, + ...props +}, ref): ReactElement => { + const [internalValue, setInternalValue] = useState(defaultValue); + const current = value ?? internalValue; + + /* The digits as typed, and the top unit they are read in. While typing, they + * are drawn as they are, in the units which were shown when typing started + * (the top one unbounded), so nothing is reformatted mid-typing (e.g. 1-7-0 + * is 1h 70m). Deleting a digit just drops it. On blur they are cleared, and + * the value is drawn normalized. */ + const [typed, setTyped] = useState(null); + + /* When normalized, a value which reaches the next unit up (e.g. 24h) shows + * that unit too. */ + const effectiveMaxUnit = current === undefined ? maxUnit : getEffectiveMaxUnit(current, maxUnit); + + const displayMaxUnit = typed?.maxUnit ?? effectiveMaxUnit; + + // Typing continues from what is shown, so the digits are the shown value's (in its units) + const digits = getDurationDigits(current ?? 0, minUnit, effectiveMaxUnit, typed); + const parts = getDigitParts(digits, minUnit, displayMaxUnit); + + const commit = (next: number): void => { + setInternalValue(next); + onChange?.(next); + }; + + const handleChange = (e: ChangeEvent): void => { + const nextDigits = parseCalculatorDigits(e.target.value, getMaxDigits(minUnit, displayMaxUnit)); + setTyped({ digits: nextDigits, maxUnit: displayMaxUnit }); + commit(getDurationFromDigits(nextDigits, minUnit, displayMaxUnit)); + }; + + const handleBlur = (e: FocusEvent): void => { + props.onBlur?.(e); + setTyped(null); + }; + + const caretProps = getCalculatorCaretProps({ + ...props, + onKeyDown: (e: KeyboardEvent): void => { + props.onKeyDown?.(e); + if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { + e.preventDefault(); + const direction = e.key === 'ArrowUp' ? 1 : -1; + const next = Math.max((current ?? 0) + direction * step * UNIT_MS[minUnit], 0); + setTyped(null); + commit(next); + } + }, + }); + + return ( + { + let significant = false; + return parts.map(({ unit, value: unitValue }) => ( + + {`${unitValue}`.padStart(2, '0').split('').map((digit, i) => { + significant = significant || digit !== '0'; + return ( + + {digit} + + ); + })} + {unit} + + )); + }} + maskDescription={getDurationDescription(parts, minUnit)} + ref={ref} + type="text" + value={digits} + onBlur={handleBlur} + onChange={handleChange} + {...caretProps} + /> + ); +}); + +InputDuration.displayName = 'InputDuration'; diff --git a/src/components/InputDuration/InputDuration.utils.test.ts b/src/components/InputDuration/InputDuration.utils.test.ts new file mode 100644 index 0000000..71ccc5b --- /dev/null +++ b/src/components/InputDuration/InputDuration.utils.test.ts @@ -0,0 +1,197 @@ +import { + describe, + expect, + it, +} from 'vitest'; + +import { + getDigitParts, + getDurationDescription, + getDurationDigits, + getDurationFromDigits, + getDurationParts, + getEffectiveMaxUnit, + getMaxDigits, + getVisibleUnits, +} from './InputDuration.utils'; + +const SECOND = 1000; +const MINUTE = 60 * SECOND; +const HOUR = 60 * MINUTE; + +describe('InputDuration.utils', () => { + describe('getMaxDigits', () => { + it('should give the top unit the same room however many units there are.', () => { + expect(getMaxDigits('m', 'm')).toBe(6); + expect(getMaxDigits('m', 'h')).toBe(8); + expect(getMaxDigits('s', 'w')).toBe(14); + }); + }); + + describe('getVisibleUnits', () => { + it('should list the units from the largest to the smallest.', () => { + expect(getVisibleUnits('s', 'h')).toEqual(['h', 'm', 's']); + expect(getVisibleUnits('m', 'h')).toEqual(['h', 'm']); + expect(getVisibleUnits('s', 'm')).toEqual(['m', 's']); + expect(getVisibleUnits('m', 'm')).toEqual(['m']); + }); + }); + + describe('getEffectiveMaxUnit', () => { + it('should keep the given unit until the next unit up is reached.', () => { + expect(getEffectiveMaxUnit(23 * HOUR + 59 * MINUTE, 'h')).toBe('h'); + expect(getEffectiveMaxUnit(24 * HOUR, 'h')).toBe('d'); + }); + it('should keep adding units.', () => { + expect(getEffectiveMaxUnit(7 * 24 * HOUR, 'h')).toBe('w'); + expect(getEffectiveMaxUnit(3 * 7 * 24 * HOUR, 'm')).toBe('w'); + }); + it('should not go past weeks.', () => { + expect(getEffectiveMaxUnit(100 * 7 * 24 * HOUR, 'w')).toBe('w'); + }); + it('should keep a larger given unit.', () => { + expect(getEffectiveMaxUnit(0, 'd')).toBe('d'); + }); + }); + + describe('getDurationParts', () => { + it('should carry lower units into higher ones.', () => { + expect(getDurationParts(90 * MINUTE, 'm', 'h')).toEqual([ + { unit: 'h', value: 1 }, + { unit: 'm', value: 30 }, + ]); + }); + it('should not cap the top unit.', () => { + expect(getDurationParts(100 * HOUR + 5 * MINUTE, 'm', 'h')[0]).toEqual({ + unit: 'h', + value: 100, + }); + expect(getDurationParts(90 * MINUTE, 'm', 'm')).toEqual([{ + unit: 'm', + value: 90, + }]); + }); + it('should floor to the smallest unit.', () => { + expect(getDurationParts(90 * MINUTE + 45 * SECOND, 'm', 'h')[1]).toEqual({ unit: 'm', value: 30 }); + }); + it('should include seconds when they are the smallest unit.', () => { + expect(getDurationParts(HOUR + 2 * MINUTE + 3 * SECOND, 's', 'h')).toEqual([ + { unit: 'h', value: 1 }, + { unit: 'm', value: 2 }, + { unit: 's', value: 3 }, + ]); + }); + it('should treat a negative duration as zero.', () => { + expect(getDurationParts(-5, 'm', 'h')).toEqual([ + { unit: 'h', value: 0 }, + { unit: 'm', value: 0 }, + ]); + }); + }); + + describe('units beyond hours', () => { + it('should carry hours into days at 24 and days into weeks at 7.', () => { + expect(getDurationParts(26 * HOUR, 'h', 'd')).toEqual([ + { unit: 'd', value: 1 }, + { unit: 'h', value: 2 }, + ]); + expect(getDurationParts(8 * 24 * HOUR, 'd', 'w')).toEqual([ + { unit: 'w', value: 1 }, + { unit: 'd', value: 1 }, + ]); + }); + it('should not cap the top unit.', () => { + expect(getDurationParts(30 * 24 * HOUR, 'h', 'd')[0]).toEqual({ unit: 'd', value: 30 }); + }); + }); + + describe('getDigitParts', () => { + it('should lay digits out as typed, without carrying.', () => { + expect(getDigitParts('170', 'm', 'h')).toEqual([ + { unit: 'h', value: 1 }, + { unit: 'm', value: 70 }, + ]); + }); + it('should lay out every visible unit when there are no digits.', () => { + expect(getDigitParts('', 's', 'h')).toEqual([ + { unit: 'h', value: 0 }, + { unit: 'm', value: 0 }, + { unit: 's', value: 0 }, + ]); + }); + it('should build up a value one digit at a time.', () => { + expect(getDigitParts('1700', 's', 'm')).toEqual([ + { unit: 'm', value: 17 }, + { unit: 's', value: 0 }, + ]); + }); + }); + + describe('getDurationFromDigits', () => { + it('should fill in from the smallest unit up.', () => { + expect(getDurationFromDigits('1', 'm', 'h')).toBe(MINUTE); + expect(getDurationFromDigits('13', 'm', 'h')).toBe(13 * MINUTE); + expect(getDurationFromDigits('130', 'm', 'h')).toBe(HOUR + 30 * MINUTE); + }); + it('should carry a lower unit which is given too much.', () => { + expect(getDurationFromDigits('90', 'm', 'h')).toBe(90 * MINUTE); + }); + it('should not cap the top unit.', () => { + expect(getDurationFromDigits('2500', 'm', 'h')).toBe(25 * HOUR); + }); + it('should interpret digits by the smallest unit.', () => { + expect(getDurationFromDigits('90', 's', 'm')).toBe(MINUTE + 30 * SECOND); + expect(getDurationFromDigits('90', 'm', 'm')).toBe(90 * MINUTE); + }); + it('should be zero for no digits.', () => { + expect(getDurationFromDigits('', 'm', 'h')).toBe(0); + }); + }); + + describe('getDurationDescription', () => { + it('should describe each unit with a value in words.', () => { + const parts = getDurationParts(3 * HOUR + 7 * MINUTE, 'm', 'h'); + expect(getDurationDescription(parts, 'm')).toBe('3 hours 7 minutes'); + }); + it('should use the singular for one.', () => { + const parts = getDurationParts(HOUR + MINUTE, 'm', 'h'); + expect(getDurationDescription(parts, 'm')).toBe('1 hour 1 minute'); + }); + it('should leave out units without a value.', () => { + const parts = getDurationParts(2 * HOUR, 'm', 'h'); + expect(getDurationDescription(parts, 'm')).toBe('2 hours'); + }); + it('should describe zero in the smallest unit.', () => { + expect(getDurationDescription(getDurationParts(0, 'm', 'h'), 'm')).toBe('0 minutes'); + }); + }); + + describe('getDurationDigits', () => { + it('should round trip with getDurationFromDigits.', () => { + const duration = 3 * HOUR + 7 * MINUTE; + expect(getDurationDigits(duration, 'm', 'h')).toBe('307'); + expect(getDurationFromDigits('307', 'm', 'h')).toBe(duration); + }); + it('should re-derive digits after a carry.', () => { + expect(getDurationDigits(getDurationFromDigits('90', 'm', 'h'), 'm', 'h')).toBe('130'); + }); + it('should be empty for zero.', () => { + expect(getDurationDigits(0, 'm', 'h')).toBe(''); + }); + it('should give back the typed digits while they still arrive at the duration.', () => { + const duration = getDurationFromDigits('170', 'm', 'h'); + expect(getDurationDigits(duration, 'm', 'h')).toBe('210'); + expect(getDurationDigits(duration, 'm', 'h', { digits: '170', maxUnit: 'h' })).toBe('170'); + }); + it('should lay out the duration in the typed units once the typed digits no longer match.', () => { + expect(getDurationDigits(25 * HOUR, 'm', 'd')).toBe('10100'); + expect(getDurationDigits(25 * HOUR, 'm', 'd', { digits: '1', maxUnit: 'h' })).toBe('2500'); + }); + it('should lay out as the same parts as the duration.', () => { + [0, 3 * HOUR + 7 * MINUTE, 25 * HOUR].forEach((duration) => { + const digits = getDurationDigits(duration, 'm', 'h'); + expect(getDigitParts(digits, 'm', 'h')).toEqual(getDurationParts(duration, 'm', 'h')); + }); + }); + }); +}); diff --git a/src/components/InputDuration/InputDuration.utils.ts b/src/components/InputDuration/InputDuration.utils.ts new file mode 100644 index 0000000..92c4039 --- /dev/null +++ b/src/components/InputDuration/InputDuration.utils.ts @@ -0,0 +1,191 @@ +export type DurationUnit = 'w' | 'd' | 'h' | 'm' | 's'; + +export type DurationPart = { + unit: DurationUnit; + value: number; +}; + +export const UNIT_MS: Record = { + w: 7 * 24 * 60 * 60 * 1000, + d: 24 * 60 * 60 * 1000, + h: 60 * 60 * 1000, + m: 60 * 1000, + s: 1000, +}; + +const UNIT_ORDER: DurationUnit[] = ['w', 'd', 'h', 'm', 's']; + +// How many of a unit make up one of the next unit up (e.g. 60 seconds, 24 hours). The top visible +// unit has no upper limit. +const getUnitBase = (unit: DurationUnit): number => ( + UNIT_MS[UNIT_ORDER[UNIT_ORDER.indexOf(unit) - 1]] / UNIT_MS[unit] +); + +// How many digits the top unit can be typed with. This keeps the value well inside the range of +// safe integers (999999 weeks is about 6e14 ms). +const TOP_UNIT_DIGITS = 6; + +export const getVisibleUnits = (minUnit: DurationUnit, maxUnit: DurationUnit): DurationUnit[] => ( + UNIT_ORDER.slice(UNIT_ORDER.indexOf(maxUnit), UNIT_ORDER.indexOf(minUnit) + 1) +); + +/** + * Gets how many digits can be typed. Every unit below the top one takes 2 digits, and the top unit + * takes up to a fixed number, so the top unit has the same room however many units there are. + * + * @param minUnit - The smallest visible unit + * @param maxUnit - The largest visible unit + * @returns The most digits which can be typed + */ +export const getMaxDigits = (minUnit: DurationUnit, maxUnit: DurationUnit): number => ( + (getVisibleUnits(minUnit, maxUnit).length - 1) * 2 + TOP_UNIT_DIGITS +); + +/** + * Gets the largest unit which needs to be shown for a duration. This is the given unit, unless + * the duration has reached the next unit up (e.g. 24 hours is 1 day), in which case that unit is + * added, and so on. + * + * @param duration - The duration in milliseconds + * @param maxUnit - The largest unit which is always shown + * @returns The largest unit to show + */ +export const getEffectiveMaxUnit = ( + duration: number, + maxUnit: DurationUnit, +): DurationUnit => { + let unit = maxUnit; + while (UNIT_ORDER.indexOf(unit) > 0 && duration >= UNIT_MS[UNIT_ORDER[UNIT_ORDER.indexOf(unit) - 1]]) { + unit = UNIT_ORDER[UNIT_ORDER.indexOf(unit) - 1]; + } + return unit; +}; + +/** + * Splits a duration into its visible units (largest first), carrying lower + * units into higher ones. + * + * @param duration - The duration in milliseconds + * @param minUnit - The smallest visible unit + * @param maxUnit - The largest visible unit + * @returns The value of each visible unit, largest first + */ +export const getDurationParts = ( + duration: number, + minUnit: DurationUnit, + maxUnit: DurationUnit, +): DurationPart[] => { + const units = getVisibleUnits(minUnit, maxUnit); + let remaining = Math.floor(Math.max(duration, 0) / UNIT_MS[minUnit]); + return [...units].reverse().map((unit, i) => { + const isTop = i === units.length - 1; + const value = isTop ? remaining : remaining % getUnitBase(unit); + remaining = isTop ? remaining : Math.floor(remaining / getUnitBase(unit)); + return { unit, value }; + }).reverse(); +}; + +export type DurationTypedDigits = { + digits: string; + maxUnit: DurationUnit; +}; + +/** + * Gets the digits which would be typed (calculator style) to arrive at a + * duration. Every unit below the top one takes 2 digits; the top unit takes the + * rest. + * + * If digits were typed and they still arrive at the duration, they are returned + * as typed (so nothing is reformatted mid-typing), and the units they were typed + * in are used instead of the given largest unit. + * + * @param duration - The duration in milliseconds + * @param minUnit - The smallest visible unit + * @param maxUnit - The largest visible unit + * @param typed - The digits as typed, and the largest unit they are read in + * @returns The digits, without leading zeros + */ +export const getDurationDigits = ( + duration: number, + minUnit: DurationUnit, + maxUnit: DurationUnit, + typed?: DurationTypedDigits | null, +): string => { + if (typed && duration === getDurationFromDigits(typed.digits, minUnit, typed.maxUnit)) { + return typed.digits; + } + return getDurationParts(duration, minUnit, typed?.maxUnit ?? maxUnit) + .map(({ value }, i) => (i === 0 ? `${value}` : `${value}`.padStart(2, '0'))) + .join('') + .replace(/^0+/, ''); +}; + +/** + * Lays typed digits out over the visible units (largest first), filled in from + * the smallest unit up, exactly as typed. A unit below the top one can be given + * more than it holds (e.g. 70 minutes), which is not carried until the duration + * is calculated. + * + * @param digits - The digits as typed + * @param minUnit - The smallest visible unit + * @param maxUnit - The largest visible unit + * @returns The value of each visible unit as typed, largest first + */ +export const getDigitParts = ( + digits: string, + minUnit: DurationUnit, + maxUnit: DurationUnit, +): DurationPart[] => { + const units = [...getVisibleUnits(minUnit, maxUnit)].reverse(); + let rest = digits; + return units.map((unit, i) => { + const isTop = i === units.length - 1; + const chunk = isTop ? rest : rest.slice(-2); + rest = isTop ? '' : rest.slice(0, -2); + return { unit, value: Number(chunk || 0) }; + }).reverse(); +}; + +const UNIT_NAMES: Record = { + w: 'week', + d: 'day', + h: 'hour', + m: 'minute', + s: 'second', +}; + +/** + * Describes visible units in words (e.g. "3 hours 7 minutes"). Units without a value are left out. + * + * @param parts - The value of each visible unit, largest first + * @param minUnit - The smallest visible unit, which is used if every value is zero + * @returns The description + */ +export const getDurationDescription = ( + parts: DurationPart[], + minUnit: DurationUnit, +): string => { + const spoken = parts + .filter(({ value }) => value > 0) + .map(({ unit, value }) => `${value} ${UNIT_NAMES[unit]}${value === 1 ? '' : 's'}`); + return spoken.length > 0 ? spoken.join(' ') : `0 ${UNIT_NAMES[minUnit]}s`; +}; + +/** + * Gets the duration for typed digits. Units which were given more than they + * hold (e.g. 90 minutes) carry into the unit above. + * + * @param digits - The digits as typed + * @param minUnit - The smallest visible unit + * @param maxUnit - The largest visible unit + * @returns The duration in milliseconds + */ +export const getDurationFromDigits = ( + digits: string, + minUnit: DurationUnit, + maxUnit: DurationUnit, +): number => ( + getDigitParts(digits, minUnit, maxUnit).reduce((total, { unit, value }) => ( + total + value * UNIT_MS[unit] + ), 0) +); diff --git a/src/components/InputDuration/index.ts b/src/components/InputDuration/index.ts new file mode 100644 index 0000000..4f52615 --- /dev/null +++ b/src/components/InputDuration/index.ts @@ -0,0 +1 @@ +export * from './InputDuration'; diff --git a/src/components/InputDuration/stories/ControlledStory.tsx b/src/components/InputDuration/stories/ControlledStory.tsx new file mode 100644 index 0000000..2c05cf8 --- /dev/null +++ b/src/components/InputDuration/stories/ControlledStory.tsx @@ -0,0 +1,16 @@ +import { useState } from 'react'; + +import { InputDuration, InputDurationProps } from '../InputDuration'; + +export const ControlledStory = (props: InputDurationProps) => { + const [value, setValue] = useState(undefined); + + return ( +
+ +

+ {value !== undefined ? `Selected: ${value} ms` : 'No duration selected'} +

+
+ ); +}; diff --git a/src/components/InputMasked/InputMasked.module.scss b/src/components/InputMasked/InputMasked.module.scss new file mode 100644 index 0000000..c9d775a --- /dev/null +++ b/src/components/InputMasked/InputMasked.module.scss @@ -0,0 +1,59 @@ +@use "../../style/layers"; + +@layer components { + .input-masked { + // The value is drawn by the mask. The input's own text is hidden, but its + // caret (aligned the + // same way) shows where the next character goes. + input { + color: transparent; + + &::selection { + background-color: transparent; + } + + &[data-align="end"] { + text-align: end; + } + } + + &-description { + position: absolute; + + overflow: hidden; + + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + + white-space: nowrap; + + clip-path: inset(50%); + } + + &-mask { + pointer-events: none; + + position: absolute; + inset: 0; + + display: flex; + gap: 0.125rem; + align-items: center; + + padding: 0 var(--input-padding-end) 0 var(--input-padding-start); + + font-variant-numeric: tabular-nums; + color: var(--color-text-ui); + + &[data-align="end"] { + justify-content: end; + } + + &[data-disabled] { + opacity: 0.4; + } + } + } +} diff --git a/src/components/InputMasked/InputMasked.module.scss.d.ts b/src/components/InputMasked/InputMasked.module.scss.d.ts new file mode 100644 index 0000000..227432f --- /dev/null +++ b/src/components/InputMasked/InputMasked.module.scss.d.ts @@ -0,0 +1,3 @@ +export declare const inputMasked: string; +export declare const inputMaskedDescription: string; +export declare const inputMaskedMask: string; diff --git a/src/components/InputMasked/InputMasked.tsx b/src/components/InputMasked/InputMasked.tsx new file mode 100644 index 0000000..0a1d7f9 --- /dev/null +++ b/src/components/InputMasked/InputMasked.tsx @@ -0,0 +1,79 @@ +import { + forwardRef, + ReactNode, + useId, +} from 'react'; +import clsx from 'clsx'; + +import { InputText, InputTextProps } from '../InputText'; + +import styles from './InputMasked.module.scss'; + +export interface InputMaskedProps extends Omit { + + /** Which side the value (and the caret) is aligned to. */ + align?: 'start' | 'end'; + + /** + * Draws something in place of the input's own text, over the input (e.g. a formatted version of + * the value). The input's text is hidden, right-aligned, and its caret stays visible. + * + * @param value - The input's current value (only set for a controlled input) + * @returns What to draw + */ + mask: (value: string) => ReactNode; + + /** + * Describes the masked value in words for assistive technology (e.g. "3 hours 7 minutes"), since + * the mask itself is hidden from it and the input's own text is only the raw digits. It is + * linked to the input with `aria-describedby`. + */ + maskDescription?: string; +} + +export const InputMasked = forwardRef(({ + align = 'start', + className, + disabled, + intent = 'secondary', + loading = false, + mask, + maskDescription, + style, + variant = 'ghost', + ...props +}, ref): JSX.Element => { + const maskDescriptionId = useId(); + const maskColor = variant === 'surface' ? undefined : `var(--color-${variant}-${intent}-text)`; + return ( + + {maskDescription && ( + + {maskDescription} + + )} +
+ {mask(`${props.value ?? ''}`)} +
+
+ ); +}); + +InputMasked.displayName = 'InputMasked'; diff --git a/src/components/InputMasked/index.ts b/src/components/InputMasked/index.ts new file mode 100644 index 0000000..48258e2 --- /dev/null +++ b/src/components/InputMasked/index.ts @@ -0,0 +1,4 @@ +export { + InputMasked, + type InputMaskedProps, +} from './InputMasked'; diff --git a/src/components/InputPanel/InputPanel.tsx b/src/components/InputPanel/InputPanel.tsx index cc8cc01..a36b0ca 100644 --- a/src/components/InputPanel/InputPanel.tsx +++ b/src/components/InputPanel/InputPanel.tsx @@ -7,6 +7,7 @@ import { ReactNode, RefAttributes, useEffect, + useRef, useState, } from 'react'; import { Drawer as BaseDrawer } from '@base-ui/react/drawer'; @@ -92,6 +93,37 @@ export const InputPanel = forwardRef(({ } }; + /* + * Native browser popups (e.g. the picker of a time input) live outside the + * DOM, so a click which dismisses one is also reported as an outside press. + * That is only reported on click, after the popup is gone, so whether one was + * open is recorded when the press starts. If one was, use that click only to + * dismiss it and keep the panel open. + */ + const nativePickerOpenRef = useRef(false); + + const popupRef = useRef(null); + + useEffect(() => { + if (!open) { + return undefined; + } + const handlePointerDown = (): void => { + nativePickerOpenRef.current = CSS.supports('selector(:open)') && !!document.activeElement?.matches(':open'); + }; + document.addEventListener('pointerdown', handlePointerDown, true); + return () => document.removeEventListener('pointerdown', handlePointerDown, true); + }, [open]); + + const handleOpenChange = (next: boolean, eventDetails: BaseDrawer.Root.ChangeEventDetails): void => { + if (!next && eventDetails.reason === 'outside-press' && nativePickerOpenRef.current) { + nativePickerOpenRef.current = false; + eventDetails.cancel(); + return; + } + setOpen(next); + }; + const label = renderValue?.(value); const triggerProps = { @@ -133,7 +165,7 @@ export const InputPanel = forwardRef(({ }; return ( - + {name && serialize && ( )} @@ -145,6 +177,8 @@ export const InputPanel = forwardRef(({ popupRef.current?.querySelector('[data-initial-focus]') ?? true} data-full-height={fullHeight || undefined} data-full-width={fullWidth || undefined} data-padding={!disablePadding} diff --git a/src/components/InputText/InputText.module.scss b/src/components/InputText/InputText.module.scss index 9bfce09..4eeefba 100644 --- a/src/components/InputText/InputText.module.scss +++ b/src/components/InputText/InputText.module.scss @@ -4,12 +4,19 @@ @layer components { .input-text { + --input-padding-start: var(--size-normal-padding); + --input-padding-end: var(--size-normal-padding); + position: relative; width: 100%; padding: 0; - color: var(--color-text-ui); + color: var(--color-text-muted); + + &[data-has-icon] { + --input-padding-start: var(--size-normal); + } - .icon-wrapper { + &-icon-wrapper { pointer-events: none; position: absolute; @@ -18,34 +25,28 @@ align-items: center; justify-content: center; - width: 2.5rem; - height: 2.5rem; + width: var(--size-normal); + height: var(--size-normal); color: var(--color-text-muted); - - svg { - width: 1rem; - height: 1rem; - } } - &:has(input:disabled) .icon-wrapper { + &:has(input:disabled) .input-text-icon-wrapper { opacity: 0.6; } input { - width: 100%; - height: 100%; - padding: 0 0.75rem; - color: inherit; + position: absolute; + inset: 0; + padding: + 0 + calc(var(--input-padding-end) - var(--border-width)) + 0 + calc(var(--input-padding-start) - var(--border-width)); &:not(:disabled) { cursor: text; } - - &[data-has-icon] { - padding-left: 2.5rem; - } } } } diff --git a/src/components/InputText/InputText.module.scss.d.ts b/src/components/InputText/InputText.module.scss.d.ts index e9deadc..0fa0db0 100644 --- a/src/components/InputText/InputText.module.scss.d.ts +++ b/src/components/InputText/InputText.module.scss.d.ts @@ -1,2 +1,2 @@ -export declare const iconWrapper: string; export declare const inputText: string; +export declare const inputTextIconWrapper: string; diff --git a/src/components/InputText/InputText.stories.tsx b/src/components/InputText/InputText.stories.tsx index 86c8148..46953d3 100644 --- a/src/components/InputText/InputText.stories.tsx +++ b/src/components/InputText/InputText.stories.tsx @@ -4,7 +4,7 @@ import { Search } from 'lucide-react'; import { InputText } from './InputText'; const meta: Meta = { - title: 'Components/InputText', + title: 'Inputs/InputText', component: InputText, parameters: { layout: 'centered', @@ -65,6 +65,18 @@ export const Default: Story = { }, }; +export const Time: Story = { + name: 'Time', + args: { + border: true, + corners: 'normal', + disabled: false, + intent: 'secondary', + type: 'time', + variant: 'ghost', + }, +}; + export const WithIcon: Story = { name: 'With Icon', args: { diff --git a/src/components/InputText/InputText.tsx b/src/components/InputText/InputText.tsx index b79db38..7493378 100644 --- a/src/components/InputText/InputText.tsx +++ b/src/components/InputText/InputText.tsx @@ -1,4 +1,5 @@ import { + cloneElement, forwardRef, InputHTMLAttributes, ReactElement, @@ -10,13 +11,14 @@ import { ElementIntent, ElementVariant, } from '../../types'; -import { getStyleClassNames } from '../../utils/getStyleClassNames'; +import { sx } from '../../utils'; +import sizes from '../../style/sizes.module.scss'; import styles from './InputText.module.scss'; export interface InputTextProps extends InputHTMLAttributes { border?: boolean; - icon?: ReactElement; + icon?: ReactElement<{ className?: string }>; loading?: boolean; corners?: boolean | ElementCorners; intent?: ElementIntent; @@ -25,6 +27,7 @@ export interface InputTextProps extends InputHTMLAttributes { export const InputText = forwardRef(({ border = true, + children, className, corners = 'normal', icon, @@ -34,27 +37,26 @@ export const InputText = forwardRef(({ variant = 'ghost', ...props }, ref): JSX.Element => ( -
+
{icon && ( -
- {icon} +
+ {cloneElement(icon, { + className: clsx(sizes.icon, icon.props.className), + })}
)} + {children}
)); diff --git a/src/components/InputTextArea/InputTextArea.stories.tsx b/src/components/InputTextArea/InputTextArea.stories.tsx index 01aaaa0..15e8230 100644 --- a/src/components/InputTextArea/InputTextArea.stories.tsx +++ b/src/components/InputTextArea/InputTextArea.stories.tsx @@ -3,7 +3,7 @@ import type { Meta, StoryObj } from '@storybook/react'; import { InputTextArea } from './InputTextArea'; const meta: Meta = { - title: 'Components/InputTextArea', + title: 'Inputs/InputTextArea', component: InputTextArea, parameters: { layout: 'centered', diff --git a/src/components/InputTime/InputTime.module.scss b/src/components/InputTime/InputTime.module.scss new file mode 100644 index 0000000..19f71c0 --- /dev/null +++ b/src/components/InputTime/InputTime.module.scss @@ -0,0 +1,10 @@ +@use "../../style/layers"; + +@layer components { + .input-time { + input { + font-variant-numeric: tabular-nums; + color: var(--color-text-ui); + } + } +} diff --git a/src/components/InputTime/InputTime.module.scss.d.ts b/src/components/InputTime/InputTime.module.scss.d.ts new file mode 100644 index 0000000..92d2669 --- /dev/null +++ b/src/components/InputTime/InputTime.module.scss.d.ts @@ -0,0 +1 @@ +export declare const inputTime: string; diff --git a/src/components/InputTime/InputTime.stories.tsx b/src/components/InputTime/InputTime.stories.tsx new file mode 100644 index 0000000..4bfc8e0 --- /dev/null +++ b/src/components/InputTime/InputTime.stories.tsx @@ -0,0 +1,90 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { ControlledStory } from './stories/ControlledStory'; +import { InputTime } from './InputTime'; + +const meta: Meta = { + title: 'Inputs/InputTime', + component: InputTime, + parameters: { + layout: 'centered', + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + tags: ['autodocs'], + argTypes: { + defaultValue: { table: { disable: true } }, + disabled: { + control: 'boolean', + description: 'Whether the input is disabled.', + table: { category: 'Behavior' }, + }, + intent: { + control: 'select', + options: ['primary', 'secondary', 'danger', 'warning', 'success', 'info'], + description: 'The color of the input.', + table: { category: 'Appearance' }, + }, + minUnit: { + control: 'select', + options: ['h', 'm', 's'], + description: 'The smallest unit which can be chosen. Smaller units are set to zero.', + table: { category: 'Behavior' }, + }, + variant: { + control: 'select', + options: ['solid', 'shaded', 'ghost', 'surface'], + description: 'The variant of the input.', + table: { category: 'Appearance' }, + }, + onChange: { table: { disable: true } }, + value: { table: { disable: true } }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + name: 'Default', + args: { + border: true, + disabled: false, + intent: 'secondary', + minUnit: 'm', + variant: 'ghost', + }, + render: (args) => , +}; + +export const WithSeconds: Story = { + name: 'With Seconds', + args: { + ...Default.args, + minUnit: 's', + }, + render: (args) => , +}; + +export const HoursOnly: Story = { + name: 'Hours Only', + args: { + ...Default.args, + minUnit: 'h', + }, + render: (args) => , +}; + +export const Disabled: Story = { + name: 'Disabled', + args: { + ...Default.args, + disabled: true, + }, + render: (args) => , +}; diff --git a/src/components/InputTime/InputTime.tsx b/src/components/InputTime/InputTime.tsx new file mode 100644 index 0000000..5d5b136 --- /dev/null +++ b/src/components/InputTime/InputTime.tsx @@ -0,0 +1,82 @@ +import { + ChangeEvent, + forwardRef, + ReactElement, + useState, +} from 'react'; +import clsx from 'clsx'; + +import { InputText, InputTextProps } from '../InputText'; +import { + createFallbackTimeOfDay, + getTimeFromInputValue, + getTimeInputValue, + TimeUnit, +} from './InputTime.utils'; + +import styles from './InputTime.module.scss'; + +export interface InputTimeProps extends Omit { + + // Un-Controlled usage: + + /** The default value (a time of day). */ + defaultValue?: Date; + + // Controlled Usage: + + /** The current value (a time of day). Only the time portion is edited; the date is preserved. */ + value?: Date; + + /** Handles the value change. */ + onChange?: (value: Date) => void; + + // Configuration: + + /** The smallest unit which can be chosen. Smaller units are set to zero. */ + minUnit?: TimeUnit; +} + +export const InputTime = forwardRef(({ + className, + defaultValue, + minUnit = 'm', + onChange, + value, + ...props +}, ref): ReactElement => { + const [internalValue, setInternalValue] = useState(defaultValue); + const current = value ?? internalValue; + + const handleChange = (e: ChangeEvent): void => { + const next = getTimeFromInputValue(current ?? createFallbackTimeOfDay(), e.target.value, minUnit); + if (next) { + setInternalValue(next); + onChange?.(next); + } + }; + + return ( + + ); +}); + +InputTime.displayName = 'InputTime'; diff --git a/src/components/InputTime/InputTime.utils.test.ts b/src/components/InputTime/InputTime.utils.test.ts new file mode 100644 index 0000000..a876553 --- /dev/null +++ b/src/components/InputTime/InputTime.utils.test.ts @@ -0,0 +1,72 @@ +import { + describe, + expect, + it, +} from 'vitest'; + +import { + getTimeFromInputValue, + getTimeInputValue, + getTimeOfDayParts, + setTimeOfDay, +} from './InputTime.utils'; + +describe('InputTime.utils', () => { + describe('getTimeOfDayParts', () => { + it('should split a date into hours, minutes and seconds.', () => { + expect(getTimeOfDayParts(new Date(2026, 5, 15, 9, 30, 45))).toEqual({ hours: 9, minutes: 30, seconds: 45 }); + }); + }); + + describe('setTimeOfDay', () => { + it('should only change the time portion.', () => { + const date = new Date(2026, 5, 15, 9, 30, 45, 123); + const next = setTimeOfDay(date, { hours: 17, minutes: 5, seconds: 0 }); + expect(next.getFullYear()).toBe(2026); + expect(next.getMonth()).toBe(5); + expect(next.getDate()).toBe(15); + expect(next.getHours()).toBe(17); + expect(next.getMinutes()).toBe(5); + expect(next.getSeconds()).toBe(0); + expect(next.getMilliseconds()).toBe(123); + }); + it('should not mutate the original date.', () => { + const date = new Date(2026, 5, 15, 9, 30); + setTimeOfDay(date, { hours: 17, minutes: 0, seconds: 0 }); + expect(date.getHours()).toBe(9); + }); + }); + + describe('getTimeInputValue', () => { + const date = new Date(2026, 5, 15, 9, 7, 5); + it('should format hours and minutes by default.', () => { + expect(getTimeInputValue(date, 'm')).toBe('09:07'); + }); + it('should include seconds only for the seconds unit.', () => { + expect(getTimeInputValue(date, 's')).toBe('09:07:05'); + }); + it('should zero the minutes for the hours unit.', () => { + expect(getTimeInputValue(date, 'h')).toBe('09:00'); + }); + }); + + describe('getTimeFromInputValue', () => { + const date = new Date(2026, 5, 15, 9, 30, 45, 123); + it('should only change the time portion.', () => { + const next = getTimeFromInputValue(date, '17:05', 'm'); + expect([next?.getFullYear(), next?.getMonth(), next?.getDate()]).toEqual([2026, 5, 15]); + expect([next?.getHours(), next?.getMinutes(), next?.getSeconds()]).toEqual([17, 5, 0]); + expect(next?.getMilliseconds()).toBe(123); + }); + it('should read seconds only for the seconds unit.', () => { + expect(getTimeFromInputValue(date, '17:05:20', 's')?.getSeconds()).toBe(20); + expect(getTimeFromInputValue(date, '17:05:20', 'm')?.getSeconds()).toBe(0); + }); + it('should zero the minutes for the hours unit.', () => { + expect(getTimeFromInputValue(date, '17:05', 'h')?.getMinutes()).toBe(0); + }); + it('should return undefined if the value is not a time.', () => { + expect(getTimeFromInputValue(date, '', 'm')).toBeUndefined(); + }); + }); +}); diff --git a/src/components/InputTime/InputTime.utils.ts b/src/components/InputTime/InputTime.utils.ts new file mode 100644 index 0000000..1e19f24 --- /dev/null +++ b/src/components/InputTime/InputTime.utils.ts @@ -0,0 +1,72 @@ +import { TimePickerValue } from '../TimePicker'; + +export type TimeUnit = 'h' | 'm' | 's'; + +export const createFallbackTimeOfDay = (): Date => { + const d = new Date(); + d.setMinutes(0, 0, 0); + return d; +}; + +export const getTimeOfDayParts = (date: Date): TimePickerValue => ({ + hours: date.getHours(), + minutes: date.getMinutes(), + seconds: date.getSeconds(), +}); + +/** + * Sets the time of day of a date, leaving the date portion (and milliseconds) untouched. + * + * @param date - The date to start from (it is not mutated) + * @param time - The hours, minutes and seconds to set + * @returns A new date with the given time of day + */ +export const setTimeOfDay = ( + date: Date, + { hours, minutes, seconds }: TimePickerValue, +): Date => { + const next = new Date(date); + next.setHours(hours, minutes, seconds); + return next; +}; + +/** + * Formats the time of day of a date as the value of a native time input. + * + * @param date - The date to read the time of day from + * @param minUnit - The smallest unit shown; seconds are only included for 's' + * @returns A string like "09:30" or "09:30:15" + */ +export const getTimeInputValue = (date: Date, minUnit: TimeUnit): string => { + const { hours, minutes, seconds } = getTimeOfDayParts(date); + const parts = [hours, minUnit === 'h' ? 0 : minutes]; + if (minUnit === 's') { + parts.push(seconds); + } + return parts.map((part) => `${part}`.padStart(2, '0')).join(':'); +}; + +/** + * Sets the time of day of a date from the value of a native time input. Units smaller than the + * smallest unit are set to zero. The date portion is untouched. + * + * @param date - The date to start from (it is not mutated) + * @param inputValue - A string like "09:30" or "09:30:15" + * @param minUnit - The smallest unit which can be chosen + * @returns A new date, or undefined if the input value is not a time (e.g. it was cleared) + */ +export const getTimeFromInputValue = ( + date: Date, + inputValue: string, + minUnit: TimeUnit, +): Date | undefined => { + const [hours, minutes, seconds = 0] = inputValue.split(':').map(Number); + if (hours === undefined || minutes === undefined || [hours, minutes, seconds].some(Number.isNaN)) { + return undefined; + } + return setTimeOfDay(date, { + hours, + minutes: minUnit === 'h' ? 0 : minutes, + seconds: minUnit === 's' ? seconds : 0, + }); +}; diff --git a/src/components/InputTime/index.ts b/src/components/InputTime/index.ts new file mode 100644 index 0000000..43bf2b4 --- /dev/null +++ b/src/components/InputTime/index.ts @@ -0,0 +1 @@ +export * from './InputTime'; diff --git a/src/components/InputTime/stories/ControlledStory.tsx b/src/components/InputTime/stories/ControlledStory.tsx new file mode 100644 index 0000000..36db0ff --- /dev/null +++ b/src/components/InputTime/stories/ControlledStory.tsx @@ -0,0 +1,16 @@ +import { useState } from 'react'; + +import { InputTime, InputTimeProps } from '../InputTime'; + +export const ControlledStory = (props: InputTimeProps) => { + const [value, setValue] = useState(undefined); + + return ( +
+ +

+ {value ? `Selected: ${value.toISOString()}` : 'No time selected'} +

+
+ ); +}; diff --git a/src/components/Select/Select.stories.tsx b/src/components/Select/Select.stories.tsx index 0db505c..42c1239 100644 --- a/src/components/Select/Select.stories.tsx +++ b/src/components/Select/Select.stories.tsx @@ -5,7 +5,7 @@ import { UserOption } from './stories/UserOption'; import { Select } from './Select'; const meta: Meta = { - title: 'Components/Select', + title: 'Inputs/Select', component: Select, parameters: { layout: 'centered', diff --git a/src/components/TimePicker/TimePicker.module.scss b/src/components/TimePicker/TimePicker.module.scss new file mode 100644 index 0000000..01742d7 --- /dev/null +++ b/src/components/TimePicker/TimePicker.module.scss @@ -0,0 +1,22 @@ +@use "../../style/layers"; +@use "../../style/flex"; +@use "../../style/text"; + +@layer components { + .time-picker { + @include flex.row($gap: 0.5rem, $yAlign: center); + + margin: 0 auto; + padding: var(--padding, calc(1rem - var(--border-width))); + + &-icon { + flex-shrink: 0; + width: 1rem; + height: 1rem; + } + + &-separator { + @include text.ui; + } + } +} diff --git a/src/components/TimePicker/TimePicker.module.scss.d.ts b/src/components/TimePicker/TimePicker.module.scss.d.ts new file mode 100644 index 0000000..bb762cb --- /dev/null +++ b/src/components/TimePicker/TimePicker.module.scss.d.ts @@ -0,0 +1,3 @@ +export declare const timePicker: string; +export declare const timePickerIcon: string; +export declare const timePickerSeparator: string; diff --git a/src/components/TimePicker/TimePicker.tsx b/src/components/TimePicker/TimePicker.tsx new file mode 100644 index 0000000..6381a42 --- /dev/null +++ b/src/components/TimePicker/TimePicker.tsx @@ -0,0 +1,90 @@ +import { ReactElement } from 'react'; +import { Clock } from 'lucide-react'; + +import { DEFAULT_MINUTE_STEP, DEFAULT_SECOND_STEP } from '../InputDateTime/InputDateTime.utils'; +import { Select, SelectValue } from '../Select'; + +import styles from './TimePicker.module.scss'; + +const HOUR_OPTIONS = Array.from({ length: 24 }, (_, i) => ({ + value: i, + label: String(i).padStart(2, '0'), +})); + +export interface TimePickerValue { + hours: number; + minutes: number; + seconds: number; +} + +export interface TimePickerProps { + minuteStep?: number; + onChange: (value: TimePickerValue) => void; + secondStep?: number; + value: TimePickerValue; +} + +export const TimePicker = ({ + minuteStep = DEFAULT_MINUTE_STEP, + onChange, + secondStep = DEFAULT_SECOND_STEP, + value, +}: TimePickerProps): ReactElement => { + if (process.env.NODE_ENV !== 'production') { + if (60 % minuteStep !== 0) { + console.warn(`TimePicker: minuteStep (${minuteStep}) must evenly divide 60.`); + } + if (60 % secondStep !== 0) { + console.warn(`TimePicker: secondStep (${secondStep}) must evenly divide 60.`); + } + } + + const minuteOptions = Array.from({ length: Math.ceil(60 / minuteStep) }, (_, i) => ({ + value: i * minuteStep, + label: String(i * minuteStep).padStart(2, '0'), + })); + + const secondOptions = Array.from({ length: Math.ceil(60 / secondStep) }, (_, i) => ({ + value: i * secondStep, + label: String(i * secondStep).padStart(2, '0'), + })); + + const handleChange = (key: keyof TimePickerValue) => (v: SelectValue | null): void => { + if (typeof v === 'number') { + onChange({ ...value, [key]: v }); + } + }; + + return ( +
+ + + {secondOptions.length > 1 && ( + <> + : +