diff --git a/.dockerignore b/.dockerignore index 2f36e56..f8aa182 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,3 +1,5 @@ Dockerfile node_modules -.vscode \ No newline at end of file +.vscode +dist +.git \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index f4f4d96..4384172 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,7 @@ FROM alpine:3.20 +# noircir repo: where to fetch updates +ENV NOIRCIR_RELEASE=https://api.github.com/repos/BDX-town/Noircir/tags # noircir folder: where you saved noircir files ENV NOIRCIR_FOLDER="/noircir" # nginx root: all noircir data will be saved there, blogs and generated content @@ -17,12 +19,14 @@ ENV WWW_GROUP=www-data COPY . $NOIRCIR_FOLDER # install deps -RUN apk add gettext gum nginx nginx-mod-http-lua nginx-mod-http-dav-ext openssl nodejs-current npm \ +RUN apk add gettext gum nginx nginx-mod-http-lua nginx-mod-http-dav-ext openssl nodejs-current npm jq curl \ && corepack enable \ && mkdir -p $NGINX_FOLDER \ - && mkdir -p /tools && cp $NOIRCIR_FOLDER/tools/* /tools \ - && cd $NOIRCIR_FOLDER && yarn workspaces focus cms template && yarn run build && rm -rf */node_modules/* && rm -rf node_modules/* && yarn workspaces focus generator && cd / \ - && cp -r $NOIRCIR_FOLDER/cms/dist/* $NGINX_FOLDER + && mkdir -p /tools && cp $NOIRCIR_FOLDER/tools/* /tools +RUN cd $NOIRCIR_FOLDER && yarn workspaces focus @bdxtown/canaille cms template && yarn run build +RUN cd $NOIRCIR_FOLDER && rm -rf */node_modules/* && rm -rf node_modules/* +RUN cd $NOIRCIR_FOLDER && yarn workspaces focus generator +RUN cp -r $NOIRCIR_FOLDER/cms/dist/* $NGINX_FOLDER RUN adduser -D -u 1001 -h /home/$WWW_USER -G $WWW_GROUP $WWW_USER\ diff --git a/canaille/.eslintignore b/canaille/.eslintignore new file mode 100644 index 0000000..b3de155 --- /dev/null +++ b/canaille/.eslintignore @@ -0,0 +1,5 @@ +dist +node_modules +*.mjs +*.test.* +*.stories.* \ No newline at end of file diff --git a/canaille/.eslintrc b/canaille/.eslintrc new file mode 100644 index 0000000..00a7382 --- /dev/null +++ b/canaille/.eslintrc @@ -0,0 +1,25 @@ +{ + "root": true, + "parser": "@typescript-eslint/parser", + "plugins": ["@typescript-eslint"], + "extends": [ + "airbnb", + "airbnb-typescript" + ], + "rules": { + "import/prefer-default-export": 0, + // turn on errors for missing imports + "import/no-unresolved": "error", + "@typescript-eslint/dot-notation": 0, + "react/require-default-props": 0, + "react/jsx-props-no-spreading": 0, + "@typescript-eslint/naming-convention": 0, + "no-underscore-dangle": 0, + "default-case-last": 0, + }, + "settings": { + "import/parsers": { + "@typescript-eslint/parser": [".ts", ".tsx"] + } + } +} diff --git a/canaille/.gitignore b/canaille/.gitignore new file mode 100644 index 0000000..901491d --- /dev/null +++ b/canaille/.gitignore @@ -0,0 +1,5 @@ +node_modules +dist +.yarn +build +stats.json \ No newline at end of file diff --git a/canaille/Readme.md b/canaille/Readme.md new file mode 100644 index 0000000..55bc465 --- /dev/null +++ b/canaille/Readme.md @@ -0,0 +1,51 @@ +# Canaille + +## Overview + +Canaille is a design system created by Bdx.town for building our user interfaces. The system is composed of three libraries that work together to provide a seamless experience for developers and designers. + +The first library is a SASS library that provides a comprehensive set of utility CSS classes. These classes can be used to quickly and easily style any element on a page. The classes are designed to be modular, so developers can mix and match them as needed to achieve their desired look and feel. + +The second library is a React library that provides a collection of reusable UI components. These components are built using the design tokens from the SASS library, making it easy to create consistent and visually appealing interfaces. The React library includes everything from basic layout components to more complex interactive components like modals and dropdown menus. + +The third library is a SVG library that exports a JavaScript bundle for icons. These icons can be easily included in any project that uses Canaille, and are designed to be flexible and scalable to fit any design. + +Canaille (v3+) uses CSS layers to avoid messing with user's code. Utilities classes will take over components style, and other css code will win everything. + +## Live preview + +You can go there to see Canaille at it current state: [https://cl0v1s.github.io/Canaille](https://cl0v1s.github.io/Canaille) + +The current mockup can be found [here](https://www.figma.com/file/m8dLKnCxYvKt8WPxsSjIMH/Mangane?type=design&node-id=1-3&mode=design&t=GauHRHjwvP13vDM0-0) + +## Technical stack + +- node +- javascript +- typescript +- sass + +## Requirements + +- Node version 20+ +- Yarn + +## Code style + +Please check `.eslintrc` file + +## Third-party libraries + +Please check `package.json` for a comprehensive list of canaille dependencies. + +`react` and `react-dom`, `react-router-dom` are exposed as externals of this project. Projects using Canaille must provide compatible versions. + +## Getting started + +1. `yarn` +2. `yarn laddle` + +### Build project + +1. `yarn` +2. `yarn build-prod` diff --git a/canaille/package.json b/canaille/package.json new file mode 100644 index 0000000..ebbc58b --- /dev/null +++ b/canaille/package.json @@ -0,0 +1,82 @@ +{ + "name": "@bdxtown/canaille", + "version": "1.0.0", + "description": "bdx.town design system", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/types/src/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "require": "./dist/index.js", + "types": "./dist/types/src/index.d.ts" + }, + "./src/scss/index.scss": { + "import": "./src/scss/index.scss", + "require": "./src/scss/index.scss" + } + }, + "layers": { + "components": "components", + "utils": "utils" + }, + "files": [ + "./dist/**", + "./src/scss/**" + ], + "scripts": { + "build": "npx webpack -c webpack.dev.mjs", + "build-prod": "npx webpack -c webpack.prod.mjs", + "dev": "npx webpack --config webpack.dev.mjs --watch", + "stats": "npx webpack --profile --json=stats.json -c webpack.prod.mjs && npx webpack-bundle-analyzer stats.json", + "test": "npx jest", + "lint": "npx eslint --fix .", + "laddle": "npx ladle serve", + "laddle-build": "npx ladle build --base $BASE_URL", + "story": "node toolchain/create-story.mjs", + "publish-package": "npx yarn build-prod && npm publish" + }, + "author": "", + "license": "ISC", + "dependencies": { + "@babel/core": "^7.23.6", + "@babel/preset-env": "^7.23.6", + "@babel/preset-react": "^7.23.3", + "@babel/preset-typescript": "^7.23.3", + "@react-docgen/cli": "^2.0.1", + "@tabler/icons-react": "^2.44.0", + "@types/react": "^18.2.45", + "@types/react-dom": "^18.2.18", + "babel-loader": "^9.1.3", + "css-loader": "^6.9.0", + "debounce": "^2.0.0", + "deepmerge": "^4.3.1", + "eslint-config-airbnb": "^19.0.4", + "eslint-import-resolver-typescript": "^3.6.1", + "eslint-plugin-import": "^2.29.1", + "eslint-plugin-jsx-a11y": "^6.8.0", + "eslint-plugin-react": "^7.33.2", + "eslint-plugin-react-hooks": "^4.6.0", + "file-loader": "^6.2.0", + "ladlescoop": "^1.0.8", + "react": "^18.2.0", + "react-docgen": "^7.0.1", + "react-dom": "^18.2.0", + "sass": "^1.69.5", + "sass-loader": "^13.3.2", + "style-loader": "^3.3.3", + "typescript": "^5.3.3", + "webpack": "^5.89.0", + "webpack-cli": "^5.1.4" + }, + "devDependencies": { + "@ladle/react": "^4.0.2", + "@types/webpack-bundle-analyzer": "^4", + "@typescript-eslint/eslint-plugin": "^6.15.0", + "@typescript-eslint/parser": "^6.15.0", + "eslint": "^8.56.0", + "eslint-config-airbnb-typescript": "^17.1.0", + "webpack-bundle-analyzer": "^4.10.1" + }, + "packageManager": "yarn@4.0.2" +} diff --git a/canaille/src/index.ts b/canaille/src/index.ts new file mode 100644 index 0000000..3ef7cc3 --- /dev/null +++ b/canaille/src/index.ts @@ -0,0 +1,14 @@ +import './scss/index.scss'; + +export * from './js/helpers/createUseStyles'; +export * from './js/helpers/useTranslations'; +export * from './js/Block/common'; +export * from './js/Button/common'; +export * from './js/Checkbox/common'; +export * from './js/Error/common'; +export * from './js/Line/common'; +export * from './js/Link/common'; +export * from './js/Radio/common'; +export * from './js/SearchInput/common'; +export * from './js/SmallInput/common'; +export * from './js/TextInput/common'; diff --git a/canaille/src/js/Block/Block.stories.tsx b/canaille/src/js/Block/Block.stories.tsx new file mode 100644 index 0000000..1c9e87d --- /dev/null +++ b/canaille/src/js/Block/Block.stories.tsx @@ -0,0 +1,11 @@ +import type { Story } from '@ladle/react'; +import React from 'react'; +import { Block } from './common.tsx'; + +export const BlockStory: Story = (props) => Test; +BlockStory.storyName = 'Block'; + +BlockStory.args = {}; +BlockStory.argTypes = { + state: { options: ['default', 'hover'], control: { type: 'select' } }, +}; diff --git a/canaille/src/js/Block/common.tsx b/canaille/src/js/Block/common.tsx new file mode 100644 index 0000000..e8b2e57 --- /dev/null +++ b/canaille/src/js/Block/common.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { ICommonProps } from '../types/ICommonProps'; +import { createUseStyles } from '../helpers/createUseStyles'; +import { block as blockCSS } from './style'; + +const useStyle = createUseStyles({ + block: { + '---border': 'unset', + '---padding': 'unset', + '---background-color': 'unset', + '---box-shadow': 'unset', + '---radius': 'unset', + + border: 'var(---border)', + padding: 'var(---padding)', + backgroundColor: 'var(---background-color)', + boxShadow: 'var(---box-shadow)', + borderRadius: 'var(---radius)', + } as React.CSSProperties, + canaille: ({ state, variant }) => blockCSS(variant, state), +}); + +interface IBlock extends ICommonProps { + variant?: 'default' | 'interactive', + state?: 'default' | 'hover', + children: React.ReactNode, +} + +export const Block = React.forwardRef(({ + state = 'default', variant = 'default', className, children, ...rest +}: IBlock, ref) => { + const { block, canaille } = useStyle({ state, variant }); + + return ( +
} className={`${block} ${canaille} ${className}`}> + { children } +
+ ); +}); diff --git a/canaille/src/js/Block/style.ts b/canaille/src/js/Block/style.ts new file mode 100644 index 0000000..3ea02b8 --- /dev/null +++ b/canaille/src/js/Block/style.ts @@ -0,0 +1,29 @@ +import React from 'react'; +import { merge } from '../helpers/merge'; + +function stateCSS(state): React.CSSProperties { + switch (state) { + case 'hover': { + return { + '---background-color': 'var(--additional-primary)', + '---box-shadow': 'var(--dp-25)', + } as React.CSSProperties; + } + case 'default': + default: { + return { + + }; + } + } +} + +export const block = (variant, state) => merge({ + '---border': '2px solid var(--grey-100)', + '---padding': 'var(--spacing-3)', + '---background-color': 'var(--white)', + '---box-shadow': 'unset', + '---radius': 'var(--rounded-100)', + + '&:hover': variant === 'interactive' ? stateCSS('hover') : null, +} as React.CSSProperties, stateCSS(state)); diff --git a/canaille/src/js/Button/Button.stories.tsx b/canaille/src/js/Button/Button.stories.tsx new file mode 100644 index 0000000..06e35d3 --- /dev/null +++ b/canaille/src/js/Button/Button.stories.tsx @@ -0,0 +1,34 @@ +import type { Story } from '@ladle/react'; +import React from 'react'; +import { Button } from './common.tsx'; + +import '../../scss/google-fonts.scss'; + +export const ButtonStory: Story = (props) => ; +ButtonStory.storyName = 'Button'; + +ButtonStory.args = { + htmlType: 'button', + disabled: false, + variant: 'primary', + size: 100, + state: 'default', +}; +ButtonStory.argTypes = { + htmlType: { + options: ['button', 'submit'], + control: { type: 'select' }, + defaultValue: 'button', + }, + variant: { + options: ['primary', 'secondary', 'light'], + control: { type: 'select' }, + defaultValue: 'primary', + }, + size: { options: [50, 100], control: { type: 'select' }, defaultValue: 100 }, + state: { + options: ['hover', 'default'], + control: { type: 'select' }, + defaultValue: 'default', + }, +}; diff --git a/canaille/src/js/Button/common.tsx b/canaille/src/js/Button/common.tsx new file mode 100644 index 0000000..189e287 --- /dev/null +++ b/canaille/src/js/Button/common.tsx @@ -0,0 +1,87 @@ +import React from 'react'; + +import { ICommonProps } from '../types/ICommonProps'; +import { createUseStyles } from '../helpers/createUseStyles'; +import { buttonCSS } from './style'; +import '../../scss/index.scss'; + +export interface IButton extends ICommonProps { + htmlType?: 'button' | 'submit'; + children?: React.ReactNode; + disabled?: boolean; + onClick?: (event: React.MouseEvent) => void; + variant?: 'primary' | 'secondary' | 'light'; + size?: 50 | 100; + state?: 'hover' | 'default'; +} + +const useStyles = createUseStyles({ + variables: { + '--background-color': 'unset', + '--text-color': 'unset', + '--font-size': 'unset', + '--padding': 'unset', + '--border-radius': 'unset', + '--border': 'unset', + '--box-shadow': 'unset', + '--gap': 'unset', + } as React.CSSProperties, + button: { + position: 'relative', + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + gap: 'var(--gap)', + backgroundColor: 'var(--background-color)', + color: 'var(--text-color)', + fontSize: 'var(--font-size)', + padding: 'var(--padding)', + borderRadius: 'var(--border-radius)', + border: 'var(--border)', + boxShadow: 'var(--box-shadow)', + cursor: 'pointer', + } as React.CSSProperties, + canaille: ({ variant, size, state }) => buttonCSS(variant, state, size), +}); + +const Button = React.forwardRef( + ( + { + className, + htmlType = 'button', + id, + testId, + style, + children, + disabled = false, + onClick, + variant = 'primary', + size = 100, + state = 'default', + ...rest + }: IButton, + ref, + ) => { + const { variables, button, canaille } = useStyles({ variant, size, state }); + + return ( + + ); + }, +); + +Button.displayName = 'Button'; + +export { Button }; diff --git a/canaille/src/js/Button/style.ts b/canaille/src/js/Button/style.ts new file mode 100644 index 0000000..01fe0d3 --- /dev/null +++ b/canaille/src/js/Button/style.ts @@ -0,0 +1,85 @@ +import React from 'react'; + +import { merge } from '../helpers/merge'; + +function variantCSS(variant) { + switch (variant) { + case 'secondary': { + return { + '--background-color': 'transparent', + '--border': '2px solid transparent', + '--box-shadow': 'none', + }; + } + case 'light': { + return { + '--background-color': 'var(--additional-primary)', + '--border': '2px solid var(--grey-100)', + '--border-radius': 'var(--rounded-100)', + }; + } + case 'primary': + default: { + return { + '--background-color': 'var(--brand-primary)', + '--border': '2px solid var(--grey-100)', + '--border-radius': 'var(--rounded-100)', + }; + } + } +} + +function stateCSS(state, size, variant) { + switch (state) { + case 'hover': { + return { + '--box-shadow': size === 50 ? 'var(--dp-75)' : 'var(--dp-100)', + transform: 'translate(2px, -2px)', + ...(variant === 'secondary' + ? { '--background-color': 'var(--brand-primary)', '--border': '2px solid var(--grey-100)' } + : {}), + }; + } + case 'default': + default: { + return { + '--box-shadow': size === 50 ? 'var(--dp-25)' : 'var(--dp-75)', + }; + } + } +} + +function sizeCSS(size) { + switch (size) { + case 50: { + return { + '--padding': 'var(--spacing-2)', + '--gap': 'var(--spacing-2)', + '--font-size': 'var(--text-100)', + '--border-radius': 'var(--rounded-50)', + }; + } + case 100: + default: { + return { + '--padding': 'var(--spacing-3)', + '--gap': 'var(--spacing-2)', + '--font-size': 'var(--text-125)', + }; + } + } +} + +export function buttonCSS(variant, state, size) { + return merge( + { + '--text-color': 'var(--grey-100)', + transition: 'all 0.2s ease', + + '&:hover': stateCSS('hover', size, variant), + } as React.CSSProperties, + stateCSS(state, size, variant), + variantCSS(variant), + sizeCSS(size), + ); +} diff --git a/canaille/src/js/Checkbox/Checkbox.stories.tsx b/canaille/src/js/Checkbox/Checkbox.stories.tsx new file mode 100644 index 0000000..c9ed2f5 --- /dev/null +++ b/canaille/src/js/Checkbox/Checkbox.stories.tsx @@ -0,0 +1,21 @@ +import type { Story } from '@ladle/react'; +import React from 'react'; +import { Checkbox } from './common.tsx'; + +export const CheckboxStory: Story = (props) => ( + Test +); +CheckboxStory.storyName = 'Checkbox'; + +CheckboxStory.args = { state: 'default' }; +CheckboxStory.argTypes = { + state: { + options: ['default', 'hover', 'checked'], + control: { type: 'select' }, + defaultValue: 'default', + }, + required: { + options: [true, false], + control: { type: 'radio' }, + }, +}; diff --git a/canaille/src/js/Checkbox/common.tsx b/canaille/src/js/Checkbox/common.tsx new file mode 100644 index 0000000..e64eb05 --- /dev/null +++ b/canaille/src/js/Checkbox/common.tsx @@ -0,0 +1,176 @@ +import React from 'react'; + +import { IconCheck } from '@tabler/icons-react'; +import { ICommonProps } from '../types/ICommonProps'; +import { IFormProps } from '../types/IFormProps'; +import { createUseStyles } from '../helpers/createUseStyles'; +import { useValidation } from '../helpers/form/form'; +import { Error } from '../Error/common'; +import { checkbox as checkboxCSS } from './style'; + +import '../../scss/index.scss'; + +export interface ICheckboxBase extends ICommonProps, IFormProps { + state?: 'default' | 'hover' | 'checked'; + /** + * Checkbox text content + */ + children?: React.ReactNode; + /** + * Click event handler. + */ + onClick?: () => void | null; + /** + * True if disabled. + */ + disabled?: boolean; + /** + * True if checked. + */ + checked?: boolean; + /** + * Default checked state. + */ + defaultChecked?: boolean; + /** + * Change event handler. + */ + onChange?: (e: React.ChangeEvent) => void; +} + +const useStyle = createUseStyles({ + variables: { + '--radius': 'unset', + '--border': 'unset', + '--font-size': 'unset', + '--height': 'unset', + '--width': 'unset', + '--background-color': 'unset', + '--label-color': 'unset', + '--box-shadow': 'unset', + '--pointer-events': 'auto', + '--check-opacity': 0, + } as React.CSSProperties, + checkbox: { + display: 'inline-flex', + alignItems: 'center', + gap: 'var(--spacing-2)', + color: 'var(--label-color)', + + '&>input': { + position: 'absolute', + width: 0, + height: 0, + opacity: 0, + '& + div>svg': { + opacity: 'var(--check-opacity)', + }, + }, + '&>span': { + userSelect: 'none', + }, + '&>div': { + transition: 'all 0.2s ease', + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + width: 'var(--width)', + height: 'var(--height)', + boxShadow: 'var(--box-shadow)', + borderRadius: 'var(--radius)', + border: 'var(--border)', + backgroundColor: 'var(--background-color)', + } as React.CSSProperties, + } as React.CSSProperties, + canaille: ({ state }) => checkboxCSS(state), +}); + +const Checkbox = React.forwardRef( + ( + { + state = 'default', + className, + name, + id, + testId, + style, + children, + onClick, + onChange, + disabled, + defaultChecked, + checked, + required, + validateOnChange, + checkValidity, + onInvalid, + ...rest + }: ICheckboxBase, + ref, + ) => { + // Unique id, useful for testing + const nameId = React.useId(); + const internalName = name || `switch-${nameId}`; + + const htmlFor = React.useId(); + + // Input ref, to use in validation hook + const inputRef = React.useRef(null); + + // Validation hook + const { + onInput, + onInvalid: onInvalidInternal, + error, + } = useValidation({ + root: inputRef, + validateOnChange: validateOnChange as boolean, + onInvalid, + checkValidity, + }); + + const { variables, checkbox, canaille } = useStyle({ state }); + + return ( + + ); + }, +); + +Checkbox.displayName = 'Checkbox'; + +export { Checkbox }; diff --git a/canaille/src/js/Checkbox/style.ts b/canaille/src/js/Checkbox/style.ts new file mode 100644 index 0000000..936cd69 --- /dev/null +++ b/canaille/src/js/Checkbox/style.ts @@ -0,0 +1,43 @@ +import React from 'react'; +import { merge } from '../helpers/merge'; + +function stateCSS(state): React.CSSProperties { + switch (state) { + case 'hover': { + return { + '--box-shadow': 'var(--dp-25)', + '&>div': { + transform: 'translate(2px, -2px)', + }, + } as React.CSSProperties; + } + case 'checked': { + return { + '--check-opacity': 1, + '--background-color': 'var(--brand-primary)', + } as React.CSSProperties; + } + case 'default': + default: { + return {}; + } + } +} + +export const checkbox = (state) => merge( + { + '--radius': 'var(--rounded-50)', + '--border': '2px solid var(--grey-100)', + '--font-size': 'var(--text-100)', + '--height': '28px', + '--width': '28px', + '--background-color': 'var(--additional-primary)', + '--label-color': 'var(--grey-100)', + '--box-shadow': 'unset', + '--pointer-events': 'auto', + + '&:hover': stateCSS('hover'), + '&>input:checked+div': stateCSS('checked'), + } as React.CSSProperties, + stateCSS(state), +); diff --git a/canaille/src/js/Error/common.tsx b/canaille/src/js/Error/common.tsx new file mode 100644 index 0000000..2dda22e --- /dev/null +++ b/canaille/src/js/Error/common.tsx @@ -0,0 +1,47 @@ +import React from 'react'; + +import { ICommonProps } from '../types/ICommonProps'; +import { createUseStyles } from '../helpers/createUseStyles'; + +const useStyle = createUseStyles({ + error: { + display: 'block', + fontSize: 'var(--text-50)', + color: 'var(--alert-100)', + fontStyle: 'normal', + marginTop: 'var(--spacing-1)', + paddingLeft: 'var(--spacing-25)', + textAlign: 'left', + fontWeight: '400', + }, +}); + +interface IError extends ICommonProps { + children: React.ReactNode; +} + +const Error = React.forwardRef( + ({ + className = '', id, style, testId, children, ...rest + }: IError, ref) => { + const { error } = useStyle(); + // https://www.w3.org/WAI/GL/wiki/Using_ARIA_role_of_alert_for_Error_Feedback_in_Forms + return ( + + {children} + + ); + }, +); + +Error.displayName = 'Error'; + +export { Error }; diff --git a/canaille/src/js/Line/Line.stories.tsx b/canaille/src/js/Line/Line.stories.tsx new file mode 100644 index 0000000..1aa32da --- /dev/null +++ b/canaille/src/js/Line/Line.stories.tsx @@ -0,0 +1,11 @@ +import type { Story } from '@ladle/react'; +import React from 'react'; +import { Line } from './common.tsx'; + +export const LineStory: Story = (props) => ; +LineStory.storyName = 'Line'; + +LineStory.args = { className: '' }; +LineStory.argTypes = { + variant: { options: ['dashed', 'solid'], control: { type: 'select' } }, +}; diff --git a/canaille/src/js/Line/common.tsx b/canaille/src/js/Line/common.tsx new file mode 100644 index 0000000..a3501a9 --- /dev/null +++ b/canaille/src/js/Line/common.tsx @@ -0,0 +1,55 @@ +import React, { useMemo } from 'react'; +import { createUseStyles } from '../helpers/createUseStyles'; +import { ICommonProps } from '../types/ICommonProps'; +import { line as lineCSS } from './style'; + +import '../../scss/index.scss'; + +const useStyle = createUseStyles({ + line: { + '--color': 'unset', + '--background': 'unset', + '--height': 'unset', + border: 'none', + background: 'var(--background)', + height: 'var(--height)', + color: 'var(--color)', + } as React.CSSProperties, + canaille: ({ variant }) => lineCSS(variant), +}); + +interface ILine extends ICommonProps { + variant?: 'dashed' | 'solid'; +} + +const Line = React.forwardRef( + ({ variant = 'dashed', className = '', ...rest }: ILine, ref: unknown) => { + const { line, canaille } = useStyle({ variant }); + return ( +
} + className={`${line} ${canaille} ${className}`} + > + {variant === 'dashed' ? ( + + + + ) : null} +
+ ); + }, +); + +export { Line }; diff --git a/canaille/src/js/Line/style.ts b/canaille/src/js/Line/style.ts new file mode 100644 index 0000000..78c9491 --- /dev/null +++ b/canaille/src/js/Line/style.ts @@ -0,0 +1,21 @@ +import React from 'react'; +import { merge } from '../helpers/merge'; + +function variantCSS(variant): React.CSSProperties { + if (variant === 'dashed') { + return { + '--background': 'none', + } as React.CSSProperties; + } + return { + '--background': 'var(--color)', + } as React.CSSProperties; +} + +export const line = (variant) => merge( + { + '--height': '2px', + '--color': 'var(--grey-100)', + } as React.CSSProperties, + variantCSS(variant), +); diff --git a/canaille/src/js/Link/Link.stories.tsx b/canaille/src/js/Link/Link.stories.tsx new file mode 100644 index 0000000..a2faff6 --- /dev/null +++ b/canaille/src/js/Link/Link.stories.tsx @@ -0,0 +1,13 @@ +import type { Story } from '@ladle/react'; +import React from 'react'; +import { Link } from './common.tsx'; + +export const LinkStory: Story = (props) => Test; +LinkStory.storyName = 'Link'; + +LinkStory.args = { className: '', href: undefined }; +LinkStory.argTypes = { + state: { options: ['default', 'hover'], control: { type: 'select' } }, + size: { options: [100, 50], control: { type: 'select' } }, + href: { control: { type: 'text' } }, +}; diff --git a/canaille/src/js/Link/common.tsx b/canaille/src/js/Link/common.tsx new file mode 100644 index 0000000..b550779 --- /dev/null +++ b/canaille/src/js/Link/common.tsx @@ -0,0 +1,81 @@ +import React from 'react'; +import { createUseStyles } from '../helpers/createUseStyles'; +import { ICommonProps } from '../types/ICommonProps'; +import { link as linkCSS } from './style'; + +import '../../scss/index.scss'; + +const useStyle = createUseStyles({ + link: { + '--text-decoration': 'unset', + '--background-color': 'unset', + '--font-size': 'unset', + '--line-height': 'unset', + + transition: 'all 0.4s ease', + border: 'none', + textDecoration: 'var(--text-decoration)', + backgroundColor: 'var(--background-color)', + fontSize: 'var(--font-size)', + lineHeight: 'var(--line-height)', + display: 'inline-block', + padding: '1px', + color: 'currentcolor', + fontFamily: 'inherit', + cursor: 'pointer', + } as React.CSSProperties, + canaille: ({ state, size }) => linkCSS(state, size), +}); + +interface ILink extends ICommonProps { + size?: 50 | 100; + state?: 'default' | 'hover'; + href?: string; + onClick?: React.MouseEventHandler; + children: React.ReactNode; + target?: string; +} + +const Link = React.forwardRef( + ( + { + className = '', + href, + onClick, + size = 100, + state = 'default', + children, + target, + ...rest + }: ILink, + ref, + ) => { + const { link, canaille } = useStyle({ state, size }); + if (href) { + return ( + } + target={target} + href={href} + className={`${link} ${canaille} ${className}`} + > + {children} + + ); + } + return ( + + ); + }, +); + +export { Link }; diff --git a/canaille/src/js/Link/style.ts b/canaille/src/js/Link/style.ts new file mode 100644 index 0000000..ed27c41 --- /dev/null +++ b/canaille/src/js/Link/style.ts @@ -0,0 +1,34 @@ +import React from 'react'; +import { merge } from '../helpers/merge'; + +function sizeCSS(size) { + if (size === 100) { + return { + '--font-size': 'var(--text-100)', + '--line-height': '100%', + } as React.CSSProperties; + } + return { + '--font-size': 'var(--text-50)', + '--line-height': '100%', + } as React.CSSProperties; +} + +function stateCSS(state) { + if (state === 'hover') { + return { + '--background-color': 'var(--brand-primary)', + } as React.CSSProperties; + } + return {} as React.CSSProperties; +} + +export const link = (state, size) => merge( + { + '--text-decoration': 'underline', + + '&:hover': stateCSS('hover'), + } as React.CSSProperties, + sizeCSS(size), + stateCSS(state), +); diff --git a/canaille/src/js/Radio/Radio.stories.tsx b/canaille/src/js/Radio/Radio.stories.tsx new file mode 100644 index 0000000..9146986 --- /dev/null +++ b/canaille/src/js/Radio/Radio.stories.tsx @@ -0,0 +1,24 @@ +import type { Story } from '@ladle/react'; +import React from 'react'; +import { Radio } from './common.tsx'; + +export const RadioStory: Story = (props) => ( + + + Option 0 + + + Option 1 + + +); +RadioStory.storyName = 'Radio'; + +RadioStory.args = { className: '', state: 'default' }; +RadioStory.argTypes = { + state: { + options: ['default', 'hover', 'checked'], + control: { type: 'select' }, + defaultValue: 'default', + }, +}; diff --git a/canaille/src/js/Radio/common.tsx b/canaille/src/js/Radio/common.tsx new file mode 100644 index 0000000..2ac969d --- /dev/null +++ b/canaille/src/js/Radio/common.tsx @@ -0,0 +1,243 @@ +import * as React from 'react'; + +import { ICommonProps } from '../types/ICommonProps'; +import { IFormProps } from '../types/IFormProps'; +import { createUseStyles } from '../helpers/createUseStyles'; +import { useValidation } from '../helpers/form/form'; +import { Error } from '../Error/common'; +import { radio as radioCSS } from './style'; + +interface IRadioContext extends IFormProps { + globalDisabled?: boolean; + defaultValue: string | number | string[] | undefined; + internalName: string | undefined; + size?: number; + error?: string; +} + +export const RadioContext = React.createContext({ + onChange: undefined, + internalName: undefined, + value: undefined, + name: undefined, + globalDisabled: undefined, + defaultValue: undefined, + error: undefined, + size: undefined, +}); + +const useStyle = createUseStyles({ + canaille: ({ state }) => radioCSS(state), + radio: { + border: 'none', + }, + radioItem: { + '--background-color': 'unset', + '--border': 'unset', + '--border-color': 'unset', + '--inner-color': 'unset', + '--size': 'unset', + '--inner-size': 'unset', + '--text-color': 'unset', + '--box-shadow': 'unset', + + display: 'inline-flex', + alignItems: 'center', + } as React.CSSProperties, + + radioItemInput: { + position: 'relative', + appearance: 'none', + backgroundColor: 'var(--background-color)', + margin: 0, + padding: 0, + font: 'inherit', + color: 'var(--text-color)', + width: 'var(--size)', + height: 'var(--size)', + border: 'var(--border)', + boxShadow: 'var(--box-shadow)', + borderRadius: '50%', + marginRight: 'var(--spacing-2)', + flexShrink: '0', + + '&::before': { + position: 'absolute', + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%)', + content: "''", + display: 'block', + width: 'var(--inner-size)', + height: 'var(--inner-size)', + backgroundColor: 'var(--inner-color)', + borderRadius: '100%', + }, + } as React.CSSProperties, +}); + +export interface IRadioItem extends ICommonProps, IFormProps { + state?: 'default' | 'hover' | 'checked'; + /** + * Radio value + */ + value: string | number; + /** + * Radio disabled state + */ + disabled?: boolean; + /** + * children + */ + children?: React.ReactNode; +} + +const RadioItem = React.forwardRef( + ( + { + className = '', + testId, + style, + value, + state = 'default', + disabled, + id, + required, + children, + ...restOfProps + }: IRadioItem, + ref, + ) => { + const { defaultValue, internalName } = React.useContext(RadioContext); + + const _internalId = React.useId(); + const internalId = id || _internalId; + + const { canaille, radioItem, radioItemInput } = useStyle({ state }); + + const input = React.useRef(null); + + return ( + + ); + }, +); + +RadioItem.displayName = 'RadioItem'; + +export interface IRadio extends ICommonProps, IFormProps { + /** + * Radio size + */ + size?: number; + /** + * Radio.Item content + */ + children: React.ReactNode; + /** + * Name used to identify the radio group onSubmit event + */ + name?: string; +} + +const Radio: React.ForwardRefExoticComponent< +Omit & React.RefAttributes +> & { Item: typeof RadioItem } = Object.assign( + React.forwardRef( + ( + { + children, + onChange, + id, + testId, + className, + size = 100, + style, + value, + defaultValue, + disabled, + validateOnChange, + onInvalid, + checkValidity, + name, + ...restOfProps + }: IRadio, + ref, + ) => { + const root = React.useRef(null); + React.useImperativeHandle(ref, () => root.current); + const { radio } = useStyle({}); + const v4 = React.useId(); + const internalName = React.useMemo(() => name || `radio-${v4}`, [name]); + + const { + onInput, + onInvalid: onInvalidInternal, + error, + } = useValidation({ + root, + validateOnChange: validateOnChange as boolean, + onInvalid, + checkValidity, + }); + + const contextValue = React.useMemo(() => ({ + value, + globalDisabled: disabled, + internalName, + defaultValue, + onChange, + error, + size, + }), [value, disabled, internalName, defaultValue, onChange, error, size]); + + return ( + +
} + id={id} + data-testid={testId} + className={`${radio} ${className}`} + style={style} + onInput={onInput} + onInvalid={onInvalidInternal} + disabled={disabled} + > + {children} + {error && {error}} +
+
+ ); + }, + ), + { + Item: RadioItem, + }, +); + +Radio.displayName = 'Radio'; + +export { RadioItem, Radio }; diff --git a/canaille/src/js/Radio/style.ts b/canaille/src/js/Radio/style.ts new file mode 100644 index 0000000..682c76b --- /dev/null +++ b/canaille/src/js/Radio/style.ts @@ -0,0 +1,47 @@ +import React from 'react'; +import { merge } from '../helpers/merge'; + +function stateCSS(state): React.CSSProperties { + switch (state) { + case 'hover': { + return { + '--box-shadow': 'var(--dp-25)', + + '&>input': { + transform: 'translate(2px, -2px)', + }, + } as React.CSSProperties; + } + case 'checked': { + return { + '--background-color': 'var(--brand-primary)', + '--inner-color': 'var(--grey-100)', + } as React.CSSProperties; + } + case 'default': + default: { + return {}; + } + } +} + +export const radio = (state) => merge( + { + '--background-color': 'var(--additional-primary)', + '--border': '2px solid', + '--border-color': 'var(--grey-100)', + '--inner-color': 'var(--background-color)', + '--size': '28px', + '--inner-size': '8.33px', + '--text-color': 'var(--grey-100)', + '--box-shadow': 'unset', + + '&>input': { + transition: 'all 0.2s ease', + }, + + '&>input:checked': stateCSS('checked'), + '&:hover': stateCSS('hover'), + } as React.CSSProperties, + stateCSS(state), +); diff --git a/canaille/src/js/SearchInput/SearchInput.stories.tsx b/canaille/src/js/SearchInput/SearchInput.stories.tsx new file mode 100644 index 0000000..900311c --- /dev/null +++ b/canaille/src/js/SearchInput/SearchInput.stories.tsx @@ -0,0 +1,19 @@ +import type { Story } from '@ladle/react'; +import React from 'react'; +import { SearchInput } from './common.tsx'; + +export const SearchInputStory: Story = (props) => ; +SearchInputStory.storyName = 'SearchInput'; + +SearchInputStory.args = { className: '', placeholder: 'Search' }; +SearchInputStory.argTypes = { + htmlType: { + options: ['email', 'number', 'password', 'search', 'tel', 'text', 'url'], + control: { type: 'select' }, + }, + state: { + options: ['default', 'hover', 'focus'], + control: { type: 'select' }, + }, + variant: { options: ['default', 'mini'], control: { type: 'select' } }, +}; diff --git a/canaille/src/js/SearchInput/common.tsx b/canaille/src/js/SearchInput/common.tsx new file mode 100644 index 0000000..a9a78ce --- /dev/null +++ b/canaille/src/js/SearchInput/common.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import { IconSearch } from '@tabler/icons-react'; +import { createUseStyles } from '../helpers/createUseStyles'; +import { ITextInput, TextInput } from '../TextInput/common'; +import { searchInput } from './style'; + +import '../../scss/index.scss'; + +const useStyle = createUseStyles({ + variables: { + '--button-background-color': 'unset', + '--button-border': 'unset', + '--button-box-shadow': 'unset', + '--button-padding': 'unset', + '--button-border-radius': 'unset', + } as React.CSSProperties, + button: { + backgroundColor: 'var(--button-background-color)', + border: 'var(--button-border)', + boxShadow: 'var(--button-box-shadow)', + padding: 'var(--button-padding)', + borderRadius: 'var(--button-border-radius)', + } as React.CSSProperties, + canaille: ({ variant, state }) => searchInput(variant, state), +}); + +interface ISearchInput extends Omit { + state?: 'default' | 'hover' | 'focus'; + variant?: 'default' | 'mini'; +} + +const SearchInput = React.forwardRef( + ( + { + className = '', + placeholder = 'Search', + variant, + state, + ...rest + }: ISearchInput, + ref, + ) => { + const { canaille, variables, button } = useStyle({ variant, state }); + + return ( + + + + ); + }, +); + +export { SearchInput }; diff --git a/canaille/src/js/SearchInput/style.ts b/canaille/src/js/SearchInput/style.ts new file mode 100644 index 0000000..414ec8c --- /dev/null +++ b/canaille/src/js/SearchInput/style.ts @@ -0,0 +1,57 @@ +import React from 'react'; +import { merge } from '../helpers/merge'; + +function variantCSS(variant): React.CSSProperties { + switch (variant) { + default: { + return {}; + } + case 'mini': { + return { + '--display': 'inline-flex', + '--padding-left': 'var(--spacing-2)', + '--button-padding': 0, + '&>label>input': { + display: 'none', + }, + } as React.CSSProperties; + } + } +} + +function stateCSS(state): React.CSSProperties { + switch (state) { + default: { + return {}; + } + case 'focus': { + return { + '--button-background-color': 'var(--brand-primary)', + '--button-border': '2px solid var(--grey-100)', + '--button-box-shadow': 'var(--dp-25)', + '--button-border-radius': 'var(--rounded-50)', + + '--button-padding': 'var(--spacing-1)', + '--display': 'flex', + '--padding-left': 'var(--spacing-3)', + '&>label>input': { + display: 'initial', + }, + } as React.CSSProperties; + } + } +} + +export const searchInput = (variant, state) => merge( + { + '--padding-right': 'var(--spacing-2)', + '--padding-top': 'var(--padding-bottom)', + + '--button-padding': 'var(--spacing-1)', + '--button-border': '2px solid transparent', + + '&:focus, &:focus-within': stateCSS('focus'), + } as React.CSSProperties, + variantCSS(variant), + stateCSS(state), +); diff --git a/canaille/src/js/SmallInput/SmallInput.stories.tsx b/canaille/src/js/SmallInput/SmallInput.stories.tsx new file mode 100644 index 0000000..38f71ff --- /dev/null +++ b/canaille/src/js/SmallInput/SmallInput.stories.tsx @@ -0,0 +1,22 @@ +import type { Story } from '@ladle/react'; +import React from 'react'; +import { SmallInput } from './common.tsx'; + +export const SmallInputStory: Story = (props) => ( +
+ +
+); +SmallInputStory.storyName = 'SmallInput'; + +SmallInputStory.args = {}; +SmallInputStory.argTypes = { + htmlType: { + options: ['email', 'number', 'password', 'search', 'tel', 'text', 'url'], + control: { type: 'select' }, + }, + state: { + options: ['default', 'hover', 'focus'], + control: { type: 'select' }, + }, +}; diff --git a/canaille/src/js/SmallInput/common.tsx b/canaille/src/js/SmallInput/common.tsx new file mode 100644 index 0000000..0ee0a27 --- /dev/null +++ b/canaille/src/js/SmallInput/common.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import { ITextInput, TextInput } from '../TextInput/common'; +import { createUseStyles } from '../helpers/createUseStyles'; +import { smallInput } from './style'; + +const useStyle = createUseStyles({ + smallInputCSS: { + '--border-bottom': 'unset', + + '&>label': { + border: 'unset', + borderTop: 'var(--border)', + borderLeft: 'var(--border)', + borderRight: 'var(--border)', + borderBottom: 'var(--border-bottom)', + + transition: 'all 0.2s ease', + }, + } as React.CSSProperties, + canaille: ({ state }) => smallInput(state), +}); + +type ISmallInput = Omit; + +const SmallInput = React.forwardRef( + ({ state, className, ...rest }: ISmallInput, ref) => { + const { canaille, smallInputCSS } = useStyle({ state }); + + return ( + + ); + }, +); + +export { SmallInput }; diff --git a/canaille/src/js/SmallInput/style.ts b/canaille/src/js/SmallInput/style.ts new file mode 100644 index 0000000..5d7ad24 --- /dev/null +++ b/canaille/src/js/SmallInput/style.ts @@ -0,0 +1,47 @@ +import React from 'react'; +import { merge } from '../helpers/merge'; + +function stateCSS(state) { + switch (state) { + case 'default': + default: { + return {}; + } + case 'hover': { + return { + '--border': 'var(--border-bottom)', + '--border-radius': 'var(--rounded-25)', + '--background-color': 'var(--additional-primary)', + '--box-shadow': 'var(--dp-25)', + }; + } + case 'focus': { + return { + '--border': 'var(--border-bottom)', + '--border-radius': 'var(--rounded-25)', + '--background-color': 'var(--additional-primary)', + '--text-color': 'var(--grey-100)', + '--box-shadow': 'var(--dp-75)', + }; + } + } +} + +export const smallInput = (state) => merge( + { + '--text-color': + 'hsl(var(--grey-100-h) var(--grey-100-s) var(--grey-100-l) / 0.6)', + '--display': 'inline-flex', + '--padding-top': 'var(--padding-bottom)', + '--padding-right': 'var(--spacing-2)', + '--padding-left': 'var(--spacing-2)', + '--border': '2px solid transparent', + '--border-bottom': '2px solid var(--grey-100)', + '--border-radius': 0, + '--background-color': 'transparent', + + '&:hover': stateCSS('hover'), + '&:focus, &:focus-within': stateCSS('focus'), + } as React.CSSProperties, + stateCSS(state), +); diff --git a/canaille/src/js/TextInput/TextInput.stories.tsx b/canaille/src/js/TextInput/TextInput.stories.tsx new file mode 100644 index 0000000..1bfa491 --- /dev/null +++ b/canaille/src/js/TextInput/TextInput.stories.tsx @@ -0,0 +1,25 @@ +import type { Story } from '@ladle/react'; +import React from 'react'; +import { TextInput } from './common.tsx'; + +import '../../scss/google-fonts.scss'; + +export const TextInputStory: Story = (props) => ; +TextInputStory.storyName = 'TextInput'; + +TextInputStory.args = { + htmlType: 'text', + label: 'test', + placeholder: 'placeholder', +}; +TextInputStory.argTypes = { + htmlType: { + options: ['email', 'number', 'password', 'search', 'tel', 'text', 'url'], + control: { type: 'select' }, + defaultValue: 'text', + }, + state: { + options: ['default', 'hover', 'focus'], + control: { type: 'select' }, + }, +}; diff --git a/canaille/src/js/TextInput/common.tsx b/canaille/src/js/TextInput/common.tsx new file mode 100644 index 0000000..f97124a --- /dev/null +++ b/canaille/src/js/TextInput/common.tsx @@ -0,0 +1,353 @@ +import React from 'react'; + +import { TablerIconsProps } from '@tabler/icons-react'; +import { ICommonProps } from '../types/ICommonProps'; +import { IFormProps } from '../types/IFormProps'; +import { createUseStyles } from '../helpers/createUseStyles'; + +import { Error } from '../Error/common'; +import { useValidation } from '../helpers/form/form'; + +import { textInputCSS } from './style'; + +import '../../scss/index.scss'; + +export const TEXTINPUTICON_DISPLAYNAME = 'TextInputIcon'; +export const TEXTINPUTERASE_DISPLAYNAME = 'TextInputErase'; +export const TEXTINPUTSELECT_DISPLAYNAME = 'TextInputSelect'; + +const useStyle = createUseStyles({ + textInput: { + '--display': 'unset', + '--background-color': 'unset', + '--border': 'unset', + '--box-shadow': 'unset', + '--placeholder-color': 'unset', + '--text-color': 'unset', + '--padding-top': 'unset', + '--padding-bottom': 'unset', + '--padding-left': 'unset', + '--padding-right': 'unset', + '--border-radius': 'unset', + '--font-size': 'unset', + + '--label-display': 'unset', + '--label-color': 'unset', + '--label-background-color': 'unset', + '--label-padding': 'unset', + '--label-border-radius': 'unset', + '--label-border': 'unset', + '--label-box-shadow': 'unset', + '--label-padding-x': 'unset', + '--label-padding-y': 'unset', + '--label-font-size': 'unset', + + border: 0, + padding: 0, + display: 'var(--display)', + minWidth: 0, + + '&>label': { + position: 'relative', + display: 'var(--display)', + width: '100%', + + alignItems: 'center', + + paddingBottom: 'var(--padding-bottom)', + paddingTop: 'var(--padding-top)', + paddingLeft: 'var(--padding-left)', + paddingRight: 'var(--padding-right)', + border: 'var(--border)', + boxShadow: 'var(--box-shadow)', + backgroundColor: 'var(--background-color)', + color: 'var(--text-color)', + borderRadius: 'var(--border-radius)', + + transition: 'box-shadow 0.2s ease', + + '&>input': { + width: '100%', + display: 'var(--display)', + fontFamily: 'inherit', + border: 0, + fontSize: 'var(--font-size)', + backgroundColor: 'transparent', + color: 'inherit', + '&::placeholder': { + color: 'var(--placeholder-color)', + fontFamily: 'inherit', + }, + } as React.CSSProperties, + + '&>[aria-roledescription=label]': { + display: 'var(--label-display)', + fontSize: 'var(--label-font-size)', + position: 'absolute', + top: 0, + border: 'var(--label-border)', + left: 'var(--padding-left)', + transform: 'translateY(-50%)', + color: 'var(--label-color)', + backgroundColor: 'var(--label-background-color)', + padding: 'var(--label-padding)', + borderRadius: 'var(--label-border-radius)', + boxShadow: 'var(--label-box-shadow)', + paddingLeft: 'var(--label-padding-x)', + paddingRight: 'var(--label-padding-x)', + paddingTop: 'var(--label-padding-y)', + paddingBottom: 'var(--label-padding-y)', + } as React.CSSProperties, + } as React.CSSProperties, + } as React.CSSProperties, + canaille: ({ state }) => textInputCSS(state), +}); + +interface ITextInputContext { + onErase: React.MouseEventHandler; + disabled: boolean; + setError: (e: string | undefined) => void; +} + +export const TextInputContext = React.createContext({ + disabled: false, + setError: () => null, + onErase: () => null, +}); + +const TEXTINPUTBEFORE_DISPLAYNAME = 'TextInputBefore'; + +interface ITextInputBefore { + children: React.ReactNode; +} + +function TextInputBefore({ children }: ITextInputBefore) { + return children; +} + +TextInputBefore.displayName = TEXTINPUTBEFORE_DISPLAYNAME; + +export interface ITextInput extends ICommonProps, IFormProps { + /** + * Input type + */ + htmlType?: + | 'email' + | 'number' + | 'password' + | 'search' + | 'tel' + | 'text' + | 'url'; + /** + * Placeholder content to show in the TextInput + */ + placeholder?: string; + /** + * Label associated with the TextInput + */ + label?: React.ReactNode; + /** + * Get focus on page load. Only on field per page. + */ + autofocus?: boolean; + /** + * Maximum string length + */ + maxLength?: number; + /** + * Minimum string length + */ + minLength?: number; + /** + * Read only mode + */ + readonly?: boolean; + /** + * Should activate spellcheck + */ + spellcheck?: boolean; + /** + * The values of the list attribute is the id of a element located in the same document + * The provides a list of predefined values to suggest to the user for this input. + * Any values in the list that are not compatible with the type are not included + * in the suggested options. + * The values provided are suggestions, not requirements: users can select from this predefined + * list or provide a different value. + */ + list?: string; + /** + * Max value if type number + */ + max?: number; + /** + * Min value if type number + */ + min?: number; + /** + * Step increase if type number + */ + step?: number; + /** + * Regexp that value must match + */ + pattern?: string; + /** + * Optional additional elements + */ + children?: + | Array< + React.ReactElement< + unknown, + React.JSXElementConstructor + > + > + | React.ReactElement< + unknown, + React.JSXElementConstructor + >; + state?: 'default' | 'hover' | 'focus'; +} + +const TextInput = React.forwardRef( + ( + { + autofocus, + checkValidity, + className, + defaultValue, + disabled, + id, + label, + maxLength, + minLength, + name, + onChange, + onInvalid, + placeholder, + readonly, + required, + spellcheck, + style, + testId, + validateOnChange, + value, + htmlType: type = 'text', + list, + max, + min, + pattern, + step, + children, + state = 'default', + ...rest + }: ITextInput, + ref, + ) => { + const { textInput, canaille } = useStyle({ state }); + const input = React.useRef(null); + const uid = React.useId(); + const internalId = id || uid; + const { + error, + setError, + onInput, + onInvalid: onInternalInvalid, + } = useValidation({ + root: input, + validateOnChange: validateOnChange || false, + checkValidity, + onInvalid, + }); + + const { before, select, others } = React.useMemo(() => { + const wIcons: Array = []; + const wErase: Array = []; + const wSelect: Array = []; + const wOthers: Array = []; + const wBefore: Array = []; + + React.Children.toArray(children).forEach((c) => { + const child = c as React.ReactElement; + if (child.type?.displayName === TEXTINPUTSELECT_DISPLAYNAME) wSelect.push(child); + else if (child.type?.displayName === TEXTINPUTBEFORE_DISPLAYNAME) wBefore.push(child); + else wOthers.push(child); + }); + + return { + icons: wIcons, + erase: wErase, + select: wSelect, + others: wOthers, + before: wBefore, + }; + }, [children]); + + const onErase = React.useCallback(() => { + if (!input.current) return; + input.current.value = ''; + const event = new Event('change', { bubbles: true }); + Object.defineProperty(event, 'target', { + writable: false, + value: input.current, + }); + input.current.dispatchEvent(event); + if (onChange) onChange(event as unknown as React.ChangeEvent); + }, [onChange]); + + const contextValue = React.useMemo(() => ({ + disabled: disabled || false, setError, onErase, + }), [disabled, setError, onErase]); + + return ( + +
} + className={`${textInput} ${canaille} ${className}`} + style={style} + data-testid={testId} + > + +
+ {error && {error}} +
+ ); + }, +); + +export { TextInput, TextInputBefore }; diff --git a/canaille/src/js/TextInput/style.ts b/canaille/src/js/TextInput/style.ts new file mode 100644 index 0000000..cf8f6c3 --- /dev/null +++ b/canaille/src/js/TextInput/style.ts @@ -0,0 +1,49 @@ +import React from 'react'; +import { merge } from '../helpers/merge'; + +function stateCSS(state: string): React.CSSProperties { + switch (state) { + default: + case 'default': { + return {}; + } + case 'focus': { + return { + '--box-shadow': 'var(--dp-75)', + } as React.CSSProperties; + } + } +} + +export const textInputCSS = (state) => merge( + { + '--display': 'flex', + '--box-shadow': 'unset', + '--background-color': 'var(--additional-primary)', + '--border': '2px solid var(--grey-100)', + '--border-radius': 'var(--rounded-100)', + '--padding-left': 'var(--spacing-3)', + '--padding-right': 'var(--spacing-3)', + '--padding-bottom': 'var(--spacing-2)', + '--padding-top': '18px', + '--font-size': 'var(--text-100)', + '--text-color': 'var(--grey-100)', + + '--label-display': 'block', + '--label-background-color': 'var(--brand-primary)', + '--label-padding-x': 'var(--spacing-2)', + '--label-padding-y': 'var(--spacing-1)', + '--label-border-radius': 'var(--rounded-50)', + '--label-border': '1px solid var(--grey-100)', + '--label-box-shadow': 'var(--dp-25)', + '--label-font-size': 'var(--text-50)', + '--label-color': 'var(--grey-100)', + + '--placeholder-color': + 'hsl(var(--grey-100-h) var(--grey-100-s) var(--grey-100-l) / 0.64)', + + '&:hover': stateCSS('hover'), + '&:focus, &:focus-within': stateCSS('focus'), + }, + stateCSS(state), +); diff --git a/canaille/src/js/helpers/createUseStyles.ts b/canaille/src/js/helpers/createUseStyles.ts new file mode 100644 index 0000000..fff3f52 --- /dev/null +++ b/canaille/src/js/helpers/createUseStyles.ts @@ -0,0 +1,449 @@ +import React from 'react'; +import debounce from 'debounce'; +import pack from '../../../package.json'; + +type Style = { [cls: string]: Style | React.CSSProperties | ((props: any) => Style | React.CSSProperties) | Style }; + +type CSSItem = { + rule: string; + properties: Array; + children?: Array; +}; + +type Sheet = { + insertRule: (r: string, index: number) => string; + deleteRule: (rulename: string) => void; + remove: () => void; + reset: () => void; + style: Array; +}; + +type Keyframe = { + prefix: string; + name: string; + originalName: string; +}; + +const BASE_CLASS = 'CANAILLE'; + +/** + * Return a counter function used to generate rules prefixes + * @returns + */ +function createGenerateId() { + let counter = 0; + return function generateId() { + counter += 1; + return counter; + }; +} + +/** + * Manage style insertion. + * On serverside, we store all styles in a dict to be able to serve them in SSR + * @param rules + * @returns + */ +function createNode(rules = '', namespace = ''): Sheet { + if (globalThis.window) { + const styleNode = document.createElement('style') as HTMLStyleElement; + styleNode.className = namespace; + document.head.appendChild(styleNode); + const styleSheet = styleNode.sheet; + // jest does not handle Layers correctly so we directly insert to the stylesheet instead + if (!globalThis.process?.env?.JEST_WORKER_ID) { + styleSheet.insertRule(`@layer ${pack.layers.components} { ${rules} }`); + } + const staticNode = !globalThis.process?.env?.JEST_WORKER_ID + ? (styleSheet.cssRules[0] as CSSLayerBlockRule) + : styleSheet; + + const reset = () => { + while (staticNode.cssRules.length > 0) { + staticNode.deleteRule(0); + } + }; + + const insertRule = (rule: string, index: number) => { + staticNode.insertRule(rule, index); + const r = staticNode.cssRules[index].cssText; + return r; + }; + + const deleteRule = (rule: string) => { + for (let i = 0; i < staticNode.cssRules.length; i += 1) { + if (rule === staticNode.cssRules[i].cssText) { + staticNode.deleteRule(i); + return; + } + } + }; + + return { + style: [], + reset, + deleteRule, + insertRule, + remove: styleNode.remove.bind(styleNode), + }; + } + const style = [`@layer ${pack.layers.components} { ${rules} }`]; + return { + style, + reset: () => { + style.splice(0, style.length); + }, + deleteRule: () => null, + insertRule: (r) => { + style.push(`@layer ${pack.layers.components} { ${r} }`); + return r; + }, + remove: () => null, + }; +} + +/** + * Define Style meta data + * All informations related to css-in-js during render is stored here + */ +class StyleMetaData { + /** + * Declare layers and layer priorities + */ + public layerSheet: Sheet; + + /** + * Declare static css style (not declared in functions) + */ + public staticSheet: Sheet; + + /** + * Declare dynamic css style (declared in functions) + */ + public dynamicSheet: Sheet; + + /** + * Declare all keyframes + */ + public keyframes: Array; + + /** + * Generate ids (reset that to allow sync between server and client during ssr) + */ + public generateId: () => number; + + public namespace: string; + + constructor() { + this.namespace = `BOTO-${new Date().getTime()}`; + this.keyframes = []; + this.layerSheet = createNode('', `${this.namespace}-layer`); + this.staticSheet = createNode('', `${this.namespace}-static`); + this.reset(); + } + + public reset = () => { + this.generateId = createGenerateId(); + this.dynamicSheet?.remove(); + this.dynamicSheet = createNode('', `${this.namespace}-dynamic`); + }; + + public getServerSideStyle = () => + `${this.layerSheet.style.join('\n\n')}\n\n${this.staticSheet.style.join('\n\n')}\n\n${this.dynamicSheet.style.join( + '\n\n' + )}`; +} + +export const StyleMeta = new StyleMetaData(); + +/** + * Manage sheets priority + * @param higherId + */ +// May be debounce for performance + +function _updateLayersOrder(higherId: number) { + // JEST doesnt handle layers correctly + if (globalThis.process?.env?.JEST_WORKER_ID) return; + StyleMeta.layerSheet.reset(); + let layers = new Array(higherId + 1).fill(0); + layers = layers.reduce((acc, curr, index) => [...acc, `${BASE_CLASS}-${index}`], []); + const rule = `@layer ${layers.join(', ')};`; + StyleMeta.layerSheet.insertRule(rule, 0); +} + +const updateLayersOrder = !globalThis.window ? _updateLayersOrder : debounce(_updateLayersOrder, 50); + +class Scope { + hash: string; + + mapping: { [x: string]: string }; + + constructor() { + this.hash = Math.random().toString(16).slice(2); + this.mapping = {}; + } + + register(original, scoped) { + this.mapping[original] = scoped; + } +} + +/** + * Convert JS rule into valid css rule + * Handle $keyframe name replacement + * @param ruleName + * @param ruleBody + * @returns + */ +function parseRule(prefix: string, ruleName: string, ruleBody: string, scope: Scope) { + const name = ruleName + .replace(/([A-Z])/g, '-$1') + .toLowerCase() + .trim(); + let body = ruleBody; + + + // handle scoped var usage + if (typeof body === 'string') { + const varMatch = body?.match(/var\((.+?)\)/); + if (varMatch && scope.mapping[varMatch[1]]) { + body = body.replace(varMatch[1], scope.mapping[varMatch[1]]); + } + } + if (name === 'animation' || name === 'animation-name') { + const match = body.match(/\$(\S+)/); + const rootPrefix = prefix.match(/(\S+?-[0-9]+)-.+/); + if (match) { + // we first search in the current prefix, and then expand to the root prefix (Boto-xx-id-xx first and then Boto-xx) + const keyframe = + StyleMeta.keyframes.find((k) => k.originalName === match[1] && k.prefix === prefix) || + (rootPrefix && StyleMeta.keyframes.find((k) => k.originalName === match[1] && k.prefix === rootPrefix[1])); + if (keyframe) { + body = body.replace(match[0], keyframe.name); + } else { + console.warn(`createUseStyle: Unable to find declared keyframe ${match[1]} in stylesheet ${prefix}`); + console.warn(StyleMeta.keyframes); + } + } + // handle scope var declaration + } else if (name.startsWith('---')) { + const scoped = name.replace('---', `--${scope.hash}-`); + scope.register(name, scoped); + return `${scoped}: ${body};`; + } + return `${name}: ${body};`; +} + +/** + * Flatten an inheritance + * @param css + * @returns + */ +function flatCSS(css: CSSItem): Array { + return [ + { rule: css.rule, properties: css.properties }, + ...(css.children + .map((c) => flatCSS({ ...c, rule: c.rule.replace(/&/g, css.rule) })) + .flat(Infinity) as Array), + ]; +} + +/** + * Convert JS style object into intermediate CSSItem structure + * @param prefix + * @param className + * @param obj + * @returns + */ +function convertObjectToCSSString( + prefix: string, + className: string, + obj: React.CSSProperties, + scope: Scope, +): CSSItem | null { + if (!obj) return null; + const { properties, children } = Object.keys(obj).reduce( + (acc, curr) => { + if (typeof obj[curr] === 'object') { + const calculated = convertObjectToCSSString(prefix, curr, obj[curr], scope); + if (!calculated) return acc; + const parts = curr.split(','); + return { + ...acc, + children: [...acc.children, ...parts.map((r) => ({ ...calculated, rule: r }))], + }; + } + return { + ...acc, + properties: [...acc.properties, parseRule(prefix, curr, obj[curr], scope)], + }; + }, + { + properties: [], + children: [], + } + ); + + return { + rule: className, + properties, + children: children.flat(Infinity) as Array, + }; +} + +/** + * Handle keyframe declaration + * @param prefix + * @param ruleName + * @param style + * @param props + * @returns + */ +function createKeyFrames(prefix, ruleName, style, scope: Scope, props = []) { + // extract keyframe name + const originalName = ruleName.replace('@keyframes', '').trim(); + const name = `${prefix}-${originalName}`; + const keyframe: Keyframe = { + name, + originalName, + prefix, + }; + StyleMeta.keyframes.push(keyframe); + const css = + typeof style === 'function' + ? convertObjectToCSSString(prefix, name, (style as (...props: any) => React.CSSProperties)(...props), scope) + : convertObjectToCSSString(prefix, name, style as React.CSSProperties, scope); + if (!css) + return { + className: '', + rules: [], + }; + const frames = flatCSS(css); + // first is keyframe declaration we can ignore + frames.shift(); + const rule = ` + @keyframes ${name} { + ${frames.map((f) => `${f.rule} { ${f.properties.join('\n')} }`).join('\n\n')} + } + `; + return { + // we dont want to issue any className for this + className: '', + rules: [rule], + }; +} + +/** + * Convert JS style declaration into valid css rules + * @param prefix + * @param ruleName + * @param style + * @param props + * @returns + */ +function createCSSBlock(prefix: string, ruleName: string, style, scope: Scope,props = []) { + if (ruleName.startsWith('@keyframes')) return createKeyFrames(prefix, ruleName, style, scope, props); + const className = `${prefix}-${ruleName}`; + const css = + typeof style === 'function' + ? convertObjectToCSSString(prefix, className, (style as (...props: any) => React.CSSProperties)(...props), scope) + : convertObjectToCSSString(prefix, className, style as React.CSSProperties, scope); + + if (!css) + return { + className: '', + rules: [], + }; + + const nakedRule = flatCSS(css).map((c) => `.${c.rule.trim()} {\n ${c.properties.join('\n')} \n}`); + + // we wrap rules inside a nested layer so we can ensure that higher prefix ID ensure higher priority + const basePrefix = prefix.match(/[A-Z]+-[0-9]+/i)[0]; + const layeredRule = ` + @layer ${basePrefix} { + ${nakedRule.join('\n\n')} + } + `; + + // Jest doesnt handle layer correctly + const rules = globalThis.process?.env?.JEST_WORKER_ID ? nakedRule : [layeredRule]; + + return { + className, + rules, + }; +} + +// Select correct hook given the context +// if react 18 -> React.useInsertionEffect else useLayoutEffect. On the ServerSide, we directly run the func +const useLayoutEffect = globalThis.window ? React.useInsertionEffect || React.useLayoutEffect : (fn) => fn(); + +/** + * Manage stylesheet created from object declarations + * CSSLayerBlockRule at index 0 is always the static style + * CSSLayerBlockRule at index 1 is always the dynamic style + */ +export function createUseStyles(style: Style) { + // we use a counter there since we cant use an uniqueId (it's a hook) + // SSR users will need to reset the counter before each page render + const sheetId = StyleMeta.generateId(); + const scope = new Scope(); + + + + // here we only apply "static" styles aka the ones that arent declared via func + const staticClasseNames = Object.keys(style).reduce((acc, curr) => { + if (typeof style[curr] === 'function') return acc; + const { className, rules } = createCSSBlock(`${BASE_CLASS}-${sheetId}`, curr, style[curr], scope); + rules.forEach((r) => StyleMeta.staticSheet.insertRule(r, 0)); + return { + ...acc, + [curr]: className, + }; + }, {}); + // create static node + + updateLayersOrder(sheetId); + + return function useStyle(...props): { [key: string]: string } { + // consistent id between server and browser + const styleId = React.useId().replace(/:/g, ''); + const propsHash = JSON.stringify(props, (key, value) => { + // we handle stringify some edgecases + if (globalThis.HTMLElement && value instanceof HTMLElement) + return value.className + value.id + value.parentElement?.className + value.parentElement?.id; + return value; + }); + + // we dont want to retrigger at dom edit everytime, so we store intermediate results to be able to compare + const { dynamicClassNames, dynamicRules } = React.useMemo(() => { + const wDynamicRules = []; + const wDynamicClassNames = Object.keys(style).reduce((acc, curr) => { + if (typeof style[curr] !== 'function') return acc; + const { className, rules } = createCSSBlock(`${BASE_CLASS}-${sheetId}-${styleId}`, curr, style[curr], scope, props); + wDynamicRules.push(...rules); + return { + ...acc, + [curr]: className, + }; + }, {}); + + return { + dynamicClassNames: wDynamicClassNames, + dynamicRules: wDynamicRules, + }; + }, [propsHash]); + + // here we only apply "dynamic" styles aka the ones that are declared via func + useLayoutEffect(() => { + if (dynamicRules.length === 0) return undefined; + const rules = dynamicRules.map((r) => StyleMeta.dynamicSheet.insertRule(r, 0)); + return () => { + rules.forEach((r) => StyleMeta.dynamicSheet.deleteRule(r)); + }; + }, [JSON.stringify(dynamicRules)]); + + return React.useMemo(() => ({ ...staticClasseNames, ...dynamicClassNames }), [JSON.stringify(dynamicClassNames)]); + }; +} diff --git a/canaille/src/js/helpers/form/form.de-DE.i18n.json b/canaille/src/js/helpers/form/form.de-DE.i18n.json new file mode 100644 index 0000000..501a31f --- /dev/null +++ b/canaille/src/js/helpers/form/form.de-DE.i18n.json @@ -0,0 +1,30 @@ +{ + "useValidation": { + "type": { + "number": "Zahl", + "date": "Datum", + "datetime-local": "Datum und Uhrzeit", + "email": "E-Mail-Adresse", + "month": "Monat", + "password": "Passwort", + "tel": "Telefonnummer", + "time": "Uhrzeit", + "url": "URL-Adresse", + "week": "Woche", + "datetime": "Datum und Uhrzeit", + "radio": "radio", + "text": "text" + }, + "error": { + "badInput": "Ungültiger Wert.", + "patternMismatch": "Bitte den Mustervorlaben entsprechen.", + "rangeOverflow": "Der Wert ist größer als %(max)s.", + "rangeUnderflow": "Der Wert ist kleiner als %(min)s.", + "stepMismatch": "Ungültiger Wert.", + "tooLong": "Der Wert muss weniger als %(maxLength)s Zeichen lang sein.", + "tooShort": "Der Wert muss mehr als %(minLength)s Zeichen lang sein.", + "typeMismatch": "Bitte eine %(type)s eingeben.", + "valueMissing": "Dieses Feld ist erforderlich." + } + } +} diff --git a/canaille/src/js/helpers/form/form.en-GB.i18n.json b/canaille/src/js/helpers/form/form.en-GB.i18n.json new file mode 100644 index 0000000..6d500bc --- /dev/null +++ b/canaille/src/js/helpers/form/form.en-GB.i18n.json @@ -0,0 +1,30 @@ +{ + "useValidation": { + "type": { + "number": "number", + "date": "date", + "datetime-local": "date", + "email": "email address", + "month": "month", + "password": "password", + "tel": "phone number", + "time": "time", + "url": "url address", + "week": "week", + "datetime": "date", + "radio": "radio", + "text": "text" + }, + "error": { + "badInput": "Invalid value.", + "patternMismatch": "Please match the pattern.", + "rangeOverflow": "The value is greater than %(max)s.", + "rangeUnderflow": "The value is lower than %(min)s.", + "stepMismatch": "Invalid value.", + "tooLong": "The value must be less than %(maxLength)s characters.", + "tooShort": "The value must be greater than %(minLength)s characters.", + "typeMismatch": "Please input a %(type)s.", + "valueMissing": "This field is required." + } + } +} diff --git a/canaille/src/js/helpers/form/form.en-US.i18n.json b/canaille/src/js/helpers/form/form.en-US.i18n.json new file mode 100644 index 0000000..6d500bc --- /dev/null +++ b/canaille/src/js/helpers/form/form.en-US.i18n.json @@ -0,0 +1,30 @@ +{ + "useValidation": { + "type": { + "number": "number", + "date": "date", + "datetime-local": "date", + "email": "email address", + "month": "month", + "password": "password", + "tel": "phone number", + "time": "time", + "url": "url address", + "week": "week", + "datetime": "date", + "radio": "radio", + "text": "text" + }, + "error": { + "badInput": "Invalid value.", + "patternMismatch": "Please match the pattern.", + "rangeOverflow": "The value is greater than %(max)s.", + "rangeUnderflow": "The value is lower than %(min)s.", + "stepMismatch": "Invalid value.", + "tooLong": "The value must be less than %(maxLength)s characters.", + "tooShort": "The value must be greater than %(minLength)s characters.", + "typeMismatch": "Please input a %(type)s.", + "valueMissing": "This field is required." + } + } +} diff --git a/canaille/src/js/helpers/form/form.es-ES.i18n.json b/canaille/src/js/helpers/form/form.es-ES.i18n.json new file mode 100644 index 0000000..b4237e7 --- /dev/null +++ b/canaille/src/js/helpers/form/form.es-ES.i18n.json @@ -0,0 +1,30 @@ +{ + "useValidation": { + "type": { + "number": "número", + "date": "fecha", + "datetime-local": "fecha", + "email": "dirección de correo electrónico", + "month": "mes", + "password": "contraseña", + "tel": "número de teléfono", + "time": "hora", + "url": "dirección URL", + "week": "semana", + "datetime": "fecha", + "radio": "radio", + "text": "texto" + }, + "error": { + "badInput": "Valor no válido.", + "patternMismatch": "Por favor, siga el patrón.", + "rangeOverflow": "El valor es mayor que %(max)s.", + "rangeUnderflow": "El valor es menor que %(min)s.", + "stepMismatch": "Valor no válido.", + "tooLong": "El valor debe tener menos de %(maxLength)s caracteres.", + "tooShort": "El valor debe tener más de %(minLength)s caracteres.", + "typeMismatch": "Por favor, ingrese un %(type)s.", + "valueMissing": "Este campo es obligatorio." + } + } +} diff --git a/canaille/src/js/helpers/form/form.fr-FR.i18n.json b/canaille/src/js/helpers/form/form.fr-FR.i18n.json new file mode 100644 index 0000000..49d7e08 --- /dev/null +++ b/canaille/src/js/helpers/form/form.fr-FR.i18n.json @@ -0,0 +1,30 @@ +{ + "useValidation": { + "type": { + "number": "nombre", + "date": "date", + "datetime-local": "date", + "email": "adresse email", + "month": "mois", + "password": "mot de passe", + "tel": "numéro de téléphone", + "time": "horraire", + "url": "adresse url", + "week": "semaine", + "datetime": "date", + "radio": "radio", + "text": "texte" + }, + "error": { + "badInput": "La valeur n'est pas valide.", + "patternMismatch": "Veuillez respecter le format demandé.", + "rangeOverflow": "La valeur saisie est supérieure à %(max)s.", + "rangeUnderflow": "La valeur saisie est inférieure à %(min)s.", + "stepMismatch": "Veuillez saisir une valeur valide.", + "tooLong": "La valeur doit faire moins de %(maxLength)s caractères.", + "tooShort": "La valeur doit faire plus de %(minLength)s caractères.", + "typeMismatch": "Veuillez saisir un(e) %(type)s.", + "valueMissing": "Ce champ est requis." + } + } +} diff --git a/canaille/src/js/helpers/form/form.it-IT.i18n.json b/canaille/src/js/helpers/form/form.it-IT.i18n.json new file mode 100644 index 0000000..cdcbcb6 --- /dev/null +++ b/canaille/src/js/helpers/form/form.it-IT.i18n.json @@ -0,0 +1,30 @@ +{ + "useValidation": { + "type": { + "number": "numero", + "date": "data", + "datetime-local": "data", + "email": "indirizzo email", + "month": "mese", + "password": "password", + "tel": "numero di telefono", + "time": "orario", + "url": "indirizzo URL", + "week": "settimana", + "datetime": "data", + "radio": "radio", + "text": "testo" + }, + "error": { + "badInput": "Valore non valido.", + "patternMismatch": "Inserisci il valore corretto.", + "rangeOverflow": "Il valore è maggiore di %(max)s.", + "rangeUnderflow": "Il valore è minore di %(min)s.", + "stepMismatch": "Valore non valido.", + "tooLong": "Il valore deve contenere meno di %(maxLength)s caratteri.", + "tooShort": "Il valore deve contenere più di %(minLength)s caratteri.", + "typeMismatch": "Inserisci un %(type)s valido.", + "valueMissing": "Questo campo è obbligatorio." + } + } +} diff --git a/canaille/src/js/helpers/form/form.test.js b/canaille/src/js/helpers/form/form.test.js new file mode 100644 index 0000000..d565b17 --- /dev/null +++ b/canaille/src/js/helpers/form/form.test.js @@ -0,0 +1,322 @@ +import React from 'react'; +import { + findByText, + fireEvent, + getAllByRole, + getByRole, + getByTestId, + getByText, + render, +} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import translate from 'counterpart'; +import { container } from 'webpack'; +import { TranslationContext, useValidation } from '../../../../dist/goodmed'; + +import us from './form.en-US.i18n.json'; + +const HELLO = 'Hello'; +const ERROR = 'Not Hello'; + +function DummyCmpSimpleInput({ onSubmit }) { + const inp = React.useRef(); + + const checkValidity = React.useCallback( + (val) => (val !== HELLO ? ERROR : undefined), + [], + ); + const { error, onInvalid, onInput } = useValidation({ + root: inp, + checkValidity, + }); + + return ( +
+ + {error && {error}} + +
+ ); +} + +function DummyCmpMultipleInput({ onSubmit }) { + const inp = React.useRef(); + + const checkValidity = React.useCallback( + (val) => (val !== '1' ? ERROR : undefined), + [], + ); + const { error, onInvalid, onInput } = useValidation({ + root: inp, + checkValidity, + }); + + return ( +
+
+ + + +
+ {error && {error}} + +
+ ); +} + +function DummyCmpSelectInput({ onSubmit }) { + const inp = React.useRef(); + + const checkValidity = React.useCallback( + (val) => (val !== HELLO ? ERROR : undefined), + [], + ); + const { error, onInvalid, onInput } = useValidation({ + root: inp, + checkValidity, + }); + + return ( +
+ + {error && {error}} + +
+ ); +} + +function checkNativeRequired(Cmp, role, action) { + return async () => { + const onSubmit = jest.fn((e) => { + e.preventDefault(); + }); + const { container } = render( + + + , + ); + const submit = getByRole(container, 'button'); + const input = getAllByRole(container, role)[0]; + + fireEvent.click(submit); + await findByText(container, us.useValidation.error.valueMissing); + expect(onSubmit).toHaveBeenCalledTimes(0); + + // following the input type we may need to click or input text + await action(input); + + fireEvent.click(submit); + expect(onSubmit).toHaveBeenCalledTimes(1); + }; +} + +test( + 'Validation system: native required check simple input', + checkNativeRequired(DummyCmpSimpleInput, 'textbox', (input) => userEvent.type(input, HELLO)), +); +test( + 'Validation system: native required check complexe input', + checkNativeRequired(DummyCmpMultipleInput, 'radio', (input) => userEvent.click(input)), +); +test( + 'Validation system: native required check select input', + checkNativeRequired(DummyCmpSelectInput, 'combobox', (input) => userEvent.selectOptions(input, HELLO)), +); + +function checkCustomValidity(Cmp, wrong, right) { + return async () => { + const onSubmit = jest.fn((e) => { + e.preventDefault(); + }); + const { container } = render( + + + , + ); + const submit = getByRole(container, 'button'); + + await wrong(container); + + fireEvent.click(submit); + await findByText(container, ERROR); + expect(onSubmit).toHaveBeenCalledTimes(0); + + // following the input type we may need to click or input text + await right(container); + + fireEvent.click(submit); + expect(onSubmit).toHaveBeenCalledTimes(1); + }; +} + +test( + 'Validation system: custom validity check simple input', + checkCustomValidity( + DummyCmpSimpleInput, + (container) => userEvent.type(getByRole(container, 'textbox'), ERROR), + async (container) => { + await userEvent.clear(getByRole(container, 'textbox')); + return userEvent.type(getByRole(container, 'textbox'), HELLO); + }, + ), +); +test( + 'Validation system: custom validity check complexe input', + checkCustomValidity( + DummyCmpMultipleInput, + (container) => userEvent.click(getAllByRole(container, 'radio')[1]), + (container) => userEvent.click(getAllByRole(container, 'radio')[0]), + ), +); +test( + 'Validation system: custom validity check select input', + checkCustomValidity( + DummyCmpSelectInput, + (container) => userEvent.selectOptions(getByRole(container, 'combobox'), ERROR), + (container) => userEvent.selectOptions(getByRole(container, 'combobox'), HELLO), + ), +); + +function checkSubmit(Cmp, action) { + return async () => { + const onSubmit = jest.fn((e) => { + e.preventDefault(); + }); + const { container } = render( + + + , + ); + + await action(container); + + const submit = getByText(container, 'submit'); + fireEvent.click(submit); + expect(onSubmit).toHaveBeenCalledTimes(1); + }; +} + +test( + 'Validation system: submit simple input', + checkSubmit(DummyCmpSimpleInput, (container) => userEvent.type(getByRole(container, 'textbox'), HELLO)), +); +test( + 'Validation system: submit complexe input', + checkSubmit(DummyCmpMultipleInput, (container) => userEvent.click(getAllByRole(container, 'radio')[0])), +); +test( + 'Validation system: submit select input', + checkSubmit(DummyCmpSelectInput, (container) => userEvent.selectOptions(getByRole(container, 'combobox'), HELLO)), +); + +function DirtyCmpSimpleInput() { + const inp = React.useRef(); + const { onInvalid, onInput } = useValidation({ root: inp }); + return ( + + ); +} + +function DirtyCmpMultipleInput() { + const inp = React.useRef(); + const { onInvalid, onInput } = useValidation({ root: inp }); + return ( +
+ + + +
+ ); +} + +function DirtyCmpSelectInput() { + const inp = React.useRef(); + + const { onInvalid, onInput } = useValidation({ root: inp }); + + return ( + + ); +} + +function checkDirty(Cmp, dirty, clean) { + return async () => { + const onSubmit = jest.fn((e) => { + e.preventDefault(); + }); + const { container } = render( +
+ +
, + ); + await dirty(container); + expect(container.querySelector("[data-dirty='true']")).toBeTruthy(); + await clean(container); + expect(container.querySelector("[data-dirty='true']")).toBeFalsy(); + expect(container.querySelector("[data-dirty='false']")).toBeTruthy(); + }; +} + +test( + 'Validation system: dirty simple input', + checkDirty( + DirtyCmpSimpleInput, + (container) => userEvent.type(getByRole(container, 'textbox'), ERROR), + async (container) => { + await userEvent.clear(getByRole(container, 'textbox')); + return userEvent.type(getByRole(container, 'textbox'), HELLO); + }, + ), +); +test( + 'Validation system: dirty complexe input', + checkDirty( + DirtyCmpMultipleInput, + (container) => userEvent.click(getAllByRole(container, 'radio')[1]), + (container) => userEvent.click(getAllByRole(container, 'radio')[0]), + ), +); +test( + 'Validation system: submit select input', + checkDirty( + DirtyCmpSelectInput, + (container) => userEvent.selectOptions(getByRole(container, 'combobox'), ERROR), + (container) => userEvent.selectOptions(getByRole(container, 'combobox'), HELLO), + ), +); diff --git a/canaille/src/js/helpers/form/form.ts b/canaille/src/js/helpers/form/form.ts new file mode 100644 index 0000000..0bf0833 --- /dev/null +++ b/canaille/src/js/helpers/form/form.ts @@ -0,0 +1,282 @@ +import React from 'react'; + +import { useTranslations } from '../useTranslations'; + +import fr from './form.fr-FR.i18n.json'; +import en from './form.en-US.i18n.json'; +import de from './form.de-DE.i18n.json'; +import gb from './form.en-GB.i18n.json'; +import es from './form.es-ES.i18n.json'; +import it from './form.it-IT.i18n.json'; + +const locales = { + 'fr-FR': fr, + 'en-US': en, + 'de-DE': de, + 'en-GB': gb, + 'es-ES': es, + 'it-IT': it, +}; + +export type HTMLEditableElement = + | HTMLInputElement + | HTMLTextAreaElement + | HTMLSelectElement; + +/** + * Hook,used to allow to track for reset event on input parent form + * Usefull to be able to make reset effective on form component with an internalValue state + * (Like Inputs for example) + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function useFormValue( + ref: React.MutableRefObject, + _initialValue: any, +) { + const initialValue = React.useRef(_initialValue); + const [internalValue, setInternalValue] = React.useState( + initialValue.current, + ); + + const onReset = React.useCallback( + () => setInternalValue(initialValue.current), + [initialValue], + ); + + React.useEffect(() => { + if (!ref.current) return undefined; + const { current } = ref; + if (!current.form) return undefined; + const callback = () => { + current.form.onResets.forEach((c) => c()); + }; + if (!current.form.onResets) { + current.form.onResets = []; + current.form.addEventListener('reset', callback); + } + current.form.onResets = [...current.form.onResets, onReset]; + + return () => { + if (!current.form) return; + current.form.onResets = current.form.onResets.filter( + (o) => o !== onReset, + ); + if (current.form.onResets.length === 0) { + current.form.removeEventListener('reset', callback); + current.form.onResets = null; + } + }; + }, [onReset, ref]); + + return React.useMemo( + () => [internalValue, setInternalValue], + [internalValue, setInternalValue], + ); +} + +/** + * Enable validation translation according application-level locale, instead of browser-level locale + */ +export function useValidation({ + root, + validateOnChange = false, + checkValidity = undefined, + onInvalid = undefined, +}: { + root: React.RefObject; + validateOnChange: boolean; + checkValidity: ((i: unknown) => string | undefined) | undefined; + onInvalid: + | React.FormEventHandler + | undefined; +}) { + const { __, disabled } = useTranslations('useValidation', locales); + const [error, setError] = React.useState(undefined); + // we use an object to be able to retrieve by ref and not only value (timers are stored as number) + const dirtyTimeout = React.useRef({ timer: null }); + const [dirty, setDirty] = React.useState(false); + + React.useEffect(() => { + const timeout = dirtyTimeout.current; + return () => { + if (!timeout.timer) return; + clearTimeout(timeout.timer); + timeout.timer = null; + }; + }, []); + + const setInitialValue = React.useCallback(() => { + if (!(root.current instanceof HTMLSelectElement)) return; + root.current.setAttribute( + 'data-initial-value', + JSON.stringify( + Array.from(root.current.selectedOptions).map((o) => o.value), + ), + ); + }, []); + + /** + * Update data-dirty attribute to help identify changed field + */ + const updateDirty = React.useCallback((node: HTMLEditableElement) => { + let wDirty = false; + if ( + node instanceof HTMLInputElement + && (node.type === 'checkbox' || node.type === 'radio') + ) { + // radio and checkbox + wDirty = node.defaultChecked !== node.checked; + } else if (node instanceof HTMLSelectElement) { + // select element + const initialValue = JSON.parse(node.getAttribute('data-initial-value')); + wDirty = node.selectedOptions.length !== initialValue.length + || !!Array.from(node.selectedOptions).find( + (o) => initialValue.indexOf(o.value) === -1, + ); + } else { + // simple text fields and basic inputs + wDirty = node.defaultValue !== node.value; + } + ( + Array.from( + root.current.querySelectorAll('input, textarea, select'), + ) as Array + ).forEach((i) => i.setAttribute('data-dirty', '')); + node.setAttribute('data-dirty', wDirty ? 'true' : 'false'); + // making react re-render there is preventing + // controlled component to work properly, temporary workaround + const timeout = dirtyTimeout.current; + if (timeout.timer) { + clearTimeout(timeout.timer); + timeout.timer = null; + } + timeout.timer = setTimeout(() => { + timeout.timer = null; + setDirty(wDirty); + }, 200); + }, []); + + /** + * Translate native validation error message so Boto locale is used instead of browser's one + */ + const translateValidity = React.useCallback( + (ev) => { + // Error messages based on https://developer.mozilla.org/en-US/docs/Web/API/ValidityState + const type = __(`type.${ev.target.type || 'text'}`); + + if (!type) { + // eslint-disable-next-line no-console + console.warn( + `Input type ${type} is unknown. Please add a valid translation for ${type} in form.xx-XX.json in boto's code base.`, + ); + } + + const e = Object.keys(fr.useValidation.error).find( + (k) => ev.target.validity[k], + ); + + // if the field is invalid because of a custom check or that we dont handle the error for now + if (!e) return; + + const attrs = { + max: null, + min: null, + maxLength: null, + minLength: null, + }; + Object.keys(attrs).forEach((k) => { + attrs[k] = ev.target.getAttribute(k); + }); + + const warn = !disabled + ? __(`error.${e}`, { ...attrs, type }) + : en.useValidation.error[e]; + + ev.target.setCustomValidity(warn); + }, + [__, disabled], + ); + + /** + * Perform optional custom validity check and update error state + */ + const updateValidity = React.useCallback( + (node: HTMLEditableElement | HTMLFieldSetElement, lifeCycle = false) => { + // we reset all errors for given input / fieldset + node.setCustomValidity(''); + const children = Array.from( + root.current.querySelectorAll('input, textarea, select'), + ) as Array; + children.forEach((i) => i.setCustomValidity('')); + // we update the error message if custom validation fails + // this message is not shown until form or input call checkValidity + if (checkValidity) { + if (node instanceof HTMLFieldSetElement) { + const errors = children.map((c) => checkValidity(c.value)); + const index = errors.findIndex((v) => !!v); + if (index !== -1) { + children[index].setCustomValidity(errors[index]); + } + } else { + const e = checkValidity((node as HTMLEditableElement).value); + if (e) { + node.setCustomValidity(e); + } + } + } + + let hasError = false; + // Handling on change validation + if (validateOnChange && !lifeCycle) { + if (node instanceof HTMLFieldSetElement) { + hasError = !!children.find((c) => !c.checkValidity()); + } else { + hasError = !node.checkValidity(); + } + } + return hasError; + }, + [checkValidity, root, validateOnChange], + ); + + const onInput: React.FormEventHandler< + HTMLEditableElement | HTMLFieldSetElement + > = React.useCallback( + (e) => { + // Event on a fieldset, the target element is the EditableElement + updateDirty(e.target as HTMLEditableElement); + if (!updateValidity(e.target as HTMLEditableElement)) { + setError(undefined); + } + }, + [updateValidity, updateDirty], + ); + + const onInvalidInternal: React.FormEventHandler< + HTMLEditableElement | HTMLFieldSetElement + > = React.useCallback( + (e) => { + translateValidity(e); + // Event on a fieldset, the target element is the EditableElement + setError((e.target as HTMLEditableElement).validationMessage); + if (onInvalid) onInvalid(e); + }, + [onInvalid, translateValidity], + ); + + React.useEffect(() => { + setInitialValue(); + updateValidity(root.current, true); + }, [setInitialValue, updateValidity]); + + return { + error, + setError, + dirty, + onInput, + onInvalid: onInvalidInternal, + setInitialValue, + updateValidity, + updateDirty, + }; +} diff --git a/canaille/src/js/helpers/merge.ts b/canaille/src/js/helpers/merge.ts new file mode 100644 index 0000000..69d11bf --- /dev/null +++ b/canaille/src/js/helpers/merge.ts @@ -0,0 +1,5 @@ +import { all } from 'deepmerge'; + +export function merge(...objs) { + return all(objs); +} diff --git a/canaille/src/js/helpers/useTranslations.tsx b/canaille/src/js/helpers/useTranslations.tsx new file mode 100644 index 0000000..e8412f4 --- /dev/null +++ b/canaille/src/js/helpers/useTranslations.tsx @@ -0,0 +1,173 @@ +import React from 'react'; + +// eslint-disable-next-line @typescript-eslint/ban-types +interface Translator extends Function { + setLocale: (lang: string) => void; + getLocale: () => string; + registerTranslations: (lang: string, keys: object) => void; +} + +interface ITranslationContext { + // counterpart or meteor i18n + __: Translator; + // ISO 639-1 language code + lang: string; + // Import keys for multiple languages at once + importTranslations: (langMap: { [lang: string]: object }) => void; + // set language + setLang: (lang: string) => void; +} + +// Transform keys in format of string into real json object +// keys are dot-string to compatibility issues with weblate +function correctJSON(keys) { + const result = {}; + Object.keys(keys).forEach((key) => { + const parts: Array = key.split('.'); + let current = result; + const last = parts.pop() as string; + parts.forEach((part) => { + if (!current[part]) current[part] = {}; + current = current[part]; + }); + current[last] = keys[key]; + }); + return result; +} + +/** + * Compatibility layer meteor:i18n universe -> counterpart + * Add plural management + * @param {*} key + * @param {*} params + * @param {*} param2 + * @returns + */ +function interpolationAdapter(key, params = {}, { count = undefined } = {}) { + // plural management + let translation = this(count && count > 1 ? `${key}_plural` : key, params); + // unifying interpolation system between counterpart and meteor i18n + Object.keys(params).forEach((item) => { + const reg = new RegExp(`%\\(${item}\\)s`, 'g'); + translation = translation.replace(reg, params[item]); + }); + return translation; +} + +const context: React.Context = React.createContext({ + __: {}, + lang: 'fr-FR', +} as ITranslationContext); + +/** + * Declare a translation context to manage lang changes and trigger re-render + * @param param0 + * @returns + */ +export function TranslationContext({ + __, + children, + defaultLang, +}: { + __: Translator; + children: React.ReactElement | React.ReactElement[]; + defaultLang: string; +}) { + const [lang, setLang] = React.useState(defaultLang); + + const internal__ = React.useMemo(() => { + // plug in the compatibility layer between meteor i18n and counterpart + const int = interpolationAdapter.bind(__); + // since we use a new function we have to rebind all the pre-existing calls + // to the interpolationAdapter + // we want to iterate the prototype chain + // eslint-disable-next-line no-restricted-syntax + for (const key in __) { + // eslint-disable-next-line no-prototype-builtins + if (!int.hasOwnProperty(key)) { + int[key] = __[key]; + } + } + + return int; + }, [__]); + + // allow to use { "fr-FR": keys, "en-US": keys} etc.. + const importTranslations = React.useCallback( + (langMap: { [lang: string]: object }) => { + Object.keys(langMap).forEach((l) => { + internal__.registerTranslations(l, correctJSON(langMap[l])); + }); + }, + [internal__], + ); + + const value: ITranslationContext = React.useMemo( + () => ({ + __: internal__, + lang, + importTranslations, + setLang, + }), + [importTranslations, internal__, lang], + ); + + return {children}; +} + +/** + * Import keys and returns function to translate keys and manage lang changes + * @param componentName + * @param keys + * @returns + */ +export function useTranslations( + componentName: string | undefined = undefined, + keys: { [lang: string]: object } | undefined = undefined, +) { + const { + __, lang, importTranslations, setLang, + } = React.useContext(context); + + const disabled = React.useMemo( + () => !__ || !lang || !importTranslations || !setLang, + [__, lang, importTranslations, setLang], + ); + + const scoped__ = React.useMemo(() => { + if (!__ || !lang || !importTranslations || !setLang) { + return () => null; + } + __.setLocale(lang); + if (keys) { + importTranslations(keys); + } + return (key, ...args) => { + if (componentName) return __(`${componentName}.${key}`, ...args); + return __(key, ...args); + }; + }, [__, componentName, importTranslations, keys, lang, setLang]); + + const T = React.useCallback( + ({ children, ...rest }: { children: React.ReactNode, [k: string]: any }) => ( + + ), + [scoped__], + ); + + return React.useMemo( + () => ({ + setLang, + lang, + __: scoped__, + T, + disabled, + }), + [T, scoped__, setLang, disabled], + ); +} diff --git a/canaille/src/js/types/ICommonProps.ts b/canaille/src/js/types/ICommonProps.ts new file mode 100644 index 0000000..c41cb0a --- /dev/null +++ b/canaille/src/js/types/ICommonProps.ts @@ -0,0 +1,27 @@ +import React from 'react'; + +/** + * Every Canaille component must support those parameters + */ +export interface ICommonProps { + /** + * React ref property + */ + ref?: React.Ref; + /** + * Optional css classes to add to the component + */ + className?: string; + /** + * Optional component DOM ID + */ + id?: string; + /** + * Optional testId + */ + testId?: string; + /** + * Optional style + */ + style?: React.CSSProperties; +} diff --git a/canaille/src/js/types/IFormProps.ts b/canaille/src/js/types/IFormProps.ts new file mode 100644 index 0000000..986ad96 --- /dev/null +++ b/canaille/src/js/types/IFormProps.ts @@ -0,0 +1,50 @@ +export interface IFormProps { + /** + * True if component is readonly + */ + readonly?: boolean; + /** + * Name used to identify the input in a form onSubmit event + */ + name?: string; + /** + * Called on change + * @param e onChange event + * @returns void + */ + onChange?: (e: React.ChangeEvent) => void; + /** + * Value in controlled mode + */ + value?: string | number | string[] | undefined; + /** + * Initial value in uncontrolled mode + */ + defaultValue?: string | number | string[] | undefined; + /** + * True if disabled + */ + disabled?: boolean; + /** + * True if required + */ + required?: boolean; + /** + * Call validation system on each change + */ + validateOnChange?: boolean; + /** + * Custom function to apply specific validity checks + * @param currentValue Input's current value + * @returns A localized string describing the error or undefined in everything is fine + */ + checkValidity?: (currentValue: unknown) => string | undefined; + /** + * Called when invalid + * @param e Invalid event + * @returns nulll + */ + onInvalid?: React.FormEventHandler< + HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement + >; +} diff --git a/canaille/src/scss/_variables.scss b/canaille/src/scss/_variables.scss new file mode 100644 index 0000000..ba8208a --- /dev/null +++ b/canaille/src/scss/_variables.scss @@ -0,0 +1,346 @@ +// Liste de toutes les couleurs de marque disponibles +// Documentation: Colors/Palet/Brand +$color-brand-primary: #6ddbb4; +$color-brand-primary-light: #e2edda; +$color-brand-primary-medium: #6ddbb4; +$color-brand-primary-dark: #31b285; +$brand-colors: ( + "brand-primary": $color-brand-primary, + "brand-primary-light": $color-brand-primary-light, + "brand-primary-medium": $color-brand-primary-medium, + "brand-primary-dark": $color-brand-primary-dark, +); + +// Liste de toutes les couleurs Additionelles +// Documentation: Colors/Palet/Additional +$color-additional-primary: #fff1e3; +$color-additional-primary-light: #faf4f0; +$color-additional-primary-medium: #fff1e3; +$color-additional-primary-dark: #ccae91; +$color-additional-secondary: #070727; +$color-additional-secondary-light: #0c0c46; +$color-additional-secondary-medium: #070727; +$color-additional-secondary-dark: #010029; +$additional-colors: ( + "additional-primary": $color-additional-primary, + "additional-primary-light": $color-additional-primary-light, + "additional-primary-medium": $color-additional-primary-medium, + "additional-primary-dark": $color-additional-primary-dark, + "additional-secondary": $color-additional-primary, + "additional-secondary-light": $color-additional-primary-light, + "additional-secondary-medium": $color-additional-primary-medium, + "additional-secondary-dark": $color-additional-primary-dark, +); + +// Liste de toutes les couleurs d'alerte disponibles +// Documentation: Colors/Palet/Alert +$color-alert-0: unset; +$color-alert-0-light: unset; +$color-alert-0-medium: unset; +$color-alert-0-dark: unset; +$color-alert-25: unset; +$color-alert-25-light: unset; +$color-alert-25-medium: unset; +$color-alert-25-dark: unset; +$color-alert-50: unset; +$color-alert-50-light: unset; +$color-alert-50-medium: unset; +$color-alert-50-dark: unset; +$color-alert-75: unset; +$color-alert-75-light: unset; +$color-alert-75-medium: unset; +$color-alert-75-dark: unset; +$color-alert-100: unset; +$color-alert-100-light: unset; +$color-alert-100-medium: unset; +$color-alert-100-dark: unset; +$color-alert-125: unset; +$color-alert-125-light: unset; +$color-alert-125-medium: unset; +$color-alert-125-dark: unset; +$alert-colors: []; + +// Liste de toutes les gris disponibles +// Documentation: Colors/Palet/Greys +$white: white; +$grey-0: #c7c7f2; +$grey-25: #9595bf; +$grey-50: #51518c; +$grey-75: #222259; +$grey-100: #070727; +$grey-colors: ( + "white": $white, + "grey-0": $grey-0, + "grey-25": $grey-25, + "grey-50": $grey-50, + "grey-75": $grey-75, + "grey-100": $grey-100, +); + +// Liste de toutes les couleurs disponibles +$colors: map-merge($additional-colors, $brand-colors); +$colors: map-merge($colors, $grey-colors); + +// listes des polices disponibles +// Documentation: Typography/Families +$font-family-primary: "Atkinson Hyperlegible"; +$font-family-secondary: unset; +$font-family-tertiary: unset; +$font-families: ( + "font-family-primary": $font-family-primary, +); + +// Liste de toutes les tailles de police disponibles +// Documentation: Typography/Size +$text-25: unset; +$text-50: 0.75rem; // 12px +$text-75: unset; +$text-100: 1rem; // 16px +$text-125: 1.25rem; // 20px +$text-150: 2rem; // 32px +$text-175: 2.5rem; // 40px +$text-200: 5rem; // 80px +$text-sizes: ( + "text-50": $text-50, + "text-100": $text-100, + "text-125": $text-125, + "text-150": $text-150, + "text-175": $text-175, + "text-200": $text-200, +); + +$weight-bold: 700; +$weight-semibold: 600; +$weight-medium: 500; +$weight-regular: 400; +$weight-normal: 400; +$weight-light: 300; +$font-weights: ( + "weight-bold": $weight-bold, + "weight-semibold": $weight-semibold, + "weight-medium": $weight-medium, + "weight-regular": $weight-regular, + "weight-normal": $weight-normal, + "weight-light": $weight-light, +); + +// Liste de toutes les familles de titre disponibles +// Documentation: Typography/Titles +$h1-family: unset; +$h2-family: unset; +$h3-family: unset; +$h4-family: unset; +$h5-family: unset; +$h6-family: unset; +$h-families: []; + +// Liste de toutes les transformations de titre disponibles +// Documentation: Typography/Titles +$h1-transform: unset; +$h2-transform: unset; +$h3-transform: unset; +$h4-transform: unset; +$h5-transform: unset; +$h6-transform: unset; +$h-transforms: []; + +// Liste de toutes les tailles de titre disponibles +// Documentation: Typography/Titles +$h1-size: $text-200; +$h2-size: $text-175; +$h3-size: $text-150; +$h4-size: $text-125; +$h5-size: unset; +$h6-size: unset; +$h-sizes: ( + "h1-size": $h1-size, + "h2-size": $h2-size, + "h3-size": $h3-size, + "h4-size": $h4-size, +); + +// Liste de toutes les graisses de titre disponibles +// Documentation: Typography/Titles +$h1-weight: $weight-bold; +$h2-weight: $weight-bold; +$h3-weight: $weight-bold; +$h4-weight: $weight-bold; +$h5-weight: unset; +$h6-weight: unset; +$h-weights: ( + "h1-weight": $h1-weight, + "h2-weight": $h2-weight, + "h3-weight": $h3-weight, + "h4-weight": $h4-weight, +); + +// Liste de toutes les line-heights disponibles +// Documentation: Typography/Titles +$h1-line-height: unset; +$h2-line-height: unset; +$h3-line-height: unset; +$h4-line-height: unset; +$h5-line-height: unset; +$h6-line-height: unset; +$h-line-heights: []; + +// Liste de tous les radius disponibles +// Documentation: Utilities/Borders +$rounded-0: 0; +$rounded-25: 4px; +$rounded-50: 8px; +$rounded-75: unset; +$rounded-100: 16px; +$rounded-125: unset; +$rounded-150: unset; +$rounded-200: 32px; +$rounded-sizes: ( + "rounded-0": $rounded-0, + "rounded-25": $rounded-25, + "rounded-50": $rounded-50, + "rounded-100": $rounded-100, + "rounded-200": $rounded-200, +); + +// Liste de toutes les ombres disponibles +// Documentation: Utilities/Shadows +$dp-0: none; +$dp-25: -1px 2px 0px 0px $grey-100; +$dp-50: unset; +$dp-75: -2px 4px 0px 0px $grey-100; +$dp-100: -4px 8px 0px 0px $grey-100; +$dp-sizes: ( + "dp-0": $dp-0, + "dp-25": $dp-25, + "dp-75": $dp-75, + "dp-100": $dp-100, +); + +// ========================================== + +// liste opacités disponibles +// Documentation: Utilities/Visibility +$opacity-0: 0; +$opacity-25: 0.25; +$opacity-50: 0.5; +$opacity-75: 0.75; +$opacity-100: 1; +$opacity-sizes: ( + "opacity-0": $opacity-0, + "opacity-25": $opacity-25, + "opacity-50": $opacity-50, + "opacity-75": $opacity-75, + "opacity-100": $opacity-100, +); + +// liste espacements disponibles +// Documentation: Spacings/Margin et Spacings/Padding +$spacing-0: 0px; +$spacing-1: 4px; +$spacing-2: 8px; +$spacing-3: 16px; +$spacing-4: 24px; +$spacing-5: 32px; +$spacing-sizes: ( + "spacing-0": $spacing-0, + "spacing-1": $spacing-1, + "spacing-2": $spacing-2, + "spacing-3": $spacing-3, + "spacing-4": $spacing-4, + "spacing-5": $spacing-5, +); + +// Liste de toutes les z-values disponibles +// Documentation: TODO +$z-0: 0; +$z-1: 1; +$z-2: 5; +$z-3: 10; +$z-cheating: 100; +$z-values: ( + "z-0": $z-0, + "z-1": $z-1, + "z-2": $z-2, + "z-3": $z-3, + "z-cheating": $z-cheating, +); + +// Variables secondaires +$border-width: 2px; +$border-color: $grey-100; +$borders: ( + "border-width": $border-width, + "border-color": $border-color, +); + +$variables: map-merge( + $map1: $font-families, + $map2: $text-sizes, +); +$variables: map-merge( + $map1: $variables, + $map2: $h-families, +); +$variables: map-merge( + $map1: $variables, + $map2: $h-transforms, +); +$variables: map-merge( + $map1: $variables, + $map2: $h-sizes, +); +$variables: map-merge( + $map1: $variables, + $map2: $h-weights, +); +$variables: map-merge( + $map1: $variables, + $map2: $h-line-heights, +); +$variables: map-merge( + $map1: $variables, + $map2: $opacity-sizes, +); +$variables: map-merge( + $map1: $variables, + $map2: $spacing-sizes, +); +$variables: map-merge( + $map1: $variables, + $map2: $rounded-sizes, +); +$variables: map-merge( + $map1: $variables, + $map2: $dp-sizes, +); +$variables: map-merge( + $map1: $variables, + $map2: $colors, +); +$variables: map-merge( + $map1: $variables, + $map2: $borders, +); +$variables: map-merge( + $map1: $variables, + $map2: $z-values, +); +$variables: map-merge( + $map1: $variables, + $map2: $font-weights, +); + +// on définit une mixin pour permettre de restaurer les variables à tout niveau +@mixin variables { + @each $variable, $value in $variables { + --#{$variable}: #{$value}; + @if (type-of($value) == color) { + $hue: hue($value); + $saturation: saturation($value); + $lightness: lightness($value); + --#{$variable}-h: #{$hue}; + --#{$variable}-s: #{$saturation}; + --#{$variable}-l: #{$lightness}; + } + } +} diff --git a/canaille/src/scss/google-fonts.scss b/canaille/src/scss/google-fonts.scss new file mode 100644 index 0000000..f90a6f4 --- /dev/null +++ b/canaille/src/scss/google-fonts.scss @@ -0,0 +1,5 @@ +/** + PLEASE DO NOT USE THIS IN PRODUCTION, ONLY THERE FOR DEV PURPOSES +**/ + +@import url("https://fonts.googleapis.com/css2?family=Atkinson+Hyperlegible&display=swap"); diff --git a/canaille/src/scss/index.scss b/canaille/src/scss/index.scss new file mode 100644 index 0000000..df80646 --- /dev/null +++ b/canaille/src/scss/index.scss @@ -0,0 +1,8 @@ +// liste de toutes les variables disponibles +@use "./variables" as vars; + +:root, +.laddle-main { + @include vars.variables(); + font-family: var(--font-family-primary); +} diff --git a/canaille/toolchain/create-story.mjs b/canaille/toolchain/create-story.mjs new file mode 100644 index 0000000..16aa064 --- /dev/null +++ b/canaille/toolchain/create-story.mjs @@ -0,0 +1,89 @@ +/* eslint-disable no-eval */ +import { parse } from "react-docgen"; +import fs from "fs"; +import { exit } from "process"; +import path from "path"; +import { format } from "prettier"; +import FindAllDefinitionsResolver from "react-docgen/dist/resolver/FindAllDefinitionsResolver.js"; + +process.argv.shift(); +process.argv.shift(); + +if (process.argv.length === 0) { + exit(1); +} + +const filePath = process.argv[0]; + +function generateArgs(props) { + return Object.keys(props).reduce((acc, curr) => { + if (!props[curr].defaultValue) return acc; + return { + ...acc, + [curr]: eval(props[curr].defaultValue.value), + }; + }, {}); +} + +function parseArgsType(type) { + switch (type.tsType.name) { + case "union": { + const options = type.tsType.elements + .map((e) => eval(e.value)) + .filter((e) => !!e); + if (options.length === 0) return null; + return { + options, + control: { type: "select" }, + defaultValue: eval(type.defaultValue?.value), + }; + } + default: + return null; + } +} + +function generateArgsType(props) { + return Object.keys(props).reduce((acc, curr) => { + const type = parseArgsType(props[curr]); + if (!type) return acc; + return { + ...acc, + [curr]: parseArgsType(props[curr]), + }; + }, {}); +} + +async function generateStory(name, props) { + const hasChildren = Object.keys(props).indexOf("children") !== -1; + const fileName = filePath.split(path.sep).pop(); + + const output = ` + import type { Story } from "@ladle/react"; + import React from 'react'; + import { ${name} } from './${fileName}'; + + export const ${name}Story: Story = (props) => <${name} {...props}>${ + hasChildren ? "Test" : "" + }; + ${name}Story.storyName = "${name}"; + + ${name}Story.args = ${JSON.stringify(generateArgs(props))}; + ${name}Story.argTypes = ${JSON.stringify(generateArgsType(props))}; + `; + + const out = await format(output, { semi: false, filepath: filePath }); + + fs.writeFileSync( + path.join(path.dirname(filePath), `${name}.stories.tsx`), + out, + ); +} + +const file = fs.readFileSync(filePath); +const docs = parse(file.toString(), { + filename: filePath, + resolver: new FindAllDefinitionsResolver(), +}); + +docs.forEach((doc) => generateStory(doc.displayName, doc.props)); diff --git a/canaille/tsconfig.json b/canaille/tsconfig.json new file mode 100644 index 0000000..c38d353 --- /dev/null +++ b/canaille/tsconfig.json @@ -0,0 +1,16 @@ +{ + "include": ["src/index.ts"], + "compilerOptions": { + "lib": ["ES2019", "dom"], + "emitDeclarationOnly": true, + "declaration": true, + // Ensure that Babel can safely transpile files in the TypeScript project + "isolatedModules": true, + "jsx": "react", + "esModuleInterop": true, + "outDir": "dist/types", + "skipLibCheck": true, + "resolveJsonModule": true, + "strictNullChecks": false + } +} diff --git a/canaille/webpack.common.mjs b/canaille/webpack.common.mjs new file mode 100644 index 0000000..2eab7fb --- /dev/null +++ b/canaille/webpack.common.mjs @@ -0,0 +1,81 @@ +import { exec } from "child_process"; +import path from "path"; + +export const sassRule = { + test: /\.(sa|sc|c)ss$/i, + use: [ + { + loader: "style-loader", + }, + { + loader: "css-loader", + }, + { + loader: "sass-loader", + options: { + sassOptions: { + charset: false, + indentWidth: 4, + includePaths: [path.resolve("node_modules")], + }, + }, + }, + ], +}; + +export default { + entry: { + bundle: { + import: "./src/index.ts", + filename: "index.js", + }, + }, + resolve: { + extensions: [".ts", ".tsx", ".js"], + }, + output: { + clean: true, + library: "Canaille", + libraryTarget: "umd", + globalObject: "this", + publicPath: "auto", + path: path.resolve("dist"), + }, + module: { + rules: [ + { + test: /\.(ts|js)x?$/, + exclude: /node_modules/, + use: { + loader: "babel-loader", + options: { + presets: [ + "@babel/preset-env", + "@babel/preset-react", + "@babel/preset-typescript", + ], + }, + }, + }, + sassRule, + ], + }, + externals: { + react: "react", + "react-dom": "react-dom", + "react-router-dom": "react-router-dom", + }, + plugins: [ + { + apply: (compiler) => { + compiler.hooks.afterEmit.tap(".d.ts Generation", () => { + exec("npx tsc", (err, stdout, stderr) => { + if (stdout) process.stdout.write(`Typescript: ${stdout}`); + + if (stderr) process.stderr.write(`Typescript: ${stderr}`); + }); + }); + }, + }, + ], +}; diff --git a/canaille/webpack.dev.mjs b/canaille/webpack.dev.mjs new file mode 100644 index 0000000..d5eac27 --- /dev/null +++ b/canaille/webpack.dev.mjs @@ -0,0 +1,6 @@ +import common from "./webpack.common.mjs"; + +export default { + ...common, + mode: "development", +}; diff --git a/canaille/webpack.prod.mjs b/canaille/webpack.prod.mjs new file mode 100644 index 0000000..cbcab3e --- /dev/null +++ b/canaille/webpack.prod.mjs @@ -0,0 +1,6 @@ +import common from "./webpack.common.mjs"; + +export default { + ...common, + mode: "production", +}; diff --git a/cms/package.json b/cms/package.json index c8f03b4..0549508 100644 --- a/cms/package.json +++ b/cms/package.json @@ -10,7 +10,7 @@ "preview": "vite preview" }, "dependencies": { - "@bdxtown/canaille": "^1.0.0-rc7", + "@bdxtown/canaille": "^1.0.0", "@floating-ui/react": "^0.26.9", "@mdxeditor/editor": "^2.3.4", "@mdxeditor/gurx": "^1.1.1", diff --git a/cms/src/App.tsx b/cms/src/App.tsx index c2c3acf..92cb36d 100644 --- a/cms/src/App.tsx +++ b/cms/src/App.tsx @@ -3,7 +3,6 @@ import { AppContextProvider, useAppContext } from './data/AppContext'; import { ErrorBoundary } from './data/ErrorBoundary'; import { createBrowserRouter, RouterProvider, Navigate } from "react-router-dom"; import { Base } from './layout/Base'; -import { Write } from './layout/Write'; import { TranslationContext } from '@bdxtown/canaille'; import '@mdxeditor/editor/style.css'; // import '@bdxtown/canaille/src/scss/google-fonts.scss'; @@ -39,19 +38,15 @@ const baseRouter = createBrowserRouter([ const loggedRouter = createBrowserRouter([ { - path: PostLocation.path + "/*", - element: , + path: PostsLocation.path, + element: , children: [ { - path: ":file", - element: , - errorElement: - }, - { + ...PostsLocation, index: true, - element: , + element: , errorElement: - } + }, ] }, { @@ -59,16 +54,21 @@ const loggedRouter = createBrowserRouter([ element: , children: [ { - ...BlogLocation, - element: , + + path: PostLocation.path + "/:file", + element: , errorElement: }, { - ...PostsLocation, - element: , + ...PostLocation, + element: , + errorElement: + }, + { + ...BlogLocation, + element: , errorElement: }, - { ...MediaLocation, element: , diff --git a/cms/src/bits/HTMLAdd.fr-FR.i18n.json b/cms/src/bits/HTMLAdd.fr-FR.i18n.json new file mode 100644 index 0000000..204ba12 --- /dev/null +++ b/cms/src/bits/HTMLAdd.fr-FR.i18n.json @@ -0,0 +1,8 @@ +{ + "HTMLAdd": { + "title": "Ajouter du code HTML", + "description": "Saissez ci-dessous le code HTML que vous souhaitez ajouter à la page. Seules des balises et attributs sans-risque seront présentées aux lecteurs.", + "insert": "Insérer", + "cancel": "Annuler" + } +} \ No newline at end of file diff --git a/cms/src/bits/HTMLAdd.tsx b/cms/src/bits/HTMLAdd.tsx new file mode 100644 index 0000000..115ae3b --- /dev/null +++ b/cms/src/bits/HTMLAdd.tsx @@ -0,0 +1,67 @@ +import { FormEventHandler, useCallback, useState } from 'react'; +import { rootEditor$, $createGenericHTMLNode } from '@mdxeditor/editor'; +import { useCellValue } from '@mdxeditor/gurx'; +import { $insertNodes } from 'lexical'; + + +import { IconCode } from '@tabler/icons-react'; +import { Modal } from './Modal'; +import { Button, useTranslations } from '@bdxtown/canaille'; + +import fr from './HTMLAdd.fr-FR.i18n.json'; + +const tr = { + "fr-FR": fr, +} + + +export const HTMLAdd = () => { + const { T } = useTranslations("HTMLAdd", tr); + // access the viewMode node value + const editor = useCellValue(rootEditor$); + const [showModal, setShowModal] = useState(false); + + const onSubmit: FormEventHandler = useCallback((e) => { + e.preventDefault(); + e.stopPropagation(); + const data = new FormData(e.currentTarget); + const code = data.get("code") as string; + + const parent = document.createElement("div"); + parent.innerHTML = code; + const node = parent.firstElementChild as Element; + if(!node) return; + + editor?.update(() => { + const vnode = $createGenericHTMLNode(node.tagName.toLowerCase() as never, "mdxJsxFlowElement", Array.from(node.attributes).map((a) => ({ name: a.name, value: a.value, type: "mdxJsxAttribute" }))); + $insertNodes([vnode]); + }) + setShowModal(false); + }, [editor]); + + const onClick = useCallback(() => { + setShowModal(true); + }, []) + + return ( + <> + + { + showModal && ( + setShowModal(false)}> +
+
title
+
description
+