Răsfoiți Sursa

feat: implement custom pin-input component (#2)

* feat: add breadcrumb component

* feat: add pin-input component

* feat: add controlled and uncontrolled props

Changes made:
- add value, onChange props for controlled PinInput
- add defaultValue for uncontrolled PinInput
- add onComplete for completed action
- add disabled and readOnly props

* feat: add autoFocus option

* feat: add ariaLabel for a11y

* feat: add validation in handlePaste

* fix: update focus logic when paste

Update focus logic to the following
- if pasted value is less than input length, focus on the next input
- if not, focus on the last input

* refactor: add comments for props and remove unused codes

* feat: add className prop for input container

* refactor: update length type to be 1 ~ 12 instead of number

* refactor: extract pin-input logics into a custom hook

* fix: add id in pin-input for label

* feat: add modular pin-input component

* tempo: add custom component page

* feat: add onIncomplete function

* feat: update component props for type inference

* refactor: rename/replace pin-input with modular new pin-input

* feat: add extra components page
Sat Naing 2 ani în urmă
părinte
comite
fb63c4ac73

+ 2 - 0
package.json

@@ -41,6 +41,7 @@
     "react-dom": "^18.2.0",
     "react-hook-form": "^7.49.3",
     "react-router-dom": "^6.21.3",
+    "react-syntax-highlighter": "^15.5.0",
     "recharts": "^2.11.0",
     "tailwind-merge": "^2.2.1",
     "tailwindcss-animate": "^1.0.7",
@@ -50,6 +51,7 @@
     "@types/node": "^20.11.6",
     "@types/react": "^18.2.43",
     "@types/react-dom": "^18.2.17",
+    "@types/react-syntax-highlighter": "^15.5.11",
     "@typescript-eslint/eslint-plugin": "^6.14.0",
     "@typescript-eslint/parser": "^6.14.0",
     "@vitejs/plugin-react-swc": "^3.5.0",

+ 150 - 0
pnpm-lock.yaml

@@ -92,6 +92,9 @@ dependencies:
   react-router-dom:
     specifier: ^6.21.3
     version: 6.21.3(react-dom@18.2.0)(react@18.2.0)
+  react-syntax-highlighter:
+    specifier: ^15.5.0
+    version: 15.5.0(react@18.2.0)
   recharts:
     specifier: ^2.11.0
     version: 2.11.0(prop-types@15.8.1)(react-dom@18.2.0)(react@18.2.0)
@@ -115,6 +118,9 @@ devDependencies:
   '@types/react-dom':
     specifier: ^18.2.17
     version: 18.2.18
+  '@types/react-syntax-highlighter':
+    specifier: ^15.5.11
+    version: 15.5.11
   '@typescript-eslint/eslint-plugin':
     specifier: ^6.14.0
     version: 6.19.1(@typescript-eslint/parser@6.19.1)(eslint@8.56.0)(typescript@5.3.3)
@@ -1924,6 +1930,12 @@ packages:
     resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==}
     dev: true
 
+  /@types/hast@2.3.10:
+    resolution: {integrity: sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw==}
+    dependencies:
+      '@types/unist': 2.0.10
+    dev: false
+
   /@types/json-schema@7.0.15:
     resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
     dev: true
@@ -1942,6 +1954,12 @@ packages:
     dependencies:
       '@types/react': 18.2.48
 
+  /@types/react-syntax-highlighter@15.5.11:
+    resolution: {integrity: sha512-ZqIJl+Pg8kD+47kxUjvrlElrraSUrYa4h0dauY/U/FTUuprSCqvUj+9PNQNQzVc6AJgIWUUxn87/gqsMHNbRjw==}
+    dependencies:
+      '@types/react': 18.2.48
+    dev: true
+
   /@types/react@18.2.48:
     resolution: {integrity: sha512-qboRCl6Ie70DQQG9hhNREz81jqC1cs9EVNcjQ1AU+jH6NFfSAhVVbrrY/+nSF+Bsk4AOwm9Qa61InvMCyV+H3w==}
     dependencies:
@@ -1956,6 +1974,10 @@ packages:
     resolution: {integrity: sha512-dn1l8LaMea/IjDoHNd9J52uBbInB796CDffS6VdIxvqYCPSG0V0DzHp76GpaWnlhg88uYyPbXCDIowa86ybd5A==}
     dev: true
 
+  /@types/unist@2.0.10:
+    resolution: {integrity: sha512-IfYcSBWE3hLpBg8+X2SEa8LVkJdJEkT2Ese2aaLs3ptGdVtABxndrMaxuFlQ1qdFf9Q5rDvDpxI3WwgvKFAsQA==}
+    dev: false
+
   /@typescript-eslint/eslint-plugin@6.19.1(@typescript-eslint/parser@6.19.1)(eslint@8.56.0)(typescript@5.3.3):
     resolution: {integrity: sha512-roQScUGFruWod9CEyoV5KlCYrubC/fvG8/1zXuT0WTcxX87GnMMmnksMwSg99lo1xiKrBzw2icsJPMAw1OtKxg==}
     engines: {node: ^16.0.0 || >=18.0.0}
@@ -2246,6 +2268,18 @@ packages:
       supports-color: 7.2.0
     dev: true
 
+  /character-entities-legacy@1.1.4:
+    resolution: {integrity: sha512-3Xnr+7ZFS1uxeiUDvV02wQ+QDbc55o97tIV5zHScSPJpcLm/r0DFPcoY3tYRp+VZukxuMeKgXYmsXQHO05zQeA==}
+    dev: false
+
+  /character-entities@1.2.4:
+    resolution: {integrity: sha512-iBMyeEHxfVnIakwOuDXpVkc54HijNgCyQB2w0VfGQThle6NXn50zU6V/u+LDhxHcDUPojn6Kpga3PTAD8W1bQw==}
+    dev: false
+
+  /character-reference-invalid@1.1.4:
+    resolution: {integrity: sha512-mKKUkUbhPpQlCOfIuZkvSEgktjPFIsZKRRbC6KWVEMvlzblj3i3asQv5ODsrwt0N3pHAEvjP8KTQPHkp0+6jOg==}
+    dev: false
+
   /chokidar@3.5.3:
     resolution: {integrity: sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==}
     engines: {node: '>= 8.10.0'}
@@ -2298,6 +2332,10 @@ packages:
   /color-name@1.1.4:
     resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
 
+  /comma-separated-tokens@1.0.8:
+    resolution: {integrity: sha512-GHuDRO12Sypu2cV70d1dkA2EUmXHgntrzbpvOB+Qy+49ypNfGgFQIC2fhhXbnyrJRynDCAARsT7Ou0M6hirpfw==}
+    dev: false
+
   /commander@4.1.1:
     resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==}
     engines: {node: '>= 6'}
@@ -2651,6 +2689,12 @@ packages:
     dependencies:
       reusify: 1.0.4
 
+  /fault@1.0.4:
+    resolution: {integrity: sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA==}
+    dependencies:
+      format: 0.2.2
+    dev: false
+
   /file-entry-cache@6.0.1:
     resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==}
     engines: {node: ^10.12.0 || >=12.0.0}
@@ -2692,6 +2736,11 @@ packages:
       cross-spawn: 7.0.3
       signal-exit: 4.1.0
 
+  /format@0.2.2:
+    resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==}
+    engines: {node: '>=0.4.x'}
+    dev: false
+
   /fraction.js@4.3.7:
     resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==}
     dev: true
@@ -2783,6 +2832,24 @@ packages:
     dependencies:
       function-bind: 1.1.2
 
+  /hast-util-parse-selector@2.2.5:
+    resolution: {integrity: sha512-7j6mrk/qqkSehsM92wQjdIgWM2/BW61u/53G6xmC8i1OmEdKLHbk419QKQUjz6LglWsfqoiHmyMRkP1BGjecNQ==}
+    dev: false
+
+  /hastscript@6.0.0:
+    resolution: {integrity: sha512-nDM6bvd7lIqDUiYEiu5Sl/+6ReP0BMk/2f4U/Rooccxkj0P5nm+acM5PrGJ/t5I8qPGiqZSE6hVAwZEdZIvP4w==}
+    dependencies:
+      '@types/hast': 2.3.10
+      comma-separated-tokens: 1.0.8
+      hast-util-parse-selector: 2.2.5
+      property-information: 5.6.0
+      space-separated-tokens: 1.1.5
+    dev: false
+
+  /highlight.js@10.7.3:
+    resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==}
+    dev: false
+
   /ignore@5.3.0:
     resolution: {integrity: sha512-g7dmpshy+gD7mh88OC9NwSGTKoc3kyLAZQRU1mt53Aw/vnvfXnbC+F/7F7QoYVKbV+KNvJx8wArewKy1vXMtlg==}
     engines: {node: '>= 4'}
@@ -2823,6 +2890,17 @@ packages:
       loose-envify: 1.4.0
     dev: false
 
+  /is-alphabetical@1.0.4:
+    resolution: {integrity: sha512-DwzsA04LQ10FHTZuL0/grVDk4rFoVH1pjAToYwBrHSxcrBIGQuXrQMtD5U1b0U2XVgKZCTLLP8u2Qxqhy3l2Vg==}
+    dev: false
+
+  /is-alphanumerical@1.0.4:
+    resolution: {integrity: sha512-UzoZUr+XfVz3t3v4KyGEniVL9BDRoQtY7tOyrRybkVNjDFWyo1yhXNGrrBTQxp3ib9BLAWs7k2YKBQsFRkZG9A==}
+    dependencies:
+      is-alphabetical: 1.0.4
+      is-decimal: 1.0.4
+    dev: false
+
   /is-binary-path@2.1.0:
     resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
     engines: {node: '>=8'}
@@ -2834,6 +2912,10 @@ packages:
     dependencies:
       hasown: 2.0.0
 
+  /is-decimal@1.0.4:
+    resolution: {integrity: sha512-RGdriMmQQvZ2aqaQq3awNA6dCGtKpiDFcOzrTWrDAT2MiWrKQVPmxLGHl7Y2nNu6led0kEyoX0enY0qXYsv9zw==}
+    dev: false
+
   /is-extglob@2.1.1:
     resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
     engines: {node: '>=0.10.0'}
@@ -2848,6 +2930,10 @@ packages:
     dependencies:
       is-extglob: 2.1.1
 
+  /is-hexadecimal@1.0.4:
+    resolution: {integrity: sha512-gyPJuv83bHMpocVYoqof5VDiZveEoGoFL8m3BXNb2VW8Xs+rz9kqO8LOQ5DH6EsuvilT1ApazU0pyl+ytbPtlw==}
+    dev: false
+
   /is-number@7.0.0:
     resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
     engines: {node: '>=0.12.0'}
@@ -2942,6 +3028,13 @@ packages:
       js-tokens: 4.0.0
     dev: false
 
+  /lowlight@1.20.0:
+    resolution: {integrity: sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw==}
+    dependencies:
+      fault: 1.0.4
+      highlight.js: 10.7.3
+    dev: false
+
   /lru-cache@10.2.0:
     resolution: {integrity: sha512-2bIM8x+VAf6JT4bKAljS1qUWgMsqZRPGJS6FSahIMPVvctcNhyVp7AJu7quxOW9jwkryBReKZY5tY5JYv2n/7Q==}
     engines: {node: 14 || >=16.14}
@@ -3060,6 +3153,17 @@ packages:
       callsites: 3.1.0
     dev: true
 
+  /parse-entities@2.0.0:
+    resolution: {integrity: sha512-kkywGpCcRYhqQIchaWqZ875wzpS/bMKhz5HnN3p7wveJTkTtyAB/AlnS0f8DFSqYW1T82t6yEAkEcB+A1I3MbQ==}
+    dependencies:
+      character-entities: 1.2.4
+      character-entities-legacy: 1.1.4
+      character-reference-invalid: 1.1.4
+      is-alphanumerical: 1.0.4
+      is-decimal: 1.0.4
+      is-hexadecimal: 1.0.4
+    dev: false
+
   /path-exists@4.0.0:
     resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
     engines: {node: '>=8'}
@@ -3230,6 +3334,16 @@ packages:
     hasBin: true
     dev: true
 
+  /prismjs@1.27.0:
+    resolution: {integrity: sha512-t13BGPUlFDR7wRB5kQDG4jjl7XeuH6jbJGt11JHPL96qwsEHNX2+68tFXqc1/k+/jALsbSWJKUOT/hcYAZ5LkA==}
+    engines: {node: '>=6'}
+    dev: false
+
+  /prismjs@1.29.0:
+    resolution: {integrity: sha512-Kx/1w86q/epKcmte75LNrEoT+lX8pBpavuAbvJWRXar7Hz8jrtF+e3vY751p0R8H9HdArwaCTNDDzHg/ScJK1Q==}
+    engines: {node: '>=6'}
+    dev: false
+
   /prop-types@15.8.1:
     resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
     dependencies:
@@ -3238,6 +3352,12 @@ packages:
       react-is: 16.13.1
     dev: false
 
+  /property-information@5.6.0:
+    resolution: {integrity: sha512-YUHSPk+A30YPv+0Qf8i9Mbfe/C0hdPXk1s1jPVToV8pk8BQtpw10ct89Eo7OWkutrwqvT0eicAxlOg3dOAu8JA==}
+    dependencies:
+      xtend: 4.0.2
+    dev: false
+
   /punycode@2.3.1:
     resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
     engines: {node: '>=6'}
@@ -3391,6 +3511,19 @@ packages:
       tslib: 2.6.2
     dev: false
 
+  /react-syntax-highlighter@15.5.0(react@18.2.0):
+    resolution: {integrity: sha512-+zq2myprEnQmH5yw6Gqc8lD55QHnpKaU8TOcFeC/Lg/MQSs8UknEA0JC4nTZGFAXC2J2Hyj/ijJ7NlabyPi2gg==}
+    peerDependencies:
+      react: '>= 0.14.0'
+    dependencies:
+      '@babel/runtime': 7.23.9
+      highlight.js: 10.7.3
+      lowlight: 1.20.0
+      prismjs: 1.29.0
+      react: 18.2.0
+      refractor: 3.6.0
+    dev: false
+
   /react-transition-group@2.9.0(react-dom@18.2.0)(react@18.2.0):
     resolution: {integrity: sha512-+HzNTCHpeQyl4MJ/bdE0u6XRMe9+XG/+aL4mCxVN4DnPBQ0/5bfHWPDuOZUzYdMj94daZaZdCCc1Dzt9R/xSSg==}
     peerDependencies:
@@ -3450,6 +3583,14 @@ packages:
       victory-vendor: 36.8.2
     dev: false
 
+  /refractor@3.6.0:
+    resolution: {integrity: sha512-MY9W41IOWxxk31o+YvFCNyNzdkc9M20NoZK5vq6jkv4I/uh2zkWcfudj0Q1fovjUQJrNewS9NMzeTtqPf+n5EA==}
+    dependencies:
+      hastscript: 6.0.0
+      parse-entities: 2.0.0
+      prismjs: 1.27.0
+    dev: false
+
   /regenerator-runtime@0.14.1:
     resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==}
     dev: false
@@ -3543,6 +3684,10 @@ packages:
     resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==}
     engines: {node: '>=0.10.0'}
 
+  /space-separated-tokens@1.1.5:
+    resolution: {integrity: sha512-q/JSVd1Lptzhf5bkYm4ob4iWPjx0KiRe3sRFBNrVqbJkFaBm5vbbowy1mymoPNLRa52+oadOhJ+K49wsSeSjTA==}
+    dev: false
+
   /string-width@4.2.3:
     resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
     engines: {node: '>=8'}
@@ -3840,6 +3985,11 @@ packages:
     resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
     dev: true
 
+  /xtend@4.0.2:
+    resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
+    engines: {node: '>=0.4'}
+    dev: false
+
   /yallist@4.0.0:
     resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==}
     dev: true

+ 85 - 0
src/components/custom/breadcrumb.tsx

@@ -0,0 +1,85 @@
+import * as React from 'react'
+import { cn } from '@/lib/utils'
+
+interface BreadcrumbProps extends React.ComponentPropsWithoutRef<'nav'> {
+  children:
+    | React.ReactElement<typeof BreadcrumbItem>
+    | React.ReactElement<typeof BreadcrumbItem>[]
+  separator?: React.ReactNode
+}
+
+const BreadcrumbContext = React.createContext<boolean>(false)
+
+const Breadcrumb = React.forwardRef<HTMLElement, BreadcrumbProps>(
+  ({ className, children, separator, ...props }, ref) => {
+    const validChildren = getValidChildren(children)
+
+    const count = validChildren.length
+
+    const clones = validChildren.map((child, index) =>
+      React.cloneElement(child, {
+        separator,
+        isLastChild: count === index + 1,
+      })
+    )
+
+    return (
+      <BreadcrumbContext.Provider value={true}>
+        <nav ref={ref} aria-label='breadcrumb' className={className} {...props}>
+          <ol className={cn(`flex`)}>{clones}</ol>
+        </nav>
+      </BreadcrumbContext.Provider>
+    )
+  }
+)
+Breadcrumb.displayName = 'Breadcrumb'
+
+interface InternalBreadcrumbItemProps {
+  separator?: React.ReactNode
+  isLastChild: boolean
+}
+
+interface BreadcrumbItemProps
+  extends Omit<
+    React.ComponentPropsWithoutRef<'li'>,
+    keyof InternalBreadcrumbItemProps
+  > {}
+
+const BreadcrumbItem = React.forwardRef<HTMLLIElement, BreadcrumbItemProps>(
+  ({ className, children, ...props }, ref) => {
+    const { separator, isLastChild, ...rest } =
+      props as InternalBreadcrumbItemProps
+
+    // Check if BreadcrumbItem is used within Breadcrumb
+    const isInsideBreadcrumb = React.useContext(BreadcrumbContext)
+    if (!isInsideBreadcrumb) {
+      throw new Error(
+        `${BreadcrumbItem.displayName} must be used within ${Breadcrumb.displayName}.`
+      )
+    }
+
+    return (
+      <li ref={ref} className={cn(`group`, className)} {...rest}>
+        {children}
+        {!isLastChild && (
+          <span className='mx-2 *:!inline-block'>{separator ?? '/'}</span>
+        )}
+      </li>
+    )
+  }
+)
+BreadcrumbItem.displayName = 'BreadcrumbItem'
+
+/* ========== Util Func ========== */
+
+const getValidChildren = (children: React.ReactNode) =>
+  React.Children.toArray(children).filter((child) => {
+    if (React.isValidElement(child) && child.type === BreadcrumbItem) {
+      return React.isValidElement(child)
+    }
+    throw new Error(
+      `${Breadcrumb.displayName} can only have ${BreadcrumbItem.displayName} as children.`
+    )
+  }) as React.ReactElement[]
+
+export { Breadcrumb, BreadcrumbItem }

+ 373 - 0
src/components/custom/pin-input-original.tsx

@@ -0,0 +1,373 @@
+import * as React from 'react'
+import { Input } from '@/components/ui/input'
+import { cn } from '@/lib/utils'
+
+interface PinInputOgProps {
+  /**
+   * id for input fields.
+   */
+  id?: string
+  /**
+   * Class name for input fields.
+   */
+  className?: string
+  /**
+   * Class name for input fields container, `flex gap-2` by default
+   */
+  containerClassName?: string
+  /**
+   * Uncontrolled pin input default value.
+   */
+  defaultValue?: string
+  /**
+   * Controlled pin input value.
+   */
+  value?: string
+  /**
+   * Called when value changes
+   */
+  onChange?: (value: string) => void
+  /**
+   * Called when all input have valid value
+   */
+  onComplete?: (value: string) => void
+  /**
+   * The type of value pin input should allow, `alphanumeric` by default
+   */
+  type?: 'numeric' | 'alphanumeric'
+  /**
+   * Placeholder for input fields, `○` by default
+   */
+  placeholder?: string
+  /**
+   * Number of pin input fields, `4` by default
+   */
+  length?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 // 4 by default
+  /**
+   * `name` attribute for hidden input
+   */
+  name?: string
+  /**
+   * `form` attribute for hidden input
+   */
+  form?: string
+  /**
+   * If set, the input's value will be masked just like password input. This field is `false` by default
+   */
+  mask?: boolean
+  /**
+   * If set, the pin input component signals to its fields that they should
+   * use `autocomplete="one-time-code"`. This field is `false` by default
+   */
+  otp?: boolean
+  /**
+   * If set, the input fields are disabled, `false` by default
+   */
+  disabled?: boolean
+  /**
+   * If set, the user cannot set the value, `false` by default
+   */
+  readOnly?: boolean
+  /**
+   * If set, the pin input receives focus on mount, `false` by default
+   */
+  autoFocus?: boolean
+  /**
+   * `aria-label` for the input fields
+   */
+  ariaLabel?: string
+}
+
+const PinInputOg = ({
+  containerClassName,
+  id,
+  className,
+  type = 'alphanumeric',
+  placeholder = '○',
+  length = 4,
+  name,
+  form,
+  defaultValue,
+  value,
+  onChange,
+  onComplete,
+  mask = false,
+  otp = false,
+  disabled = false,
+  readOnly = false,
+  autoFocus = false,
+  ariaLabel,
+}: PinInputOgProps) => {
+  if (length < 1 || length > 12) {
+    throw new Error('input length cannot be more than 12 or less than 1')
+  }
+
+  if ((value !== undefined && !onChange) || (value === undefined && onChange)) {
+    throw new Error(
+      'if one of value or onChange is specified, both props must be set.'
+    )
+  }
+
+  const { pins, pinValue, refMap, ...handlers } = usePinInput({
+    value,
+    defaultValue,
+    placeholder,
+    type,
+    length,
+    readOnly,
+  })
+
+  /* call onChange func if pinValue changes */
+  React.useEffect(() => {
+    onChange && onChange(pinValue)
+  }, [onChange, pinValue])
+
+  /* call onComplete func if pinValue is valid and completed */
+  React.useEffect(() => {
+    if (onComplete && pinValue.length === length) {
+      onComplete(pinValue)
+    }
+  }, [length, onComplete, pinValue])
+
+  /* focus on first input field if autoFocus is set */
+  React.useEffect(() => {
+    if (!autoFocus) return
+    const node = refMap?.get(0)
+    if (node) {
+      node.focus()
+    }
+  }, [autoFocus, refMap])
+
+  return (
+    <div className={cn('flex gap-2', containerClassName)}>
+      {pins.map((pin, i) => (
+        <PinInputField
+          key={i}
+          id={i === 0 ? id : undefined}
+          defaultValue={pin}
+          onChange={(e) => handlers.handleChange(e, i)}
+          onFocus={(e) => handlers.handleFocus(e, i)}
+          onBlur={() => handlers.handleBlur(i)}
+          onKeyDown={(e) => handlers.handleKeyDown(e, i)}
+          onPaste={handlers.handlePaste}
+          placeholder={placeholder}
+          className={className}
+          type={type}
+          mask={mask}
+          autoComplete={otp ? 'one-time-code' : 'off'}
+          disabled={disabled}
+          readOnly={readOnly}
+          aria-label={ariaLabel}
+          ref={(node) => {
+            if (node) {
+              refMap?.set(i, node)
+            } else {
+              refMap?.delete(i)
+            }
+          }}
+        />
+      ))}
+      <input type='hidden' name={name} form={form} value={pinValue} />
+    </div>
+  )
+}
+PinInputOg.displayName = 'PinInputOg'
+
+interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
+  mask?: boolean
+}
+
+const PinInputField = React.forwardRef<HTMLInputElement, InputProps>(
+  ({ className, type, mask, ...props }, ref) => {
+    const inputType = mask ? 'password' : type === 'numeric' ? 'tel' : 'text'
+    return (
+      <Input
+        ref={ref}
+        type={inputType}
+        inputMode={type === 'numeric' ? 'numeric' : 'text'}
+        className={cn('size-10 text-center', className)}
+        {...props}
+      />
+    )
+  }
+)
+
+/* ========== usePinInput custom hook ========== */
+
+interface UsePinInputProps {
+  value: string | undefined
+  defaultValue: string | undefined
+  placeholder: string
+  type: 'numeric' | 'alphanumeric'
+  length: number
+  readOnly: boolean
+}
+
+const usePinInput = ({
+  value,
+  defaultValue,
+  placeholder,
+  type,
+  length,
+  readOnly,
+}: UsePinInputProps) => {
+  const pinInputs = Array.from({ length }, (_, index) =>
+    defaultValue ? defaultValue.charAt(index) : value ? value.charAt(index) : ''
+  )
+  const [pins, setPins] = React.useState<(string | number)[]>(pinInputs)
+  const pinValue = pins.join('').trim()
+
+  const itemsRef = React.useRef<Map<number, HTMLInputElement> | null>(null)
+
+  function getMap() {
+    if (!itemsRef.current) {
+      // Initialize the Map on first usage.
+      itemsRef.current = new Map()
+    }
+    return itemsRef.current
+  }
+
+  function getNode(index: number) {
+    const map = getMap()
+    const node = map?.get(index)
+    return node
+  }
+
+  function focusInput(itemId: number) {
+    const node = getNode(itemId)
+    if (node) {
+      node.focus()
+      node.placeholder = ''
+    }
+  }
+
+  function handleFocus(
+    event: React.FocusEvent<HTMLInputElement>,
+    index: number
+  ) {
+    event.target.select()
+    focusInput(index)
+  }
+
+  function handleBlur(index: number) {
+    const node = getNode(index)
+    if (node) {
+      node.placeholder = placeholder
+    }
+  }
+
+  function updateInputField(val: string, index: number) {
+    const node = getNode(index)
+
+    if (node) {
+      node.value = val
+    }
+
+    setPins((prev) =>
+      prev.map((p, i) => {
+        if (i === index) {
+          return val
+        } else {
+          return p
+        }
+      })
+    )
+  }
+
+  function validate(value: string) {
+    const NUMERIC_REGEX = /^[0-9]+$/
+    const ALPHA_NUMERIC_REGEX = /^[a-zA-Z0-9]+$/i
+    const regex = type === 'alphanumeric' ? ALPHA_NUMERIC_REGEX : NUMERIC_REGEX
+    return regex.test(value)
+  }
+
+  const pastedVal = React.useRef<null | string>(null)
+  function handleChange(e: React.ChangeEvent<HTMLInputElement>, index: number) {
+    const inputValue = e.target.value
+    const pastedValue = pastedVal.current
+    const inputChar = pastedValue
+      ? pastedValue.charAt(length - 1)
+      : inputValue.slice(-1)
+
+    if (validate(inputChar)) {
+      updateInputField(inputChar, index)
+      pastedVal.current = null
+      if (inputValue.length > 0) {
+        focusInput(index + 1)
+      }
+    }
+  }
+
+  function handlePaste(event: React.ClipboardEvent<HTMLInputElement>) {
+    event.preventDefault()
+    const copyValue = event.clipboardData
+      .getData('text/plain')
+      .replace(/[\n\r\s]+/g, '')
+    const copyArr = copyValue.split('').slice(0, length)
+
+    const isValid = copyArr.every((c) => validate(c))
+
+    if (!isValid) return
+
+    for (let i = 0; i < length; i++) {
+      if (i < copyArr.length) {
+        updateInputField(copyArr[i], i)
+      }
+    }
+
+    pastedVal.current = copyValue
+    focusInput(copyArr.length < length ? copyArr.length : length - 1)
+  }
+
+  function handleKeyDown(
+    event: React.KeyboardEvent<HTMLInputElement>,
+    index: number
+  ) {
+    const { ctrlKey, key, shiftKey, metaKey } = event
+
+    if (type === 'numeric') {
+      const canTypeSign =
+        key === 'Backspace' ||
+        key === 'Tab' ||
+        key === 'Control' ||
+        key === 'Delete' ||
+        (ctrlKey && key === 'v') ||
+        (metaKey && key === 'v')
+          ? true
+          : !Number.isNaN(Number(key))
+
+      if (!canTypeSign || readOnly) {
+        event.preventDefault()
+      }
+    }
+
+    if (key === 'ArrowLeft' || (shiftKey && key === 'Tab')) {
+      event.preventDefault()
+      focusInput(index - 1)
+    } else if (key === 'ArrowRight' || key === 'Tab' || key === ' ') {
+      event.preventDefault()
+      focusInput(index + 1)
+    } else if (key === 'Delete') {
+      event.preventDefault()
+    } else if (key === 'Backspace') {
+      event.preventDefault()
+      updateInputField('', index)
+      if ((event.target as HTMLInputElement).value === '') {
+        focusInput(index - 1)
+      }
+    }
+  }
+
+  return {
+    pins,
+    pinValue,
+    refMap: getMap(),
+    handleFocus,
+    handleBlur,
+    handleChange,
+    handlePaste,
+    handleKeyDown,
+  }
+}
+
+export { PinInputOg }

+ 445 - 0
src/components/custom/pin-input.tsx

@@ -0,0 +1,445 @@
+import * as React from 'react'
+import { cn } from '@/lib/utils'
+
+interface PinInputProps {
+  children:
+    | React.ReactElement<typeof PinInputField>
+    | React.ReactElement<typeof PinInputField>[]
+  /**
+   * className for the input container
+   */
+  className?: string
+  /**
+   * `aria-label` for the input fields
+   */
+  ariaLabel?: string
+  /**
+   * If set, the pin input receives focus on mount, `false` by default
+   */
+  autoFocus?: boolean
+  /**
+   * Called when value changes
+   */
+  onChange?: (value: string) => void
+  /**
+   * Called when all inputs have valid value
+   */
+  onComplete?: (value: string) => void
+  /**
+   * Called when any input doesn't have value
+   */
+  onIncomplete?: (value: string) => void
+  /**
+   * `name` attribute for input fields
+   */
+  name?: string
+  /**
+   * `form` attribute for hidden input
+   */
+  form?: string
+  /**
+   * If set, the input's value will be masked just like password input. This field is `false` by default
+   */
+  mask?: boolean
+  /**
+   * If set, the pin input component signals to its fields that they should
+   * use `autocomplete="one-time-code"`. This field is `false` by default
+   */
+  otp?: boolean
+  /**
+   * Uncontrolled pin input default value.
+   */
+  defaultValue?: string
+  /**
+   * Controlled pin input value.
+   */
+  value?: string
+  /**
+   * The type of value pin input should allow, `alphanumeric` by default
+   */
+  type?: 'numeric' | 'alphanumeric'
+  /**
+   * Placeholder for input fields, `○` by default
+   */
+  placeholder?: string
+  /**
+   * If set, the user cannot set the value, `false` by default
+   */
+  readOnly?: boolean
+  /**
+   * If set, the input fields are disabled, `false` by default
+   */
+  disabled?: boolean
+}
+
+const PinInputContext = React.createContext<boolean>(false)
+
+const PinInput = React.forwardRef<HTMLDivElement, PinInputProps>(
+  ({ className, children, ...props }, ref) => {
+    const {
+      defaultValue,
+      value,
+      onChange,
+      onComplete,
+      onIncomplete,
+      placeholder = '○',
+      type = 'alphanumeric',
+      name,
+      form,
+      otp = false,
+      mask = false,
+      disabled = false,
+      readOnly = false,
+      autoFocus = false,
+      ariaLabel = '',
+      ...rest
+    } = props
+
+    const validChildren = getValidChildren(children)
+
+    const length = getInputFieldCount(children)
+
+    // pins, pinValue, refMap, ...handlers
+    const { pins, pinValue, refMap, ...handlers } = usePinInput({
+      value,
+      defaultValue,
+      placeholder,
+      type,
+      length,
+      readOnly,
+    })
+
+    /* call onChange func if pinValue changes */
+    React.useEffect(() => {
+      onChange && onChange(pinValue)
+    }, [onChange, pinValue])
+
+    /* call onComplete func if pinValue is valid and completed */
+    React.useEffect(() => {
+      if (onComplete && pinValue.length === length) {
+        onComplete(pinValue)
+      }
+      if (onIncomplete && pinValue.length !== length) {
+        onIncomplete(pinValue)
+      }
+    }, [length, onComplete, onIncomplete, pinValue])
+
+    /* focus on first input field if autoFocus is set */
+    React.useEffect(() => {
+      if (!autoFocus) return
+      const node = refMap?.get(0)
+      if (node) {
+        node.focus()
+      }
+    }, [autoFocus, refMap])
+
+    const skipRef = React.useRef(0)
+    let counter = 0
+    const clones = validChildren.map((child) => {
+      if (child.type === PinInputField) {
+        const pinIndex = counter
+        counter = counter + 1
+        return React.cloneElement(child, {
+          name,
+          inputKey: `input-${pinIndex}`,
+          defaultValue: length > pinIndex ? pins[pinIndex] : '',
+          onChange: (e: React.ChangeEvent<HTMLInputElement>) =>
+            handlers.handleChange(e, pinIndex),
+          onFocus: (e: React.FocusEvent<HTMLInputElement>) =>
+            handlers.handleFocus(e, pinIndex),
+          onBlur: () => handlers.handleBlur(pinIndex),
+          onKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) =>
+            handlers.handleKeyDown(e, pinIndex),
+          onPaste: (e: React.ClipboardEvent<HTMLInputElement>) =>
+            handlers.handlePaste(e),
+          placeholder: placeholder,
+          type: type,
+          mask: mask,
+          autoComplete: otp ? 'one-time-code' : 'off',
+          disabled: disabled,
+          readOnly: readOnly,
+          'aria-label': ariaLabel,
+          ref: (node: HTMLInputElement | null) => {
+            if (node) {
+              refMap?.set(pinIndex, node)
+            } else {
+              refMap?.delete(pinIndex)
+            }
+          },
+        })
+      }
+      skipRef.current = skipRef.current + 1
+      return child
+    })
+
+    return (
+      <PinInputContext.Provider value={true}>
+        <div ref={ref} aria-label='Pin Input' className={className} {...rest}>
+          {clones}
+          <input type='hidden' name={name} form={form} value={pinValue} />
+        </div>
+      </PinInputContext.Provider>
+    )
+  }
+)
+PinInput.displayName = 'PinInput'
+
+/* ========== PinInputField ========== */
+
+interface _PinInputFieldProps {
+  mask: boolean
+  inputKey: string
+  type: 'numeric' | 'alphanumeric'
+}
+
+interface PinInputFieldProps<T>
+  extends Omit<
+    React.ComponentPropsWithoutRef<'input'>,
+    keyof _PinInputFieldProps
+  > {
+  component?: T
+}
+
+const PinInputFieldNoRef = <T extends React.ElementType = 'input'>(
+  {
+    className,
+    component,
+    ...props
+  }: PinInputFieldProps<T> &
+    (React.ComponentType<T> extends undefined
+      ? never
+      : React.ComponentProps<T>),
+  ref: React.ForwardedRef<HTMLInputElement>
+) => {
+  const { mask, type, inputKey, ...rest } = props as _PinInputFieldProps &
+    React.ComponentProps<T>
+
+  // Check if PinInputField is used within PinInput
+  const isInsidePinInput = React.useContext(PinInputContext)
+  if (!isInsidePinInput) {
+    throw new Error(
+      `PinInputField must be used within ${PinInput.displayName}.`
+    )
+  }
+
+  const Element = component || 'input'
+
+  return (
+    <Element
+      key={inputKey}
+      ref={ref}
+      type={mask ? 'password' : type === 'numeric' ? 'tel' : 'text'}
+      inputMode={type === 'numeric' ? 'numeric' : 'text'}
+      className={cn('size-10 text-center', className)}
+      {...rest}
+    />
+  )
+}
+
+const PinInputField = React.forwardRef(PinInputFieldNoRef) as <
+  T extends React.ElementType = 'input',
+>(
+  {
+    className,
+    component,
+    ...props
+  }: PinInputFieldProps<T> & React.ComponentProps<T>,
+  ref: React.ForwardedRef<HTMLInputElement>
+) => JSX.Element
+
+/* ========== usePinInput custom hook ========== */
+
+interface UsePinInputProps {
+  value: string | undefined
+  defaultValue: string | undefined
+  placeholder: string
+  type: 'numeric' | 'alphanumeric'
+  length: number
+  readOnly: boolean
+}
+
+const usePinInput = ({
+  value,
+  defaultValue,
+  placeholder,
+  type,
+  length,
+  readOnly,
+}: UsePinInputProps) => {
+  const pinInputs = Array.from({ length }, (_, index) =>
+    defaultValue ? defaultValue.charAt(index) : value ? value.charAt(index) : ''
+  )
+  const [pins, setPins] = React.useState<(string | number)[]>(pinInputs)
+  const pinValue = pins.join('').trim()
+
+  const itemsRef = React.useRef<Map<number, HTMLInputElement> | null>(null)
+
+  function getMap() {
+    if (!itemsRef.current) {
+      // Initialize the Map on first usage.
+      itemsRef.current = new Map()
+    }
+    return itemsRef.current
+  }
+
+  function getNode(index: number) {
+    const map = getMap()
+    const node = map?.get(index)
+    return node
+  }
+
+  function focusInput(itemId: number) {
+    const node = getNode(itemId)
+    if (node) {
+      node.focus()
+      node.placeholder = ''
+    }
+  }
+
+  function handleFocus(
+    event: React.FocusEvent<HTMLInputElement>,
+    index: number
+  ) {
+    event.target.select()
+    focusInput(index)
+  }
+
+  function handleBlur(index: number) {
+    const node = getNode(index)
+    if (node) {
+      node.placeholder = placeholder
+    }
+  }
+
+  function updateInputField(val: string, index: number) {
+    const node = getNode(index)
+
+    if (node) {
+      node.value = val
+    }
+
+    setPins((prev) =>
+      prev.map((p, i) => {
+        if (i === index) {
+          return val
+        } else {
+          return p
+        }
+      })
+    )
+  }
+
+  function validate(value: string) {
+    const NUMERIC_REGEX = /^[0-9]+$/
+    const ALPHA_NUMERIC_REGEX = /^[a-zA-Z0-9]+$/i
+    const regex = type === 'alphanumeric' ? ALPHA_NUMERIC_REGEX : NUMERIC_REGEX
+    return regex.test(value)
+  }
+
+  const pastedVal = React.useRef<null | string>(null)
+  function handleChange(e: React.ChangeEvent<HTMLInputElement>, index: number) {
+    const inputValue = e.target.value
+    const pastedValue = pastedVal.current
+    const inputChar = pastedValue
+      ? pastedValue.charAt(length - 1)
+      : inputValue.slice(-1)
+
+    if (validate(inputChar)) {
+      updateInputField(inputChar, index)
+      pastedVal.current = null
+      if (inputValue.length > 0) {
+        focusInput(index + 1)
+      }
+    }
+  }
+
+  function handlePaste(event: React.ClipboardEvent<HTMLInputElement>) {
+    event.preventDefault()
+    const copyValue = event.clipboardData
+      .getData('text/plain')
+      .replace(/[\n\r\s]+/g, '')
+    const copyArr = copyValue.split('').slice(0, length)
+
+    const isValid = copyArr.every((c) => validate(c))
+
+    if (!isValid) return
+
+    for (let i = 0; i < length; i++) {
+      if (i < copyArr.length) {
+        updateInputField(copyArr[i], i)
+      }
+    }
+
+    pastedVal.current = copyValue
+    focusInput(copyArr.length < length ? copyArr.length : length - 1)
+  }
+
+  function handleKeyDown(
+    event: React.KeyboardEvent<HTMLInputElement>,
+    index: number
+  ) {
+    const { ctrlKey, key, shiftKey, metaKey } = event
+
+    if (type === 'numeric') {
+      const canTypeSign =
+        key === 'Backspace' ||
+        key === 'Tab' ||
+        key === 'Control' ||
+        key === 'Delete' ||
+        (ctrlKey && key === 'v') ||
+        (metaKey && key === 'v')
+          ? true
+          : !Number.isNaN(Number(key))
+
+      if (!canTypeSign || readOnly) {
+        event.preventDefault()
+      }
+    }
+
+    if (key === 'ArrowLeft' || (shiftKey && key === 'Tab')) {
+      event.preventDefault()
+      focusInput(index - 1)
+    } else if (key === 'ArrowRight' || key === 'Tab' || key === ' ') {
+      event.preventDefault()
+      focusInput(index + 1)
+    } else if (key === 'Delete') {
+      event.preventDefault()
+    } else if (key === 'Backspace') {
+      event.preventDefault()
+      updateInputField('', index)
+      if ((event.target as HTMLInputElement).value === '') {
+        focusInput(index - 1)
+      }
+    }
+  }
+
+  return {
+    pins,
+    pinValue,
+    refMap: getMap(),
+    handleFocus,
+    handleBlur,
+    handleChange,
+    handlePaste,
+    handleKeyDown,
+  }
+}
+
+/* ========== Util Func ========== */
+
+const getValidChildren = (children: React.ReactNode) =>
+  React.Children.toArray(children).filter((child) => {
+    if (React.isValidElement(child)) {
+      return React.isValidElement(child)
+    }
+    throw new Error(`${PinInput.displayName} contains invalid children.`)
+  }) as React.ReactElement[]
+
+const getInputFieldCount = (children: React.ReactNode) =>
+  React.Children.toArray(children).filter((child) => {
+    if (React.isValidElement(child) && child.type === PinInputField) {
+      return React.isValidElement(child)
+    }
+  }).length
+
+export { PinInput, PinInputField }

+ 7 - 0
src/data/sidelinks.tsx

@@ -3,6 +3,7 @@ import {
   IconBoxSeam,
   IconChartHistogram,
   IconChecklist,
+  IconComponents,
   IconError404,
   IconExclamationCircle,
   IconHexagonNumber1,
@@ -113,6 +114,12 @@ export const sidelinks: SideLink[] = [
     href: '/analysis',
     icon: <IconChartHistogram size={18} />,
   },
+  {
+    title: 'Extra Components',
+    label: '',
+    href: '/extra-components',
+    icon: <IconComponents size={18} />,
+  },
   {
     title: 'Error Pages',
     label: '',

+ 166 - 0
src/pages/extra-components/index.tsx

@@ -0,0 +1,166 @@
+import { useState } from 'react'
+import { Link } from 'react-router-dom'
+import { IconChevronRight } from '@tabler/icons-react'
+import { nord } from 'react-syntax-highlighter/dist/esm/styles/prism'
+import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
+import { Layout, LayoutBody, LayoutHeader } from '@/components/custom/layout'
+import { Breadcrumb, BreadcrumbItem } from '@/components/custom/breadcrumb'
+import { PinInput, PinInputField } from '@/components/custom/pin-input'
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
+import { Separator } from '@/components/ui/separator'
+import { Input } from '@/components/ui/input'
+import { Search } from '@/components/search'
+import ThemeSwitch from '@/components/theme-switch'
+import { UserNav } from '@/components/user-nav'
+
+export default function ExtraComponents() {
+  const items = [
+    { title: 'Extra Components', href: '/extra-components' },
+    { title: 'Breadcrumb' },
+  ].map(({ href, title }) => (
+    <BreadcrumbItem key={title}>
+      {href ? (
+        <Link
+          className='text-muted-foreground underline decoration-muted-foreground decoration-dashed underline-offset-4 hover:text-foreground hover:decoration-solid'
+          to={href}
+        >
+          {title}
+        </Link>
+      ) : (
+        <span className='text-muted-foreground'>{title}</span>
+      )}
+    </BreadcrumbItem>
+  ))
+
+  const [pinInput, setPinInput] = useState('')
+
+  return (
+    <Layout>
+      {/* ===== Top Heading ===== */}
+      <LayoutHeader>
+        <div className='ml-auto flex items-center space-x-4'>
+          <Search />
+          <ThemeSwitch />
+          <UserNav />
+        </div>
+      </LayoutHeader>
+
+      <LayoutBody className='space-y-4'>
+        <div className='flex items-center justify-between space-y-2'>
+          <h1 className='text-2xl font-bold tracking-tight md:text-3xl'>
+            Extra Components
+          </h1>
+        </div>
+        <h2 className='text-lg font-bold md:text-xl'>Breadcrumbs</h2>
+        <Breadcrumb separator={<IconChevronRight size={18} />}>
+          {items}
+        </Breadcrumb>
+        <Breadcrumb>{items}</Breadcrumb>
+
+        <Separator />
+
+        <h2 className='text-lg font-bold md:text-xl'>Pin Input</h2>
+        <div className='flex flex-col gap-12 lg:flex-row'>
+          <div className='flex-1'>
+            <h3 className='mb-2 font-medium'>Uncontrolled</h3>
+            <Tabs defaultValue='preview'>
+              <TabsList>
+                <TabsTrigger value='preview'>Preview</TabsTrigger>
+                <TabsTrigger value='code'>Code</TabsTrigger>
+              </TabsList>
+              <TabsContent value='preview'>
+                <div className='flex min-h-56 items-center justify-center rounded border'>
+                  <PinInput
+                    className='flex h-10 space-x-4'
+                    onComplete={(str) => console.log('completed', str)}
+                    autoFocus
+                  >
+                    <PinInputField component={Input} />
+                    <PinInputField component={Input} />
+                    <Separator orientation='vertical' />
+                    <PinInputField component={Input} />
+                    <PinInputField component={Input} />
+                  </PinInput>
+                </div>
+              </TabsContent>
+              <TabsContent value='code'>
+                <SyntaxHighlighter
+                  language='tsx'
+                  style={nord}
+                  wrapLines
+                  wrapLongLines
+                >
+                  {`<PinInput
+  className='flex h-10 space-x-4'
+  defaultValue=''
+  onComplete={(str) => 
+    console.log('completed', str)
+  }  
+  autoFocus
+>
+  <PinInputField component={Input} />
+  <PinInputField component={Input} />
+  <Separator orientation='vertical' />
+  <PinInputField component={Input} />
+  <PinInputField component={Input} />
+</PinInput>
+`}
+                </SyntaxHighlighter>
+              </TabsContent>
+            </Tabs>
+          </div>
+          <div className='flex-1'>
+            <h3 className='mb-2 font-medium'>Controlled</h3>
+            <Tabs defaultValue='preview'>
+              <TabsList>
+                <TabsTrigger value='preview'>Preview</TabsTrigger>
+                <TabsTrigger value='code'>Code</TabsTrigger>
+              </TabsList>
+              <TabsContent value='preview'>
+                <div className='flex min-h-56 items-center justify-center rounded border'>
+                  <PinInput
+                    className='flex h-10 space-x-4'
+                    value={pinInput}
+                    onChange={setPinInput}
+                    onComplete={(str) => console.log('completed', str)}
+                  >
+                    {Array.from({ length: 4 }, (_, i) => (
+                      <PinInputField key={i} component={Input} />
+                    ))}
+                  </PinInput>
+                </div>
+              </TabsContent>
+              <TabsContent value='code'>
+                <SyntaxHighlighter
+                  language='tsx'
+                  style={nord}
+                  wrapLines
+                  wrapLongLines
+                >
+                  {`function ControlledPinInput() {
+  const [pinInput, setPinInput] = useState('');
+
+  return (
+    <PinInput
+      className='flex h-10 space-x-4'
+      value={pinInput}
+      onChange={setPinInput}
+      onComplete={(str) => 
+        console.log('completed', str)
+      }
+    >
+      {Array.from({ length: 4 }, (_, i) => (
+        <PinInputField key={i} component={Input} />
+      ))}
+    </PinInput>
+  )
+}`}
+                </SyntaxHighlighter>
+              </TabsContent>
+            </Tabs>
+          </div>
+        </div>
+      </LayoutBody>
+    </Layout>
+  )
+}

+ 6 - 0
src/router.tsx

@@ -69,6 +69,12 @@ const router = createBrowserRouter([
           Component: (await import('@/components/coming-soon')).default,
         }),
       },
+      {
+        path: 'extra-components',
+        lazy: async () => ({
+          Component: (await import('@/pages/extra-components')).default,
+        }),
+      },
       {
         path: 'settings',
         lazy: async () => ({