From 6f91f2a3ed1aa7b19becfe83de0e12b04201acf8 Mon Sep 17 00:00:00 2001 From: Ian Paschal Date: Thu, 22 Oct 2026 23:27:28 +0200 Subject: [PATCH 1/6] CC-190: Add ``. ``, ``, and `` --- .storybook/DocsContainer.tsx | 23 ++ .storybook/preview.tsx | 4 + .vscode/settings.json | 1 + package-lock.json | 223 +----------------- .../CheckboxGroup/CheckboxGroup.tsx | 12 +- .../InputCurrency/InputCurrency.constants.ts | 15 ++ .../InputCurrency/InputCurrency.module.scss | 35 +++ .../InputCurrency.module.scss.d.ts | 5 + .../InputCurrency/InputCurrency.stories.tsx | 86 +++++++ .../InputCurrency/InputCurrency.tsx | 153 ++++++++++++ .../InputCurrency/InputCurrency.utils.test.ts | 47 ++++ .../InputCurrency/InputCurrency.utils.ts | 45 ++++ src/components/InputCurrency/index.ts | 1 + .../InputCurrency/stories/ControlledStory.tsx | 20 ++ .../InputDateTime/InputDateTime.stories.tsx | 2 +- .../DateTimePicker/DateTimePicker.module.scss | 18 -- .../DateTimePicker.module.scss.d.ts | 3 - .../DateTimePicker/DateTimePicker.tsx | 88 ++----- .../InputDuration/InputDuration.module.scss | 35 +++ .../InputDuration.module.scss.d.ts | 4 + .../InputDuration/InputDuration.stories.tsx | 119 ++++++++++ .../InputDuration/InputDuration.tsx | 165 +++++++++++++ .../InputDuration/InputDuration.utils.test.ts | 163 +++++++++++++ .../InputDuration/InputDuration.utils.ts | 152 ++++++++++++ src/components/InputDuration/index.ts | 1 + .../InputDuration/stories/ControlledStory.tsx | 16 ++ .../InputText/InputText.module.scss | 63 +++-- .../InputText/InputText.module.scss.d.ts | 2 + .../InputText/InputText.stories.tsx | 2 +- src/components/InputText/InputText.tsx | 98 ++++++-- src/components/InputText/InputText.utils.ts | 10 + .../InputTextArea/InputTextArea.stories.tsx | 2 +- .../InputTime/InputTime.module.scss | 14 ++ .../InputTime/InputTime.module.scss.d.ts | 2 + .../InputTime/InputTime.stories.tsx | 109 +++++++++ src/components/InputTime/InputTime.tsx | 126 ++++++++++ .../InputTime/InputTime.utils.test.ts | 80 +++++++ src/components/InputTime/InputTime.utils.ts | 93 ++++++++ src/components/InputTime/index.ts | 1 + .../InputTime/stories/ControlledStory.tsx | 16 ++ src/components/Select/Select.stories.tsx | 2 +- .../TimePicker/TimePicker.module.scss | 22 ++ .../TimePicker/TimePicker.module.scss.d.ts | 3 + src/components/TimePicker/TimePicker.tsx | 87 +++++++ src/components/TimePicker/index.ts | 1 + src/index.ts | 3 + src/style/sizes.module.scss | 3 + 47 files changed, 1822 insertions(+), 353 deletions(-) create mode 100644 .storybook/DocsContainer.tsx create mode 100644 src/components/InputCurrency/InputCurrency.constants.ts create mode 100644 src/components/InputCurrency/InputCurrency.module.scss create mode 100644 src/components/InputCurrency/InputCurrency.module.scss.d.ts create mode 100644 src/components/InputCurrency/InputCurrency.stories.tsx create mode 100644 src/components/InputCurrency/InputCurrency.tsx create mode 100644 src/components/InputCurrency/InputCurrency.utils.test.ts create mode 100644 src/components/InputCurrency/InputCurrency.utils.ts create mode 100644 src/components/InputCurrency/index.ts create mode 100644 src/components/InputCurrency/stories/ControlledStory.tsx create mode 100644 src/components/InputDuration/InputDuration.module.scss create mode 100644 src/components/InputDuration/InputDuration.module.scss.d.ts create mode 100644 src/components/InputDuration/InputDuration.stories.tsx create mode 100644 src/components/InputDuration/InputDuration.tsx create mode 100644 src/components/InputDuration/InputDuration.utils.test.ts create mode 100644 src/components/InputDuration/InputDuration.utils.ts create mode 100644 src/components/InputDuration/index.ts create mode 100644 src/components/InputDuration/stories/ControlledStory.tsx create mode 100644 src/components/InputText/InputText.utils.ts create mode 100644 src/components/InputTime/InputTime.module.scss create mode 100644 src/components/InputTime/InputTime.module.scss.d.ts create mode 100644 src/components/InputTime/InputTime.stories.tsx create mode 100644 src/components/InputTime/InputTime.tsx create mode 100644 src/components/InputTime/InputTime.utils.test.ts create mode 100644 src/components/InputTime/InputTime.utils.ts create mode 100644 src/components/InputTime/index.ts create mode 100644 src/components/InputTime/stories/ControlledStory.tsx create mode 100644 src/components/TimePicker/TimePicker.module.scss create mode 100644 src/components/TimePicker/TimePicker.module.scss.d.ts create mode 100644 src/components/TimePicker/TimePicker.tsx create mode 100644 src/components/TimePicker/index.ts 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..a5a606d 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,14 @@ export type CheckboxOption = { disabled?: boolean; }; -export interface CheckboxGroupProps { +export interface CheckboxGroupProps extends Omit, + 'className' | + 'defaultValue' | + 'disabled' | + 'onChange' | + 'onValueChange' | + 'value' +> { className?: string; defaultValue?: string[]; disabled?: boolean; @@ -35,8 +43,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..a53d560 --- /dev/null +++ b/src/components/InputCurrency/InputCurrency.tsx @@ -0,0 +1,153 @@ +import { + ChangeEvent, + forwardRef, + ReactElement, + useState, +} from 'react'; +import clsx from 'clsx'; + +import { InputText, InputTextProps } from '../InputText'; +import { parseDigits } from '../InputText/InputText.utils'; +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 [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 = parseDigits(e.target.value, MAX_DIGITS); + commit({ amount: getAmountFromDigits(digits), currency }); + }; + + const handleCurrencyChange = (next: SelectValue | null): void => { + if (typeof next === 'string') { + commit({ amount, currency: next }); + } + }; + + const { fraction, integer } = getAmountParts(getAmountDigits(amount), decimals); + + 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..7288340 --- /dev/null +++ b/src/components/InputDuration/InputDuration.tsx @@ -0,0 +1,165 @@ +import { + ChangeEvent, + FocusEvent, + KeyboardEvent, + ReactElement, + SyntheticEvent, + useState, +} from 'react'; +import clsx from 'clsx'; + +import { InputText, InputTextProps } from '../InputText'; +import { parseDigits } from '../InputText/InputText.utils'; +import { + DurationUnit, + getDigitParts, + getDurationDigits, + getDurationFromDigits, + getDurationParts, + 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 = ({ + className, + defaultValue, + maxUnit, + minUnit, + onChange, + step = 1, + value, + ...props +}: InputDurationProps): 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<{ digits: string; maxUnit: DurationUnit } | null>(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); + + // Typing continues from what is shown, so the digits are the shown value's (in its units) + const typing = typed !== null && current === getDurationFromDigits(typed.digits, minUnit, typed.maxUnit); + const digits = typed !== null && typing + ? typed.digits + : getDurationDigits(current ?? 0, minUnit, typed?.maxUnit ?? effectiveMaxUnit); + + const commit = (next: number): void => { + setInternalValue(next); + onChange?.(next); + }; + + const handleChange = (e: ChangeEvent): void => { + const nextMaxUnit = typed?.maxUnit ?? effectiveMaxUnit; + const nextDigits = parseDigits(e.target.value, getMaxDigits(minUnit, nextMaxUnit)); + setTyped({ digits: nextDigits, maxUnit: nextMaxUnit }); + commit(getDurationFromDigits(nextDigits, minUnit, nextMaxUnit)); + }; + + const handleBlur = (e: FocusEvent): void => { + props.onBlur?.(e); + setTyped(null); + }; + + const handleKeyDown = (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); + } + }; + + // Digits are always added at the end, so keep the caret there + const moveCaretToEnd = (e: SyntheticEvent): void => { + const { length } = e.currentTarget.value; + e.currentTarget.setSelectionRange(length, length); + }; + + return ( + { + let significant = false; + const parts = typed !== null && typing + ? getDigitParts(digits, minUnit, typed.maxUnit) + : getDurationParts(current ?? 0, minUnit, effectiveMaxUnit); + return parts.map(({ unit, value: unitValue }) => ( + + {`${unitValue}`.padStart(2, '0').split('').map((digit, i) => { + significant = significant || digit !== '0'; + return ( + + {digit} + + ); + })} + {unit} + + )); + }} + type="text" + value={digits} + onBlur={handleBlur} + onChange={handleChange} + onClick={moveCaretToEnd} + onFocus={moveCaretToEnd} + onKeyDown={handleKeyDown} + /> + ); +}; + +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..b0496d8 --- /dev/null +++ b/src/components/InputDuration/InputDuration.utils.test.ts @@ -0,0 +1,163 @@ +import { + describe, + expect, + it, +} from 'vitest'; + +import { + getDigitParts, + 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('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(''); + }); + }); +}); diff --git a/src/components/InputDuration/InputDuration.utils.ts b/src/components/InputDuration/InputDuration.utils.ts new file mode 100644 index 0000000..03c1eea --- /dev/null +++ b/src/components/InputDuration/InputDuration.utils.ts @@ -0,0 +1,152 @@ +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(); +}; + +/** + * 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. + * + * @param duration - The duration in milliseconds + * @param minUnit - The smallest visible unit + * @param maxUnit - The largest visible unit + * @returns The digits, without leading zeros + */ +export const getDurationDigits = ( + duration: number, + minUnit: DurationUnit, + maxUnit: DurationUnit, +): string => ( + getDurationParts(duration, minUnit, 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(); +}; + +/** + * 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/InputText/InputText.module.scss b/src/components/InputText/InputText.module.scss index 9bfce09..e7f467d 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,15 +25,10 @@ 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 { @@ -34,17 +36,48 @@ } 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; + // The value is drawn by the mask, aligned to the end. The input's own text is hidden, but + // its caret (also at the end) shows where the next character goes. + &[data-mask] { + color: transparent; + text-align: end; + + &::selection { + background-color: transparent; + } + } + } + + &-mask { + pointer-events: none; + + position: absolute; + inset: 0; + + display: flex; + gap: 0.125rem; + align-items: center; + justify-content: end; + + padding: 0 var(--input-padding-end) 0 var(--input-padding-start); + + font-variant-numeric: tabular-nums; + color: var(--color-text-ui); + + &[data-disabled] { + opacity: 0.4; } } } diff --git a/src/components/InputText/InputText.module.scss.d.ts b/src/components/InputText/InputText.module.scss.d.ts index e9deadc..98337a6 100644 --- a/src/components/InputText/InputText.module.scss.d.ts +++ b/src/components/InputText/InputText.module.scss.d.ts @@ -1,2 +1,4 @@ export declare const iconWrapper: string; export declare const inputText: string; +export declare const inputTextIconWrapper: string; +export declare const inputTextMask: string; diff --git a/src/components/InputText/InputText.stories.tsx b/src/components/InputText/InputText.stories.tsx index 86c8148..f2e0166 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', diff --git a/src/components/InputText/InputText.tsx b/src/components/InputText/InputText.tsx index b79db38..3fb89aa 100644 --- a/src/components/InputText/InputText.tsx +++ b/src/components/InputText/InputText.tsx @@ -1,7 +1,9 @@ import { + cloneElement, forwardRef, InputHTMLAttributes, ReactElement, + ReactNode, } from 'react'; import clsx from 'clsx'; @@ -10,16 +12,37 @@ 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 InputTextMaskProps { + border: boolean; + corners: boolean | ElementCorners; + disabled?: boolean; + icon?: ReactElement<{ className?: string }>; + intent: ElementIntent; + loading: boolean; + variant: ElementVariant; +} + export interface InputTextProps extends InputHTMLAttributes { border?: boolean; - icon?: ReactElement; + icon?: ReactElement<{ className?: string }>; loading?: boolean; corners?: boolean | ElementCorners; intent?: ElementIntent; + + /** + * 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) + * @param props - The resolved props of the input + * @returns What to draw + */ + mask?: (value: string, props: InputTextMaskProps) => ReactNode; variant?: ElementVariant; } @@ -31,31 +54,54 @@ export const InputText = forwardRef(({ loading = false, // TODO: Implement skeleton loading state disabled, intent = 'secondary', + mask, + style, variant = 'ghost', ...props -}, ref): JSX.Element => ( -
- {icon && ( -
- {icon} -
- )} - -
-)); +}, ref): JSX.Element => { + const maskColor = variant === 'surface' ? undefined : `var(--color-${variant}-${intent}-text)`; + return ( +
+ {icon && ( +
+ {cloneElement(icon, { + className: clsx(sizes.icon, icon.props.className), + })} +
+ )} + + {mask && ( +
+ {mask(`${props.value ?? ''}`, { + border, + corners, + disabled, + icon, + intent, + loading, + variant, + })} +
+ )} +
+ ); +}); InputText.displayName = 'InputText'; diff --git a/src/components/InputText/InputText.utils.ts b/src/components/InputText/InputText.utils.ts new file mode 100644 index 0000000..5b58b90 --- /dev/null +++ b/src/components/InputText/InputText.utils.ts @@ -0,0 +1,10 @@ +/** + * Gets the digits which were typed into an input which is filled in from the end (calculator style). + * + * @param value - The input's raw value + * @param maxDigits - The most digits allowed. Any extra typed digits (at the end) are dropped + * @returns The digits, without leading zeros + */ +export const parseDigits = (value: string, maxDigits: number): string => ( + value.replace(/\D/g, '').replace(/^0+/, '').slice(0, maxDigits) +); 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..948d5d1 --- /dev/null +++ b/src/components/InputTime/InputTime.module.scss @@ -0,0 +1,14 @@ +@use "../../style/layers"; + +@layer components { + .input-time { + display: flex; + gap: 0.5rem; + align-items: center; + width: 100%; + + &-separator { + color: var(--color-text-muted); + } + } +} 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..fc3ae35 --- /dev/null +++ b/src/components/InputTime/InputTime.module.scss.d.ts @@ -0,0 +1,2 @@ +export declare const inputTime: string; +export declare const inputTimeSeparator: string; diff --git a/src/components/InputTime/InputTime.stories.tsx b/src/components/InputTime/InputTime.stories.tsx new file mode 100644 index 0000000..2ecce13 --- /dev/null +++ b/src/components/InputTime/InputTime.stories.tsx @@ -0,0 +1,109 @@ +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' }, + }, + maxUnit: { + control: 'select', + options: ['h', 'm', 's'], + description: 'The largest unit which can be chosen.', + table: { category: 'Behavior' }, + }, + minUnit: { + control: 'select', + options: ['h', 'm', 's'], + description: 'The smallest unit which can be chosen. Smaller units are set to zero.', + table: { category: 'Behavior' }, + }, + minuteStep: { + control: { type: 'number', min: 1, max: 60 }, + description: 'Interval in minutes between selectable minute values.', + table: { category: 'Behavior' }, + }, + secondStep: { + control: { type: 'number', min: 1, max: 60 }, + description: 'Interval in seconds between selectable second values.', + 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', + maxUnit: 'h', + minUnit: 'm', + minuteStep: 5, + secondStep: 5, + 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..b424cf5 --- /dev/null +++ b/src/components/InputTime/InputTime.tsx @@ -0,0 +1,126 @@ +import { + Fragment, + HTMLAttributes, + ReactElement, + useState, +} from 'react'; +import clsx from 'clsx'; + +import { ElementIntent, ElementVariant } from '../../types'; +import { DEFAULT_MINUTE_STEP, DEFAULT_SECOND_STEP } from '../InputDateTime/InputDateTime.utils'; +import { Select, SelectValue } from '../Select'; +import { + createFallbackTimeOfDay, + getTimeOfDayParts, + getTimeOptions, + getVisibleTimeUnits, + normalizeTimeOfDay, + setTimeOfDay, + TimeUnit, +} from './InputTime.utils'; + +import styles from './InputTime.module.scss'; + +const UNIT_PARTS = { + h: 'hours', + m: 'minutes', + s: 'seconds', +} as const; + +export interface InputTimeProps extends Omit, + 'defaultValue' | + 'onChange' +> { + border?: boolean; + className?: string; + disabled?: boolean; + intent?: ElementIntent; + variant?: ElementVariant; + + // 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 largest unit which can be chosen. */ + maxUnit: TimeUnit; + + /** The smallest unit which can be chosen. Smaller units are set to zero. */ + minUnit: TimeUnit; + + /** Interval in minutes between selectable minute values. */ + minuteStep?: number; + + /** Interval in seconds between selectable second values. */ + secondStep?: number; +} + +export const InputTime = ({ + border = true, + className, + defaultValue, + disabled, + id, + intent = 'secondary', + maxUnit, + minUnit, + minuteStep = DEFAULT_MINUTE_STEP, + onChange, + secondStep = DEFAULT_SECOND_STEP, + value, + variant = 'ghost', + ...props +}: InputTimeProps): ReactElement => { + const [internalValue, setInternalValue] = useState(defaultValue); + const current = value ?? internalValue; + const normalized = current ? normalizeTimeOfDay(current, minUnit, minuteStep, secondStep) : undefined; + const parts = normalized ? getTimeOfDayParts(normalized) : undefined; + + const handleChange = (unit: TimeUnit, next: SelectValue | null): void => { + if (typeof next !== 'number') { + return; + } + const base = normalized ?? createFallbackTimeOfDay(); + const updated = normalizeTimeOfDay( + setTimeOfDay(base, { ...getTimeOfDayParts(base), [UNIT_PARTS[unit]]: next }), + minUnit, + minuteStep, + secondStep, + ); + setInternalValue(updated); + onChange?.(updated); + }; + + return ( +
+ {getVisibleTimeUnits(minUnit, maxUnit).map((unit, i) => ( + + {i > 0 && :} + + : + + + )} +
+ ); +}; + +TimePicker.displayName = 'TimePicker'; diff --git a/src/components/TimePicker/index.ts b/src/components/TimePicker/index.ts new file mode 100644 index 0000000..00c41e3 --- /dev/null +++ b/src/components/TimePicker/index.ts @@ -0,0 +1 @@ +export * from './TimePicker'; diff --git a/src/index.ts b/src/index.ts index 9843d98..7032489 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,10 +15,13 @@ export * from './components/Drawer'; export * from './components/FooterBar'; export * from './components/Hero'; export * from './components/ImageViewer'; +export * from './components/InputCurrency'; export * from './components/InputDateTime'; +export * from './components/InputDuration'; export * from './components/InputPanel'; export * from './components/InputText'; export * from './components/InputTextArea'; +export * from './components/InputTime'; export * from './components/Map'; export * from './components/MarkdownRenderer'; export * from './components/MasonryGrid'; diff --git a/src/style/sizes.module.scss b/src/style/sizes.module.scss index 248d93c..5a53fb4 100644 --- a/src/style/sizes.module.scss +++ b/src/style/sizes.module.scss @@ -31,8 +31,11 @@ } .icon { + pointer-events: none; + display: block; flex-shrink: 0; + width: calc(var(--size-#{$size}) * 0.4); height: calc(var(--size-#{$size}) * 0.4); From a333bffd7c9d208dbfc87b2a76d6758f35c8f3a2 Mon Sep 17 00:00:00 2001 From: Ian Paschal Date: Thu, 22 Oct 2026 23:44:43 +0200 Subject: [PATCH 2/6] PR Feedback --- src/components/CheckboxGroup/CheckboxGroup.tsx | 1 + src/components/InputDuration/InputDuration.tsx | 15 +++++++++++++-- src/components/InputText/InputText.module.scss | 2 +- src/components/InputText/InputText.tsx | 2 +- src/components/InputTime/InputTime.tsx | 1 + src/components/InputTime/InputTime.utils.ts | 8 +++++--- src/components/TimePicker/TimePicker.tsx | 7 +++++-- 7 files changed, 27 insertions(+), 9 deletions(-) diff --git a/src/components/CheckboxGroup/CheckboxGroup.tsx b/src/components/CheckboxGroup/CheckboxGroup.tsx index a5a606d..689e0ee 100644 --- a/src/components/CheckboxGroup/CheckboxGroup.tsx +++ b/src/components/CheckboxGroup/CheckboxGroup.tsx @@ -21,6 +21,7 @@ export type CheckboxOption = { }; export interface CheckboxGroupProps extends Omit, + 'children' | 'className' | 'defaultValue' | 'disabled' | diff --git a/src/components/InputDuration/InputDuration.tsx b/src/components/InputDuration/InputDuration.tsx index 7288340..a044333 100644 --- a/src/components/InputDuration/InputDuration.tsx +++ b/src/components/InputDuration/InputDuration.tsx @@ -2,6 +2,7 @@ import { ChangeEvent, FocusEvent, KeyboardEvent, + MouseEvent, ReactElement, SyntheticEvent, useState, @@ -123,6 +124,16 @@ export const InputDuration = ({ e.currentTarget.setSelectionRange(length, length); }; + const handleClick = (e: MouseEvent): void => { + props.onClick?.(e); + moveCaretToEnd(e); + }; + + const handleFocus = (e: FocusEvent): void => { + props.onFocus?.(e); + moveCaretToEnd(e); + }; + return ( ); diff --git a/src/components/InputText/InputText.module.scss b/src/components/InputText/InputText.module.scss index e7f467d..0d54652 100644 --- a/src/components/InputText/InputText.module.scss +++ b/src/components/InputText/InputText.module.scss @@ -31,7 +31,7 @@ color: var(--color-text-muted); } - &:has(input:disabled) .icon-wrapper { + &:has(input:disabled) .input-text-icon-wrapper { opacity: 0.6; } diff --git a/src/components/InputText/InputText.tsx b/src/components/InputText/InputText.tsx index 3fb89aa..b0ea5ee 100644 --- a/src/components/InputText/InputText.tsx +++ b/src/components/InputText/InputText.tsx @@ -85,7 +85,7 @@ export const InputText = forwardRef(({ {mask && (
diff --git a/src/components/InputTime/InputTime.tsx b/src/components/InputTime/InputTime.tsx index b424cf5..2bef6ce 100644 --- a/src/components/InputTime/InputTime.tsx +++ b/src/components/InputTime/InputTime.tsx @@ -107,6 +107,7 @@ export const InputTime = ({ {i > 0 && :} : Date: Fri, 23 Oct 2026 00:28:03 +0200 Subject: [PATCH 3/6] More changes --- .../components/Calendar/Calendar.tsx | 1 + .../DateTimePicker/DateTimePicker.module.scss | 15 +-- .../DateTimePicker.module.scss.d.ts | 3 +- .../DateTimePicker/DateTimePicker.tsx | 34 +++--- src/components/InputPanel/InputPanel.tsx | 33 +++++- .../InputText/InputText.module.scss.d.ts | 1 - .../InputText/InputText.stories.tsx | 12 ++ .../InputTime/InputTime.module.scss | 10 +- .../InputTime/InputTime.module.scss.d.ts | 1 - .../InputTime/InputTime.stories.tsx | 19 ---- src/components/InputTime/InputTime.tsx | 103 +++++------------- .../InputTime/InputTime.utils.test.ts | 60 +++++----- src/components/InputTime/InputTime.utils.ts | 75 +++++-------- 13 files changed, 148 insertions(+), 219 deletions(-) diff --git a/src/components/InputDateTime/components/Calendar/Calendar.tsx b/src/components/InputDateTime/components/Calendar/Calendar.tsx index d19150e..1c3e548 100644 --- a/src/components/InputDateTime/components/Calendar/Calendar.tsx +++ b/src/components/InputDateTime/components/Calendar/Calendar.tsx @@ -90,6 +90,7 @@ export const Calendar = ({ {days.map((day) => (