Compare commits
9 Commits
722475215d
...
v0.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 6d3331f802 | |||
| 69751eb6f2 | |||
| 1c87e23e5d | |||
| d36330084a | |||
| b8fb8c63c6 | |||
| df7bbba915 | |||
| 95f72407f8 | |||
| d915443b8c | |||
| f4fd1fc04b |
37
.github/workflows/publish-package.yml
vendored
Normal file
37
.github/workflows/publish-package.yml
vendored
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
name: Publish package to GitHub Packages
|
||||||
|
|
||||||
|
# Publishes @richiesnitch/ads3-design-system to GitHub Packages when a version
|
||||||
|
# tag (v*) is pushed, or on manual dispatch. Uses the workflow's built-in
|
||||||
|
# GITHUB_TOKEN (no PAT needed) — packages:write permission is granted below.
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
registry-url: https://npm.pkg.github.com
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Build library
|
||||||
|
run: npm run build:lib
|
||||||
|
|
||||||
|
- name: Publish
|
||||||
|
run: npm publish
|
||||||
|
env:
|
||||||
|
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
174
ARCHITECTURE.md
Normal file
174
ARCHITECTURE.md
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
# ARCHITECTURE.md — ADS 3.0 Design System
|
||||||
|
|
||||||
|
This is the living architecture document for the ADS 3.0 design system. All structural decisions are recorded here. Update this document when the architecture evolves — never let the codebase and this document drift apart.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Overview
|
||||||
|
|
||||||
|
ADS 3.0 Design System is a React component library implementing the ADS 3.0 (Adaptive Design System) design language. It provides tokens, primitives, and composite components as a shared foundation. Application-specific screens and domain logic belong in downstream forks of this repo.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Token Pipeline
|
||||||
|
|
||||||
|
```
|
||||||
|
Figma (design tool)
|
||||||
|
↓ Figma MCP / get_variable_defs
|
||||||
|
src/tokens/tokens.css (@theme block)
|
||||||
|
↓ Tailwind CSS v4 reads @theme
|
||||||
|
↓ Generates utility classes + CSS custom properties
|
||||||
|
Components (use Tailwind utilities or var() references)
|
||||||
|
↓ Rendered in
|
||||||
|
Storybook (visual verification)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Token Layers
|
||||||
|
Tokens are structured in three layers:
|
||||||
|
|
||||||
|
1. **Palette** — raw colour values (`--color-blue-01`, `--color-grey-03`). Not used directly in components.
|
||||||
|
2. **Semantic** — purpose-based aliases (`--color-primary`, `--color-error`, `--color-text`). General UI usage.
|
||||||
|
3. **Form control** — shared interactive-state tokens for all form components (`--color-control-border`, `--color-control-checked`, `--color-control-label`, etc.). Ensures consistent styling across Input, Checkbox, Radio, Switch, Select, and future form primitives.
|
||||||
|
4. **Button** — dedicated tokens for the Button component's intent system (`--color-button-default`, `--color-button-danger`, `--color-button-neutral`, `--color-button-subtle-bg`, `--color-button-subtle-text`).
|
||||||
|
5. **Badge** — status colour tokens for the Badge component's variant system (`--color-badge-info`, `--color-badge-success`, `--color-badge-error`, `--color-badge-warning`, `--color-badge-neutral`, `--color-badge-text`).
|
||||||
|
6. **Chip** — tokens for the Chip component's border/fill states (`--color-chip-border`, `--color-chip-text`, `--color-chip-bg`, `--color-chip-selected-bg`, `--color-chip-selected-text`).
|
||||||
|
7. **Tag** — colour tokens for the Tag component's 11-colour system (`--color-tag-navy`, `--color-tag-blue`, `--color-tag-green`, `--color-tag-red`, `--color-tag-orange`, `--color-tag-grey`, `--color-tag-teal`, `--color-tag-brown`, `--color-tag-purple`, `--color-tag-fuchsia`, `--color-tag-yellow`, plus `-light` variants for each).
|
||||||
|
8. **Alert** — background, border, and icon colour tokens for 5 alert variants (`--color-alert-{variant}-bg`, `--color-alert-{variant}-border`, `--color-alert-{variant}-icon` for info, warning, error, success, neutral).
|
||||||
|
9. **Switch** — dedicated on-state tokens (`--color-switch-on`, `--color-switch-on-hover`) using success green per ADS 3.0.
|
||||||
|
10. **Avatar** — background and text colour tokens (`--color-avatar`, `--color-avatar-text`).
|
||||||
|
11. **TopBar** — background colour token (`--color-topbar`).
|
||||||
|
12. **SideNav** — navigation-specific tokens (`--color-nav-bg`, `--color-nav-text`, `--color-nav-icon`, `--color-nav-active`, `--color-nav-divider`).
|
||||||
|
|
||||||
|
### Token Categories
|
||||||
|
- **Palette colours**: `--color-{palette}-{shade}` — 10 families (blue, red, orange, green, teal, brown, purple, fuchsia, yellow, grey) × 4 shades each
|
||||||
|
- **Semantic colours**: `--color-{purpose}` (e.g., `--color-primary` = navy, `--color-info` = bright blue, `--color-error`, `--color-text`)
|
||||||
|
- **Form control colours**: `--color-control-{role}` (e.g., `--color-control-border`, `--color-control-checked`)
|
||||||
|
- **Button colours**: `--color-button-{intent}` (e.g., `--color-button-default`, `--color-button-danger`)
|
||||||
|
- **Badge colours**: `--color-badge-{variant}` (e.g., `--color-badge-info`, `--color-badge-error`)
|
||||||
|
- **Chip colours**: `--color-chip-{role}` (e.g., `--color-chip-border`, `--color-chip-selected-bg`)
|
||||||
|
- **Tag colours**: `--color-tag-{color}` and `--color-tag-{color}-light` (e.g., `--color-tag-blue`, `--color-tag-blue-light`)
|
||||||
|
- **Radii**: `--radius-*` (sm, default, lg, full)
|
||||||
|
- **Shadows**: `--shadow-*` (default, md)
|
||||||
|
|
||||||
|
### How Tailwind v4 @theme Works
|
||||||
|
Declaring `--color-primary: #2563eb` inside `@theme` in `tokens.css` automatically generates utilities like `bg-primary`, `text-primary`, `border-primary`. No JavaScript config file needed — the CSS file is the config.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Component Taxonomy
|
||||||
|
|
||||||
|
### `src/components/atoms/` — Atoms
|
||||||
|
Single-purpose, atomic building blocks. Each wraps a single native element or interaction pattern with no domain logic.
|
||||||
|
- Button, IconButton
|
||||||
|
- Input, Textarea, Select, Autocomplete
|
||||||
|
- Checkbox, Radio/RadioGroup, Switch
|
||||||
|
- Slider, RangeSlider
|
||||||
|
- FileInput
|
||||||
|
- Badge, Tag, Chip
|
||||||
|
- Tabs (TabList, Tab, TabPanel)
|
||||||
|
- List (ListItem, ListSubheader, ListDivider)
|
||||||
|
- Avatar, Tooltip
|
||||||
|
|
||||||
|
### `src/components/molecules/` — Molecules
|
||||||
|
Small compositions of atoms into reusable units. May combine icons, text, buttons, or other atoms.
|
||||||
|
- Alert, Accordion, Card, Dialog, Popover
|
||||||
|
- DataTable
|
||||||
|
|
||||||
|
### `src/components/organisms/` — Organisms
|
||||||
|
Larger compositions that carry domain semantics or define page-level regions. Built from atoms and molecules.
|
||||||
|
- TopBar, SideNav, PageHeader
|
||||||
|
- *(planned)* DatePicker
|
||||||
|
|
||||||
|
### `src/components/templates/` — Templates
|
||||||
|
Page-level layout components that define the shell and content structure. Templates accept typed slot props (ReactNode) for their sections, making them composable by AI agents and developers. They do not own content — they define where content goes.
|
||||||
|
- **AppShell** — TopBar + SideNav + scrollable content area. All pages render inside this.
|
||||||
|
- **DashboardPage** — PageHeader + stat cards row + responsive 2-column content grid
|
||||||
|
- **ListPage** — PageHeader + stat cards + list header with actions + scrollable item list
|
||||||
|
- **FormPage** — PageHeader + optional action bar + optional vertical stepper + constrained-width form content
|
||||||
|
- **DetailPage** — PageHeader + optional action bar (e.g. tabs) + single-column constrained content for viewing records/profiles/documents
|
||||||
|
- **CenteredPage** — TopBar (optional) + horizontally/vertically centered content, no sidebar. For login, error, onboarding flows
|
||||||
|
|
||||||
|
Templates have Storybook stories tagged `['autodocs', 'template']` that show realistic "recipe" compositions — full pages built from real components with sample data. These serve as reference implementations for AI coding agents.
|
||||||
|
|
||||||
|
### Which Tier Does a Component Belong To?
|
||||||
|
|
||||||
|
| Question | If yes → |
|
||||||
|
|---|---|
|
||||||
|
| Does it wrap a single native element or a single interaction pattern (button, input, toggle)? | **atoms/** |
|
||||||
|
| Does it compose 2+ atoms into a reusable unit (e.g., Alert = icon + text + close button)? | **molecules/** |
|
||||||
|
| Does it carry domain-specific naming or logic (e.g., ThemeCard, ParticipantRow)? | **organisms/** |
|
||||||
|
| Does it define a page-level region or shell (header, sidebar, content area)? | **organisms/** |
|
||||||
|
| Does it define the layout structure of a full page (slot-based, no owned content)? | **templates/** |
|
||||||
|
|
||||||
|
When in doubt: start in `atoms/`. Promote to `molecules/` when a component begins importing other atoms.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Styling Approach
|
||||||
|
|
||||||
|
- **Primary**: Tailwind utility classes
|
||||||
|
- **Conditional classes**: `cn()` from `@/lib/utils` (clsx + tailwind-merge)
|
||||||
|
- **Token values**: Always from `src/tokens/tokens.css`, never hardcoded
|
||||||
|
- **Token discipline**: Components reference semantic or domain-specific tokens (form-control, button), not palette tokens. If the needed token doesn't exist, add it to `tokens.css` before using a raw palette value.
|
||||||
|
- **No CSS modules, no styled-components, no inline styles** (except truly dynamic values)
|
||||||
|
- **Class ordering**: Enforced by `prettier-plugin-tailwindcss`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Storybook Conventions
|
||||||
|
|
||||||
|
- Every component has a co-located `.stories.tsx` file
|
||||||
|
- All stories use `tags: ['autodocs']` for auto-generated docs
|
||||||
|
- Stories cover: default state, all variants, edge cases, disabled/error states
|
||||||
|
- A11y addon runs on all stories — violations should be addressed
|
||||||
|
- MCP addon enabled at `localhost:6006/mcp` for AI-assisted development
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── components/
|
||||||
|
│ ├── atoms/ # Single-purpose elements
|
||||||
|
│ ├── molecules/ # Small compositions of atoms
|
||||||
|
│ ├── organisms/ # Domain-aware / page-level components
|
||||||
|
│ └── templates/ # Page-level layout components (slot-based)
|
||||||
|
├── tokens/
|
||||||
|
│ └── tokens.css # Design tokens (@theme block)
|
||||||
|
├── styles/
|
||||||
|
│ └── global.css # Tailwind imports + base styles
|
||||||
|
├── lib/
|
||||||
|
│ └── utils.ts # cn() utility
|
||||||
|
├── hooks/ # Custom React hooks
|
||||||
|
├── App.tsx # Root component
|
||||||
|
└── main.tsx # Vite entry point
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Design Tool Integration
|
||||||
|
|
||||||
|
### Figma
|
||||||
|
- Project file: https://www.figma.com/design/mrabO6AtxN3ektGiTk0I9c/ResearchInsights (file key: `mrabO6AtxN3ektGiTk0I9c`)
|
||||||
|
- MCP server: Official Figma Remote MCP at `https://mcp.figma.com/mcp` (HTTP transport, OAuth auth)
|
||||||
|
- Key tools: `get_design_context`, `get_variable_defs`, `get_screenshot`, `search_design_system`, `use_figma`
|
||||||
|
- Design tokens extracted via `get_variable_defs` → mapped to `@theme` values in `tokens.css`
|
||||||
|
|
||||||
|
### Code Connect
|
||||||
|
- Links Figma components to their React implementations
|
||||||
|
- Once linked, `get_design_context` returns actual component code instead of generic markup
|
||||||
|
- Mapped as we build each component via `add_code_connect_map` (label: "React")
|
||||||
|
|
||||||
|
### Storybook MCP
|
||||||
|
- Available at `localhost:6006/mcp` when Storybook dev server is running
|
||||||
|
- Provides: component listing, documentation retrieval, story generation, a11y testing
|
||||||
|
- `@storybook/addon-designs` embeds Figma frames in the story panel for side-by-side comparison
|
||||||
|
- `@storybook/addon-mcp` serves the MCP endpoint
|
||||||
|
|
||||||
|
### claude2figma Skills
|
||||||
|
- 4 skills in `.claude/skills/` that enforce design system compliance when writing to Figma
|
||||||
|
- **figma-preflight**: Validates MCP connection, audits libraries, builds a Token Map of all styles/variables
|
||||||
|
- **component-rules**: Library-first lookup, Auto Layout conventions, semantic node naming
|
||||||
|
- **figma-style-binding**: All visual values must bind to Figma Styles or Variables, never hardcoded; includes post-write QA
|
||||||
|
- **reference-interpreter**: Converts screenshots/references into structured Design Briefs mapped to design tokens
|
||||||
91
README.md
91
README.md
@@ -1,73 +1,32 @@
|
|||||||
# React + TypeScript + Vite
|
# ADS 3.0 Design System
|
||||||
|
|
||||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
A React component library implementing the ADS 3.0 (Adaptive Design System) design language. Built with React 19, TypeScript, Tailwind CSS v4, and Storybook 10.
|
||||||
|
|
||||||
Currently, two official plugins are available:
|
## Getting Started
|
||||||
|
|
||||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
```bash
|
||||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
npm install
|
||||||
|
npm run storybook # Component development at localhost:6006
|
||||||
## React Compiler
|
npm run dev # Vite dev server
|
||||||
|
|
||||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
|
||||||
|
|
||||||
## Expanding the ESLint configuration
|
|
||||||
|
|
||||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
|
||||||
|
|
||||||
```js
|
|
||||||
export default defineConfig([
|
|
||||||
globalIgnores(['dist']),
|
|
||||||
{
|
|
||||||
files: ['**/*.{ts,tsx}'],
|
|
||||||
extends: [
|
|
||||||
// Other configs...
|
|
||||||
|
|
||||||
// Remove tseslint.configs.recommended and replace with this
|
|
||||||
tseslint.configs.recommendedTypeChecked,
|
|
||||||
// Alternatively, use this for stricter rules
|
|
||||||
tseslint.configs.strictTypeChecked,
|
|
||||||
// Optionally, add this for stylistic rules
|
|
||||||
tseslint.configs.stylisticTypeChecked,
|
|
||||||
|
|
||||||
// Other configs...
|
|
||||||
],
|
|
||||||
languageOptions: {
|
|
||||||
parserOptions: {
|
|
||||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
|
||||||
tsconfigRootDir: import.meta.dirname,
|
|
||||||
},
|
|
||||||
// other options...
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
```
|
```
|
||||||
|
|
||||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
## Architecture
|
||||||
|
|
||||||
```js
|
- **Tokens** — Design tokens in `src/tokens/tokens.css` as a Tailwind v4 `@theme` block
|
||||||
// eslint.config.js
|
- **Atoms** — Single-purpose elements (Button, Input, Badge, etc.)
|
||||||
import reactX from 'eslint-plugin-react-x'
|
- **Molecules** — Small compositions (Alert, Dialog, Card, Accordion)
|
||||||
import reactDom from 'eslint-plugin-react-dom'
|
- **Organisms** — Page-level regions (AppShell, TabBar)
|
||||||
|
|
||||||
export default defineConfig([
|
## Usage as a Base
|
||||||
globalIgnores(['dist']),
|
|
||||||
{
|
This repo is designed to be forked for specific applications. Fork it, then build your application screens and domain logic on top of the shared component set.
|
||||||
files: ['**/*.{ts,tsx}'],
|
|
||||||
extends: [
|
## Tech Stack
|
||||||
// Other configs...
|
|
||||||
// Enable lint rules for React
|
| Tool | Purpose |
|
||||||
reactX.configs['recommended-typescript'],
|
|------|---------|
|
||||||
// Enable lint rules for React DOM
|
| React 19 | UI framework |
|
||||||
reactDom.configs.recommended,
|
| TypeScript (strict) | Type safety |
|
||||||
],
|
| Tailwind CSS v4 | Utility-first styling via CSS-first config |
|
||||||
languageOptions: {
|
| Storybook 10 | Component development and documentation |
|
||||||
parserOptions: {
|
| Vite | Build tooling |
|
||||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
|
||||||
tsconfigRootDir: import.meta.dirname,
|
|
||||||
},
|
|
||||||
// other options...
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>sdc-frontend</title>
|
<title>ADS 3.0 Design System</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
690
package-lock.json
generated
690
package-lock.json
generated
@@ -1,25 +1,24 @@
|
|||||||
{
|
{
|
||||||
"name": "sdc-frontend",
|
"name": "@richiesnitch/ads3-design-system",
|
||||||
"version": "0.0.0",
|
"version": "0.1.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "sdc-frontend",
|
"name": "@richiesnitch/ads3-design-system",
|
||||||
"version": "0.0.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@floating-ui/react": "^0.27.19",
|
"@floating-ui/react": "^0.27.19",
|
||||||
"@fontsource-variable/public-sans": "^5.2.7",
|
"@fontsource-variable/public-sans": "^5.2.7",
|
||||||
"@tailwindcss/vite": "^4.3.0",
|
"@tailwindcss/vite": "^4.3.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"react": "^19.2.6",
|
|
||||||
"react-dom": "^19.2.6",
|
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"tailwindcss": "^4.3.0"
|
"tailwindcss": "^4.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@chromatic-com/storybook": "^5.2.1",
|
"@chromatic-com/storybook": "^5.2.1",
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
|
"@microsoft/api-extractor": "^7.58.7",
|
||||||
"@storybook/addon-a11y": "^10.4.0",
|
"@storybook/addon-a11y": "^10.4.0",
|
||||||
"@storybook/addon-designs": "^11.1.3",
|
"@storybook/addon-designs": "^11.1.3",
|
||||||
"@storybook/addon-docs": "^10.4.0",
|
"@storybook/addon-docs": "^10.4.0",
|
||||||
@@ -42,11 +41,18 @@
|
|||||||
"playwright": "^1.60.0",
|
"playwright": "^1.60.0",
|
||||||
"prettier": "^3.8.3",
|
"prettier": "^3.8.3",
|
||||||
"prettier-plugin-tailwindcss": "^0.8.0",
|
"prettier-plugin-tailwindcss": "^0.8.0",
|
||||||
|
"react": "^19.2.7",
|
||||||
|
"react-dom": "^19.2.7",
|
||||||
"storybook": "^10.4.0",
|
"storybook": "^10.4.0",
|
||||||
"typescript": "~6.0.2",
|
"typescript": "~6.0.2",
|
||||||
"typescript-eslint": "^8.59.2",
|
"typescript-eslint": "^8.59.2",
|
||||||
"vite": "^8.0.12",
|
"vite": "^8.0.12",
|
||||||
|
"vite-plugin-dts": "^5.0.2",
|
||||||
"vitest": "^4.1.6"
|
"vitest": "^4.1.6"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@adobe/css-tools": {
|
"node_modules/@adobe/css-tools": {
|
||||||
@@ -1171,6 +1177,130 @@
|
|||||||
"react": ">=16"
|
"react": ">=16"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@microsoft/api-extractor": {
|
||||||
|
"version": "7.58.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@microsoft/api-extractor/-/api-extractor-7.58.7.tgz",
|
||||||
|
"integrity": "sha512-yK6OycD46gIzLRpj6ueVUWPk1ACSpkN1LBo05gY1qPTylbWyUCanXfH7+VgkI5LJrJoRSQR5F04XuCffCXLOBw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@microsoft/api-extractor-model": "7.33.8",
|
||||||
|
"@microsoft/tsdoc": "~0.16.0",
|
||||||
|
"@microsoft/tsdoc-config": "~0.18.1",
|
||||||
|
"@rushstack/node-core-library": "5.23.1",
|
||||||
|
"@rushstack/rig-package": "0.7.3",
|
||||||
|
"@rushstack/terminal": "0.24.0",
|
||||||
|
"@rushstack/ts-command-line": "5.3.9",
|
||||||
|
"diff": "~8.0.2",
|
||||||
|
"minimatch": "10.2.3",
|
||||||
|
"resolve": "~1.22.1",
|
||||||
|
"semver": "~7.7.4",
|
||||||
|
"source-map": "~0.6.1",
|
||||||
|
"typescript": "5.9.3"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"api-extractor": "bin/api-extractor"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@microsoft/api-extractor-model": {
|
||||||
|
"version": "7.33.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@microsoft/api-extractor-model/-/api-extractor-model-7.33.8.tgz",
|
||||||
|
"integrity": "sha512-aIcoQggPyer3B6Ze3usz0YWC/oBwUHfRH5ETUsr+oT2BRA6SfTJl7IKPcPZkX4UR+PohowzW4uMxsvjrn8vm+w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@microsoft/tsdoc": "~0.16.0",
|
||||||
|
"@microsoft/tsdoc-config": "~0.18.1",
|
||||||
|
"@rushstack/node-core-library": "5.23.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@microsoft/api-extractor/node_modules/minimatch": {
|
||||||
|
"version": "10.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.3.tgz",
|
||||||
|
"integrity": "sha512-Rwi3pnapEqirPSbWbrZaa6N3nmqq4Xer/2XooiOKyV3q12ML06f7MOuc5DVH8ONZIFhwIYQ3yzPH4nt7iWHaTg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BlueOak-1.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"brace-expansion": "^5.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "18 || 20 || >=22"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@microsoft/api-extractor/node_modules/semver": {
|
||||||
|
"version": "7.7.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||||
|
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"bin": {
|
||||||
|
"semver": "bin/semver.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@microsoft/api-extractor/node_modules/typescript": {
|
||||||
|
"version": "5.9.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"tsc": "bin/tsc",
|
||||||
|
"tsserver": "bin/tsserver"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.17"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@microsoft/tsdoc": {
|
||||||
|
"version": "0.16.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.16.0.tgz",
|
||||||
|
"integrity": "sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@microsoft/tsdoc-config": {
|
||||||
|
"version": "0.18.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@microsoft/tsdoc-config/-/tsdoc-config-0.18.1.tgz",
|
||||||
|
"integrity": "sha512-9brPoVdfN9k9g0dcWkFeA7IH9bbcttzDJlXvkf8b2OBzd5MueR1V2wkKBL0abn0otvmkHJC6aapBOTJDDeMCZg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@microsoft/tsdoc": "0.16.0",
|
||||||
|
"ajv": "~8.18.0",
|
||||||
|
"jju": "~1.4.0",
|
||||||
|
"resolve": "~1.22.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@microsoft/tsdoc-config/node_modules/ajv": {
|
||||||
|
"version": "8.18.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
|
||||||
|
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"fast-deep-equal": "^3.1.3",
|
||||||
|
"fast-uri": "^3.0.1",
|
||||||
|
"json-schema-traverse": "^1.0.0",
|
||||||
|
"require-from-string": "^2.0.2"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/epoberezkin"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@microsoft/tsdoc-config/node_modules/json-schema-traverse": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@napi-rs/wasm-runtime": {
|
"node_modules/@napi-rs/wasm-runtime": {
|
||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
|
||||||
@@ -2197,6 +2327,158 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@rushstack/node-core-library": {
|
||||||
|
"version": "5.23.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rushstack/node-core-library/-/node-core-library-5.23.1.tgz",
|
||||||
|
"integrity": "sha512-wlKmIKIYCKuCASbITvOxLZXepPbwXvrv7S6ig6XNWFchSyhL/E2txmVXspHY49Wu2dzf7nI27a2k/yV5BA3EiA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ajv": "~8.18.0",
|
||||||
|
"ajv-draft-04": "~1.0.0",
|
||||||
|
"ajv-formats": "~3.0.1",
|
||||||
|
"fs-extra": "~11.3.0",
|
||||||
|
"import-lazy": "~4.0.0",
|
||||||
|
"jju": "~1.4.0",
|
||||||
|
"resolve": "~1.22.1",
|
||||||
|
"semver": "~7.7.4"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/node": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rushstack/node-core-library/node_modules/ajv": {
|
||||||
|
"version": "8.18.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
|
||||||
|
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"fast-deep-equal": "^3.1.3",
|
||||||
|
"fast-uri": "^3.0.1",
|
||||||
|
"json-schema-traverse": "^1.0.0",
|
||||||
|
"require-from-string": "^2.0.2"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/epoberezkin"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rushstack/node-core-library/node_modules/ajv-draft-04": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"ajv": "^8.5.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"ajv": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rushstack/node-core-library/node_modules/json-schema-traverse": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@rushstack/node-core-library/node_modules/semver": {
|
||||||
|
"version": "7.7.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||||
|
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"bin": {
|
||||||
|
"semver": "bin/semver.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rushstack/problem-matcher": {
|
||||||
|
"version": "0.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rushstack/problem-matcher/-/problem-matcher-0.2.1.tgz",
|
||||||
|
"integrity": "sha512-gulfhBs6n+I5b7DvjKRfhMGyUejtSgOHTclF/eONr8hcgF1APEDjhxIsfdUYYMzC3rvLwGluqLjbwCFZ8nxrog==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/node": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rushstack/rig-package": {
|
||||||
|
"version": "0.7.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rushstack/rig-package/-/rig-package-0.7.3.tgz",
|
||||||
|
"integrity": "sha512-aAA518n6wxxjCfnTAOjQnm7ngNE0FVHxHAw2pxKlIhxrMn0XQjGcXKF0oKWpjBgJOmsaJpVob/v+zr3zxgPWuA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"jju": "~1.4.0",
|
||||||
|
"resolve": "~1.22.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rushstack/terminal": {
|
||||||
|
"version": "0.24.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rushstack/terminal/-/terminal-0.24.0.tgz",
|
||||||
|
"integrity": "sha512-8ZQS4MMaGsv27EXCBiH7WMPkRZrffeDoIevs6z9TM5dzqiY6+Hn4evfK/G+gvgBTjfvfkHIZPQQmalmI2sM4TQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@rushstack/node-core-library": "5.23.1",
|
||||||
|
"@rushstack/problem-matcher": "0.2.1",
|
||||||
|
"supports-color": "~8.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/node": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rushstack/terminal/node_modules/supports-color": {
|
||||||
|
"version": "8.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
|
||||||
|
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"has-flag": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rushstack/ts-command-line": {
|
||||||
|
"version": "5.3.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rushstack/ts-command-line/-/ts-command-line-5.3.9.tgz",
|
||||||
|
"integrity": "sha512-GIHqU+sRGQ3LGWAZu1O+9Yh++qwtyNIIGuNbcWHJjBTm2qRez0cwINUHZ+pQLR8UuzZDcMajrDaNbUYoaL/XtQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@rushstack/terminal": "0.24.0",
|
||||||
|
"@types/argparse": "1.0.38",
|
||||||
|
"argparse": "~1.0.9",
|
||||||
|
"string-argv": "~0.3.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@standard-schema/spec": {
|
"node_modules/@standard-schema/spec": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||||
@@ -2925,6 +3207,13 @@
|
|||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/argparse": {
|
||||||
|
"version": "1.0.38",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/argparse/-/argparse-1.0.38.tgz",
|
||||||
|
"integrity": "sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/aria-query": {
|
"node_modules/@types/aria-query": {
|
||||||
"version": "5.0.4",
|
"version": "5.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
||||||
@@ -3588,6 +3877,35 @@
|
|||||||
"url": "https://opencollective.com/vitest"
|
"url": "https://opencollective.com/vitest"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@volar/language-core": {
|
||||||
|
"version": "2.4.28",
|
||||||
|
"resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.28.tgz",
|
||||||
|
"integrity": "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@volar/source-map": "2.4.28"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@volar/source-map": {
|
||||||
|
"version": "2.4.28",
|
||||||
|
"resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.28.tgz",
|
||||||
|
"integrity": "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@volar/typescript": {
|
||||||
|
"version": "2.4.28",
|
||||||
|
"resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.28.tgz",
|
||||||
|
"integrity": "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@volar/language-core": "2.4.28",
|
||||||
|
"path-browserify": "^1.0.1",
|
||||||
|
"vscode-uri": "^3.0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@webcontainer/env": {
|
"node_modules/@webcontainer/env": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@webcontainer/env/-/env-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@webcontainer/env/-/env-1.1.1.tgz",
|
||||||
@@ -3635,6 +3953,48 @@
|
|||||||
"url": "https://github.com/sponsors/epoberezkin"
|
"url": "https://github.com/sponsors/epoberezkin"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ajv-formats": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ajv": "^8.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"ajv": "^8.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"ajv": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ajv-formats/node_modules/ajv": {
|
||||||
|
"version": "8.20.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
|
||||||
|
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"fast-deep-equal": "^3.1.3",
|
||||||
|
"fast-uri": "^3.0.1",
|
||||||
|
"json-schema-traverse": "^1.0.0",
|
||||||
|
"require-from-string": "^2.0.2"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/epoberezkin"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ajv-formats/node_modules/json-schema-traverse": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/ansi-regex": {
|
"node_modules/ansi-regex": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||||
@@ -3660,6 +4020,16 @@
|
|||||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/argparse": {
|
||||||
|
"version": "1.0.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
|
||||||
|
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"sprintf-js": "~1.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/aria-query": {
|
"node_modules/aria-query": {
|
||||||
"version": "5.3.0",
|
"version": "5.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
|
||||||
@@ -3919,6 +4289,20 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/compare-versions": {
|
||||||
|
"version": "6.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-6.1.1.tgz",
|
||||||
|
"integrity": "sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/confbox": {
|
||||||
|
"version": "0.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz",
|
||||||
|
"integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/convert-source-map": {
|
"node_modules/convert-source-map": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||||
@@ -4052,6 +4436,16 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/diff": {
|
||||||
|
"version": "8.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz",
|
||||||
|
"integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.3.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/doctrine": {
|
"node_modules/doctrine": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
|
||||||
@@ -4435,6 +4829,13 @@
|
|||||||
"node": ">=12.0.0"
|
"node": ">=12.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/exsolve": {
|
||||||
|
"version": "1.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz",
|
||||||
|
"integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/fast-deep-equal": {
|
"node_modules/fast-deep-equal": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
@@ -4456,6 +4857,23 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/fast-uri": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
|
||||||
|
"integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fastify"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/fastify"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/fdir": {
|
"node_modules/fdir": {
|
||||||
"version": "6.5.0",
|
"version": "6.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||||
@@ -4524,6 +4942,21 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/fs-extra": {
|
||||||
|
"version": "11.3.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.5.tgz",
|
||||||
|
"integrity": "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"graceful-fs": "^4.2.0",
|
||||||
|
"jsonfile": "^6.0.1",
|
||||||
|
"universalify": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.14"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fsevents": {
|
"node_modules/fsevents": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
@@ -4665,6 +5098,16 @@
|
|||||||
"node": ">= 4"
|
"node": ">= 4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/import-lazy": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/import-lazy/-/import-lazy-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/imurmurhash": {
|
"node_modules/imurmurhash": {
|
||||||
"version": "0.1.4",
|
"version": "0.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
|
||||||
@@ -4830,6 +5273,13 @@
|
|||||||
"jiti": "lib/jiti-cli.mjs"
|
"jiti": "lib/jiti-cli.mjs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jju": {
|
||||||
|
"version": "1.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/jju/-/jju-1.4.0.tgz",
|
||||||
|
"integrity": "sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/js-tokens": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
@@ -4914,6 +5364,13 @@
|
|||||||
"json-buffer": "3.0.1"
|
"json-buffer": "3.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/kolorist": {
|
||||||
|
"version": "1.8.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/kolorist/-/kolorist-1.8.0.tgz",
|
||||||
|
"integrity": "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/levn": {
|
"node_modules/levn": {
|
||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
||||||
@@ -5189,6 +5646,24 @@
|
|||||||
"url": "https://opencollective.com/parcel"
|
"url": "https://opencollective.com/parcel"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/local-pkg": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"mlly": "^1.7.4",
|
||||||
|
"pkg-types": "^2.3.0",
|
||||||
|
"quansync": "^0.2.11"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/antfu"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/locate-path": {
|
"node_modules/locate-path": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
||||||
@@ -5339,6 +5814,38 @@
|
|||||||
"node": ">=16 || 14 >=14.17"
|
"node": ">=16 || 14 >=14.17"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/mlly": {
|
||||||
|
"version": "1.8.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz",
|
||||||
|
"integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"acorn": "^8.16.0",
|
||||||
|
"pathe": "^2.0.3",
|
||||||
|
"pkg-types": "^1.3.1",
|
||||||
|
"ufo": "^1.6.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mlly/node_modules/confbox": {
|
||||||
|
"version": "0.1.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz",
|
||||||
|
"integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/mlly/node_modules/pkg-types": {
|
||||||
|
"version": "1.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz",
|
||||||
|
"integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"confbox": "^0.1.8",
|
||||||
|
"mlly": "^1.7.4",
|
||||||
|
"pathe": "^2.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/mrmime": {
|
"node_modules/mrmime": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
|
||||||
@@ -5548,6 +6055,13 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/path-browserify": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/path-exists": {
|
"node_modules/path-exists": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||||
@@ -5644,6 +6158,18 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/pkg-types": {
|
||||||
|
"version": "2.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz",
|
||||||
|
"integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"confbox": "^0.2.4",
|
||||||
|
"exsolve": "^1.0.8",
|
||||||
|
"pathe": "^2.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/playwright": {
|
"node_modules/playwright": {
|
||||||
"version": "1.60.0",
|
"version": "1.60.0",
|
||||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
|
||||||
@@ -5860,10 +6386,27 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/quansync": {
|
||||||
|
"version": "0.2.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz",
|
||||||
|
"integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://github.com/sponsors/antfu"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://github.com/sponsors/sxzz"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/react": {
|
"node_modules/react": {
|
||||||
"version": "19.2.6",
|
"version": "19.2.7",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
|
||||||
"integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==",
|
"integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@@ -5902,15 +6445,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-dom": {
|
"node_modules/react-dom": {
|
||||||
"version": "19.2.6",
|
"version": "19.2.7",
|
||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz",
|
||||||
"integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==",
|
"integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scheduler": "^0.27.0"
|
"scheduler": "^0.27.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^19.2.6"
|
"react": "^19.2.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-is": {
|
"node_modules/react-is": {
|
||||||
@@ -5965,6 +6508,16 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/require-from-string": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/resolve": {
|
"node_modules/resolve": {
|
||||||
"version": "1.22.12",
|
"version": "1.22.12",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
|
||||||
@@ -6113,6 +6666,13 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/sprintf-js": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/sqids": {
|
"node_modules/sqids": {
|
||||||
"version": "0.3.0",
|
"version": "0.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/sqids/-/sqids-0.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/sqids/-/sqids-0.3.0.tgz",
|
||||||
@@ -6207,6 +6767,16 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/string-argv": {
|
||||||
|
"version": "0.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz",
|
||||||
|
"integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.6.19"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/strip-ansi": {
|
"node_modules/strip-ansi": {
|
||||||
"version": "7.2.0",
|
"version": "7.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
|
||||||
@@ -6500,6 +7070,13 @@
|
|||||||
"typescript": ">=4.8.4 <6.1.0"
|
"typescript": ">=4.8.4 <6.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ufo": {
|
||||||
|
"version": "1.6.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz",
|
||||||
|
"integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/undici-types": {
|
"node_modules/undici-types": {
|
||||||
"version": "7.16.0",
|
"version": "7.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
||||||
@@ -6533,6 +7110,60 @@
|
|||||||
"node": ">=18.12.0"
|
"node": ">=18.12.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/unplugin-dts": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/unplugin-dts/-/unplugin-dts-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-VbNiMD0LMl/t6nJueGtrCp79N7ZO1nquxj/FUybJDnKwZGsnW2wjdwBSzA3QEHujoxmxZIptsG43hL7LzXE96w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@rollup/pluginutils": "^5.1.4",
|
||||||
|
"@volar/typescript": "^2.4.26",
|
||||||
|
"compare-versions": "^6.1.1",
|
||||||
|
"debug": "^4.4.0",
|
||||||
|
"kolorist": "^1.8.0",
|
||||||
|
"local-pkg": "^1.1.1",
|
||||||
|
"magic-string": "^0.30.17",
|
||||||
|
"unplugin": "^2.3.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@microsoft/api-extractor": ">=7",
|
||||||
|
"@rspack/core": "^1",
|
||||||
|
"@vue/language-core": "~3.1.5",
|
||||||
|
"esbuild": "*",
|
||||||
|
"rolldown": "*",
|
||||||
|
"rollup": ">=3",
|
||||||
|
"typescript": ">=4",
|
||||||
|
"vite": ">=3",
|
||||||
|
"webpack": "^4 || ^5"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@microsoft/api-extractor": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@rspack/core": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vue/language-core": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"esbuild": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"rolldown": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"rollup": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"vite": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"webpack": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/update-browserslist-db": {
|
"node_modules/update-browserslist-db": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||||
@@ -6683,6 +7314,32 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vite-plugin-dts": {
|
||||||
|
"version": "5.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/vite-plugin-dts/-/vite-plugin-dts-5.0.2.tgz",
|
||||||
|
"integrity": "sha512-lNeHS+dwGju6eRmNvZQt8Shwv9j3m98hbHse/lIbLq9q3yE2DcIOBBYQEVUF6tS0kOmv+VA9Z5FqmzFnGe4U8g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"unplugin-dts": "1.0.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@microsoft/api-extractor": ">=7",
|
||||||
|
"rollup": ">=3",
|
||||||
|
"vite": ">=3"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@microsoft/api-extractor": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"rollup": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"vite": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vitest": {
|
"node_modules/vitest": {
|
||||||
"version": "4.1.6",
|
"version": "4.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.6.tgz",
|
||||||
@@ -6801,6 +7458,13 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vscode-uri": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/webpack-virtual-modules": {
|
"node_modules/webpack-virtual-modules": {
|
||||||
"version": "0.6.2",
|
"version": "0.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
|
||||||
|
|||||||
37
package.json
37
package.json
@@ -1,29 +1,53 @@
|
|||||||
{
|
{
|
||||||
"name": "sdc-frontend",
|
"name": "@richiesnitch/ads3-design-system",
|
||||||
"private": true,
|
"version": "0.1.0",
|
||||||
"version": "0.0.0",
|
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/Richiesnitch/ads3-design-system.git"
|
||||||
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"registry": "https://npm.pkg.github.com"
|
||||||
|
},
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"module": "./dist/index.js",
|
||||||
|
"types": "./dist/src/index.d.ts",
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/src/index.d.ts",
|
||||||
|
"import": "./dist/index.js"
|
||||||
|
},
|
||||||
|
"./tokens": "./dist/tokens.css",
|
||||||
|
"./typography": "./dist/typography.css"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
|
"build:lib": "vite build --config vite.lib.config.ts && cp src/tokens/tokens.css src/styles/typography.css dist/",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"storybook": "storybook dev -p 6006",
|
"storybook": "storybook dev -p 6006",
|
||||||
"build-storybook": "storybook build"
|
"build-storybook": "storybook build"
|
||||||
},
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0"
|
||||||
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@floating-ui/react": "^0.27.19",
|
"@floating-ui/react": "^0.27.19",
|
||||||
"@fontsource-variable/public-sans": "^5.2.7",
|
"@fontsource-variable/public-sans": "^5.2.7",
|
||||||
"@tailwindcss/vite": "^4.3.0",
|
"@tailwindcss/vite": "^4.3.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"react": "^19.2.6",
|
|
||||||
"react-dom": "^19.2.6",
|
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"tailwindcss": "^4.3.0"
|
"tailwindcss": "^4.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@chromatic-com/storybook": "^5.2.1",
|
"@chromatic-com/storybook": "^5.2.1",
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
|
"@microsoft/api-extractor": "^7.58.7",
|
||||||
"@storybook/addon-a11y": "^10.4.0",
|
"@storybook/addon-a11y": "^10.4.0",
|
||||||
"@storybook/addon-designs": "^11.1.3",
|
"@storybook/addon-designs": "^11.1.3",
|
||||||
"@storybook/addon-docs": "^10.4.0",
|
"@storybook/addon-docs": "^10.4.0",
|
||||||
@@ -46,10 +70,13 @@
|
|||||||
"playwright": "^1.60.0",
|
"playwright": "^1.60.0",
|
||||||
"prettier": "^3.8.3",
|
"prettier": "^3.8.3",
|
||||||
"prettier-plugin-tailwindcss": "^0.8.0",
|
"prettier-plugin-tailwindcss": "^0.8.0",
|
||||||
|
"react": "^19.2.7",
|
||||||
|
"react-dom": "^19.2.7",
|
||||||
"storybook": "^10.4.0",
|
"storybook": "^10.4.0",
|
||||||
"typescript": "~6.0.2",
|
"typescript": "~6.0.2",
|
||||||
"typescript-eslint": "^8.59.2",
|
"typescript-eslint": "^8.59.2",
|
||||||
"vite": "^8.0.12",
|
"vite": "^8.0.12",
|
||||||
|
"vite-plugin-dts": "^5.0.2",
|
||||||
"vitest": "^4.1.6"
|
"vitest": "^4.1.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
23
public/nsw-logo.svg
Normal file
23
public/nsw-logo.svg
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0_167_1431)">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.4904 20.0612C13.3542 19.8393 13.1486 19.6376 12.8798 19.462C12.6114 19.2865 12.2456 19.1525 11.7938 19.0638L10.5361 18.803C10.1561 18.7205 9.89415 18.6137 9.75741 18.4862C9.62416 18.3617 9.55657 18.1961 9.55656 17.9941C9.55655 17.8654 9.58561 17.7483 9.64326 17.6456C9.70054 17.5427 9.78375 17.4516 9.89033 17.3748C9.99754 17.298 10.1309 17.2373 10.2863 17.1947C10.4436 17.1512 10.6203 17.1293 10.8117 17.1293C11.0636 17.1293 11.2909 17.1628 11.4872 17.2292C11.6811 17.2953 11.8419 17.4035 11.9648 17.5518C12.0879 17.7001 12.1635 17.9017 12.1894 18.1511L12.1929 18.1856L13.7534 18.1856L13.7527 18.146C13.7458 17.7219 13.6266 17.3328 13.3986 16.9898C13.1703 16.6464 12.8339 16.3706 12.3982 16.1694C11.9636 15.9688 11.4214 15.867 10.7864 15.8671C10.253 15.8671 9.76419 15.9608 9.33312 16.146C8.90088 16.3316 8.5538 16.5993 8.30217 16.942C8.0494 17.2865 7.92342 17.6987 7.92725 18.1678C7.93759 18.7401 8.1102 19.2046 8.44013 19.5491C8.76892 19.8917 9.21955 20.1198 9.77965 20.2262L11.0482 20.4866C11.2807 20.5331 11.4918 20.5941 11.6756 20.6683C11.8558 20.7409 11.9997 20.8354 12.1033 20.9491C12.2044 21.06 12.2556 21.206 12.2557 21.3819C12.2557 21.5779 12.1919 21.7434 12.0662 21.8733C11.9375 22.0062 11.7627 22.1064 11.5474 22.1722C11.3278 22.239 11.0823 22.2728 10.8176 22.2728C10.5595 22.2729 10.3216 22.2326 10.1109 22.1527C9.9012 22.0739 9.72519 21.9587 9.58771 21.8104C9.4506 21.6629 9.35662 21.4781 9.30815 21.2619L9.30125 21.2315L7.71088 21.2316L7.71508 21.2743C7.75252 21.6492 7.86824 21.9814 8.05879 22.2622C8.24896 22.5411 8.49258 22.7762 8.7828 22.9621C9.07224 23.1468 9.39679 23.2878 9.74778 23.3804C10.0979 23.4725 10.4599 23.5194 10.823 23.5193C11.4176 23.5193 11.9448 23.4248 12.3904 23.2381C12.838 23.0506 13.192 22.7925 13.4432 22.4709C13.6963 22.1471 13.8246 21.7779 13.8246 21.3727C13.8246 21.1814 13.8032 20.9686 13.7604 20.7404C13.7165 20.5103 13.6256 20.2818 13.4904 20.0612ZM2.3858 18.4422L5.78063 23.3835L7.16138 23.3834L7.16106 15.9938L5.61649 15.9939L5.6167 20.7472L2.3689 16.0106L2.35744 15.9941L0.845709 15.9941L0.846031 23.3837L2.38602 23.3836L2.3858 18.4422ZM20.4675 20.7194L21.7903 15.9934L23.3036 15.9933L21.135 23.383L19.9059 23.3831L18.5673 18.7052L17.2196 23.3832L16.0107 23.3832L13.8464 15.9937L15.3647 15.9937L16.6875 20.7188L18.0105 15.9936L19.1293 15.9935L20.4675 20.7194Z" fill="white"/>
|
||||||
|
<path d="M11.2457 14.1603C9.42964 13.9357 7.66557 14.5223 4.56151 13.6728C4.24457 13.586 4.12774 13.9225 4.2992 14.2045C5.13321 15.5765 9.34571 14.5011 11.2541 14.3029C11.3374 14.2941 11.329 14.1707 11.2457 14.1603Z" fill="#D7153A"/>
|
||||||
|
<path d="M19.1946 13.6722C16.0906 14.522 14.3265 13.9355 12.5104 14.1603C12.4272 14.1707 12.4184 14.2944 12.502 14.3028C14.4109 14.5009 18.6231 15.5759 19.457 14.2038C19.6283 13.9218 19.5116 13.5853 19.1946 13.6722Z" fill="#D7153A"/>
|
||||||
|
<path d="M5.50686 11.7099C4.87563 10.7776 4.35971 9.73467 3.96025 8.5873C2.75554 8.9419 1.52259 9.45362 0.267489 10.1232C0.105208 10.2096 0.005553 10.3718 0.000597054 10.5561C-0.00397704 10.7406 0.0869094 10.9072 0.244616 11.0021C2.67513 12.4667 5.05139 13.3044 7.31879 13.4982C6.64368 13.0835 6.02468 12.4746 5.50686 11.7099Z" fill="#D7153A"/>
|
||||||
|
<path d="M2.12068 8.59386C2.67663 8.36988 3.22763 8.17779 3.77366 8.01605C3.57813 7.37686 3.41773 6.70733 3.29092 6.0086C2.66316 5.92988 2.01364 5.87882 1.34159 5.85657C1.33586 5.85657 1.32975 5.85619 1.32402 5.85619C1.148 5.8562 0.98915 5.94532 0.89675 6.09744C0.801296 6.25417 0.797104 6.44431 0.885699 6.60564C1.27598 7.3155 1.68802 7.97811 2.12068 8.59386Z" fill="#D7153A"/>
|
||||||
|
<path d="M8.09299 13.2336C8.46988 13.401 8.85555 13.4947 9.22934 13.5143C8.31407 12.8544 7.595 11.7354 7.18063 10.3092C6.64673 8.47307 6.46833 6.44717 6.64426 4.26145C5.92407 3.80399 5.14357 3.37571 4.30311 2.9778C4.14081 2.90097 3.95524 2.91596 3.80633 3.01777C3.65779 3.11918 3.5757 3.28705 3.58678 3.46644C3.78243 6.63782 4.59397 9.29789 5.99849 11.3725C6.58733 12.243 7.31172 12.8863 8.09299 13.2336Z" fill="#D7153A"/>
|
||||||
|
<path d="M9.88405 2.85273C9.45322 2.32648 8.98586 1.80601 8.48142 1.29168C8.38366 1.19181 8.25766 1.1388 8.12744 1.13881C8.08086 1.13881 8.0339 1.14573 7.9873 1.15956C7.81052 1.21219 7.6826 1.35471 7.64405 1.54102C7.54555 2.01965 7.35811 2.99535 7.27529 3.96029C7.92024 4.3878 8.51594 4.8399 9.06162 5.31542C9.27309 4.50375 9.54799 3.68131 9.88405 2.85273Z" fill="#D7153A"/>
|
||||||
|
<path d="M23.4873 10.1218C22.2321 9.45228 20.9992 8.94067 19.7944 8.58617C19.3951 9.73359 18.8792 10.7765 18.248 11.7088C17.7304 12.474 17.1113 13.0826 16.4359 13.4975C18.7033 13.3034 21.0795 12.4654 23.5099 11.0006C23.6676 10.9057 23.7588 10.7391 23.7538 10.5547C23.7497 10.3703 23.6496 10.2086 23.4873 10.1218Z" fill="#D7153A"/>
|
||||||
|
<path d="M21.6346 8.59305C22.0673 7.97727 22.4793 7.31424 22.8695 6.60435C22.958 6.44301 22.9543 6.25325 22.8584 6.09614C22.7659 5.94441 22.6071 5.85492 22.4311 5.85493C22.4253 5.85493 22.4193 5.85493 22.4136 5.85531C21.7415 5.87762 21.0915 5.92912 20.4642 6.00751C20.3378 6.70625 20.1771 7.3758 19.9817 8.015C20.5277 8.17708 21.079 8.36951 21.6346 8.59305Z" fill="#D7153A"/>
|
||||||
|
<path d="M16.5749 10.3091C16.1607 11.7351 15.4413 12.8544 14.5264 13.5144C14.9002 13.4948 15.2863 13.4006 15.6628 13.2331C16.444 12.8859 17.1683 12.2424 17.7572 11.3723C19.1615 9.29759 19.9728 6.63745 20.1681 3.46605C20.1792 3.28667 20.0971 3.1188 19.9485 3.0174C19.7996 2.91561 19.6141 2.90064 19.4518 2.97747C18.6113 3.37509 17.8308 3.80342 17.1107 4.26134C17.2869 6.44704 17.1082 8.47257 16.5749 10.3091Z" fill="#D7153A"/>
|
||||||
|
<path d="M16.4796 3.95948C16.3967 2.99493 16.2092 2.01924 16.1107 1.54024C16.0724 1.35394 15.9441 1.21143 15.7674 1.15881C15.7208 1.14498 15.6738 1.13807 15.6272 1.13807C15.497 1.13808 15.371 1.1911 15.2732 1.29097C14.7689 1.80534 14.3015 2.32586 13.8708 2.85214C14.2068 3.6807 14.4818 4.50312 14.6938 5.31439C15.2394 4.83919 15.8346 4.38705 16.4796 3.95948Z" fill="#D7153A"/>
|
||||||
|
<path d="M11.8757 8.75247C12.4862 7.53897 13.4614 6.45107 14.1937 5.76383C13.7916 4.11593 13.1725 2.56137 12.3179 0.934998C12.2297 0.767136 12.0639 0.667269 11.8757 0.667277C11.6874 0.667286 11.5221 0.767551 11.4336 0.935036C10.5703 2.56686 9.94066 4.19061 9.56004 5.76364C10.3245 6.47617 11.333 7.63813 11.8757 8.75247Z" fill="#D7153A"/>
|
||||||
|
<path d="M14.1193 13.0585C14.7523 12.6105 15.3854 11.8299 15.7871 10.744C16.5648 8.6416 16.6452 6.29993 16.5337 4.64279C15.124 5.58974 12.8865 7.56735 12.1224 9.68163C11.7628 10.6766 11.5953 12.0318 11.8724 12.7877C11.9882 13.1039 12.1852 13.3436 12.4559 13.4576C12.866 13.6304 13.4961 13.4998 14.1193 13.0585Z" fill="#D7153A"/>
|
||||||
|
<path d="M11.5602 9.497C11.3738 9.05911 11.1683 8.64617 10.8495 8.18215C9.94749 6.86962 8.73206 5.68578 7.21952 4.64293C7.20388 4.88877 6.98329 7.5439 7.75778 10.1398C8.34706 12.1142 9.35364 12.9185 9.90537 13.2293C10.4606 13.5416 10.9685 13.6153 11.5641 13.4662C10.9604 12.6381 11.0356 10.9386 11.5602 9.497Z" fill="#D7153A"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_167_1431">
|
||||||
|
<rect width="24" height="23.1724" fill="white" transform="translate(0 0.667796)"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 7.0 KiB |
@@ -2,11 +2,11 @@ function App() {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-bg text-text">
|
<div className="min-h-screen bg-bg text-text">
|
||||||
<header className="bg-surface border-b border-border px-6 py-3">
|
<header className="bg-surface border-b border-border px-6 py-3">
|
||||||
<h1 className="text-lg font-semibold">SDC Design System</h1>
|
<h1 className="text-lg font-semibold">ADS 3.0 Design System</h1>
|
||||||
</header>
|
</header>
|
||||||
<main className="p-6">
|
<main className="p-6">
|
||||||
<p className="text-text-secondary">
|
<p className="text-text-secondary">
|
||||||
Component library for the Research Synthesiser.
|
React component library implementing the ADS 3.0 design language.
|
||||||
</p>
|
</p>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
91
src/components/atoms/Autocomplete/Autocomplete.stories.tsx
Normal file
91
src/components/atoms/Autocomplete/Autocomplete.stories.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Autocomplete } from './Autocomplete'
|
||||||
|
|
||||||
|
const states = [
|
||||||
|
{ value: 'nsw', label: 'New South Wales' },
|
||||||
|
{ value: 'vic', label: 'Victoria' },
|
||||||
|
{ value: 'qld', label: 'Queensland' },
|
||||||
|
{ value: 'wa', label: 'Western Australia' },
|
||||||
|
{ value: 'sa', label: 'South Australia' },
|
||||||
|
{ value: 'tas', label: 'Tasmania' },
|
||||||
|
{ value: 'act', label: 'Australian Capital Territory' },
|
||||||
|
{ value: 'nt', label: 'Northern Territory' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const meta: Meta<typeof Autocomplete> = {
|
||||||
|
title: 'Atoms/Autocomplete',
|
||||||
|
component: Autocomplete,
|
||||||
|
tags: ['autodocs'],
|
||||||
|
parameters: { layout: 'padded' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof Autocomplete>
|
||||||
|
|
||||||
|
const BasicTemplate = () => {
|
||||||
|
const [value, setValue] = useState('')
|
||||||
|
return (
|
||||||
|
<div className="w-96">
|
||||||
|
<Autocomplete
|
||||||
|
label="State"
|
||||||
|
placeholder="Search states…"
|
||||||
|
options={states}
|
||||||
|
value={value}
|
||||||
|
onChange={setValue}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
render: () => <BasicTemplate />,
|
||||||
|
}
|
||||||
|
|
||||||
|
const FreeSoloTemplate = () => {
|
||||||
|
const [value, setValue] = useState('')
|
||||||
|
return (
|
||||||
|
<div className="w-96">
|
||||||
|
<Autocomplete
|
||||||
|
label="School name"
|
||||||
|
placeholder="Type to search or enter a new name…"
|
||||||
|
options={states}
|
||||||
|
value={value}
|
||||||
|
onChange={setValue}
|
||||||
|
freeSolo
|
||||||
|
hint="Select from the list or type a custom value"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FreeSolo: Story = {
|
||||||
|
name: 'Free solo (Combobox)',
|
||||||
|
render: () => <FreeSoloTemplate />,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WithError: Story = {
|
||||||
|
name: 'With error',
|
||||||
|
render: () => (
|
||||||
|
<div className="w-96">
|
||||||
|
<Autocomplete
|
||||||
|
label="State"
|
||||||
|
options={states}
|
||||||
|
error="Please select a valid state"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Disabled: Story = {
|
||||||
|
render: () => (
|
||||||
|
<div className="w-96">
|
||||||
|
<Autocomplete
|
||||||
|
label="State"
|
||||||
|
options={states}
|
||||||
|
value="nsw"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}
|
||||||
216
src/components/atoms/Autocomplete/Autocomplete.tsx
Normal file
216
src/components/atoms/Autocomplete/Autocomplete.tsx
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
import {
|
||||||
|
forwardRef,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useId,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type HTMLAttributes,
|
||||||
|
} from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export interface AutocompleteOption {
|
||||||
|
value: string
|
||||||
|
label: string
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AutocompleteProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange'> {
|
||||||
|
label: string
|
||||||
|
description?: string
|
||||||
|
hint?: string
|
||||||
|
error?: string
|
||||||
|
placeholder?: string
|
||||||
|
options: AutocompleteOption[]
|
||||||
|
value?: string
|
||||||
|
onChange?: (value: string) => void
|
||||||
|
freeSolo?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
loading?: boolean
|
||||||
|
noResultsText?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChevronIcon = () => (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="m6 9 6 6 6-6" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
export const Autocomplete = forwardRef<HTMLDivElement, AutocompleteProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
hint,
|
||||||
|
error,
|
||||||
|
placeholder,
|
||||||
|
options,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
freeSolo = false,
|
||||||
|
disabled = false,
|
||||||
|
loading = false,
|
||||||
|
noResultsText = 'No results found',
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) => {
|
||||||
|
const id = useId()
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const listRef = useRef<HTMLUListElement>(null)
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const [activeIndex, setActiveIndex] = useState(-1)
|
||||||
|
|
||||||
|
const selectedOption = options.find((o) => o.value === value)
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
if (!query) return options
|
||||||
|
const q = query.toLowerCase()
|
||||||
|
return options.filter((o) => o.label.toLowerCase().includes(q))
|
||||||
|
}, [options, query])
|
||||||
|
|
||||||
|
const selectOption = useCallback(
|
||||||
|
(opt: AutocompleteOption) => {
|
||||||
|
onChange?.(opt.value)
|
||||||
|
setQuery(opt.label)
|
||||||
|
setOpen(false)
|
||||||
|
setActiveIndex(-1)
|
||||||
|
},
|
||||||
|
[onChange],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleInputChange = useCallback(
|
||||||
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setQuery(e.target.value)
|
||||||
|
setOpen(true)
|
||||||
|
setActiveIndex(-1)
|
||||||
|
if (freeSolo) {
|
||||||
|
onChange?.(e.target.value)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[freeSolo, onChange],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e: React.KeyboardEvent) => {
|
||||||
|
if (!open && (e.key === 'ArrowDown' || e.key === 'Enter')) {
|
||||||
|
setOpen(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!open) return
|
||||||
|
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault()
|
||||||
|
setActiveIndex((i) => (i + 1) % filtered.length)
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault()
|
||||||
|
setActiveIndex((i) => (i <= 0 ? filtered.length - 1 : i - 1))
|
||||||
|
} else if (e.key === 'Enter' && activeIndex >= 0) {
|
||||||
|
e.preventDefault()
|
||||||
|
const opt = filtered[activeIndex]
|
||||||
|
if (opt && !opt.disabled) selectOption(opt)
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[open, filtered, activeIndex, selectOption],
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedOption && !open) {
|
||||||
|
setQuery(selectedOption.label)
|
||||||
|
}
|
||||||
|
}, [selectedOption, open])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
const handleClick = (e: MouseEvent) => {
|
||||||
|
const el = (ref as React.RefObject<HTMLDivElement>)?.current
|
||||||
|
if (el && !el.contains(e.target as Node)) setOpen(false)
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handleClick)
|
||||||
|
return () => document.removeEventListener('mousedown', handleClick)
|
||||||
|
}, [open, ref])
|
||||||
|
|
||||||
|
const listboxId = `${id}-listbox`
|
||||||
|
const hasError = !!error
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className={cn('relative flex flex-col gap-1.5', className)} {...props}>
|
||||||
|
<label htmlFor={id} className="text-small font-semibold text-control-label">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
{description && <p className="text-small text-control-description">{description}</p>}
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
id={id}
|
||||||
|
type="text"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-controls={listboxId}
|
||||||
|
aria-activedescendant={activeIndex >= 0 ? `${id}-opt-${activeIndex}` : undefined}
|
||||||
|
aria-invalid={hasError || undefined}
|
||||||
|
disabled={disabled}
|
||||||
|
placeholder={placeholder}
|
||||||
|
value={query}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
onFocus={() => setOpen(true)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
className={cn(
|
||||||
|
'h-14 w-full rounded-default border bg-control-bg px-4 pr-10 text-body text-text outline-none transition-colors',
|
||||||
|
'focus:border-primary focus:ring-2 focus:ring-control-focus-ring',
|
||||||
|
hasError ? 'border-control-error' : 'border-control-border hover:border-primary',
|
||||||
|
disabled && 'pointer-events-none opacity-55',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="pointer-events-none absolute right-3 top-1/2 size-5 -translate-y-1/2 text-primary [&>svg]:size-full">
|
||||||
|
<ChevronIcon />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{open && !disabled && (
|
||||||
|
<ul
|
||||||
|
ref={listRef}
|
||||||
|
id={listboxId}
|
||||||
|
role="listbox"
|
||||||
|
className="absolute left-0 right-0 top-full z-50 mt-1 max-h-60 overflow-auto rounded-default border border-border bg-surface py-1 shadow-md"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<li className="px-4 py-3 text-body text-text-secondary">Loading…</li>
|
||||||
|
) : filtered.length === 0 ? (
|
||||||
|
<li className="px-4 py-3 text-body text-text-secondary">{noResultsText}</li>
|
||||||
|
) : (
|
||||||
|
filtered.map((opt, i) => (
|
||||||
|
<li
|
||||||
|
key={opt.value}
|
||||||
|
id={`${id}-opt-${i}`}
|
||||||
|
role="option"
|
||||||
|
aria-selected={opt.value === value}
|
||||||
|
aria-disabled={opt.disabled || undefined}
|
||||||
|
onClick={() => !opt.disabled && selectOption(opt)}
|
||||||
|
className={cn(
|
||||||
|
'cursor-pointer px-4 py-3 text-body transition-colors',
|
||||||
|
opt.value === value && 'bg-info/12 font-bold',
|
||||||
|
i === activeIndex && opt.value !== value && 'bg-info/5',
|
||||||
|
opt.disabled && 'pointer-events-none opacity-55',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hint && !error && <p className="text-small text-control-description">{hint}</p>}
|
||||||
|
{error && <p className="text-small text-control-error">{error}</p>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
Autocomplete.displayName = 'Autocomplete'
|
||||||
2
src/components/atoms/Autocomplete/index.ts
Normal file
2
src/components/atoms/Autocomplete/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { Autocomplete } from './Autocomplete'
|
||||||
|
export type { AutocompleteProps, AutocompleteOption } from './Autocomplete'
|
||||||
49
src/components/atoms/Avatar/Avatar.stories.tsx
Normal file
49
src/components/atoms/Avatar/Avatar.stories.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react'
|
||||||
|
import { Avatar } from './Avatar'
|
||||||
|
|
||||||
|
const meta: Meta<typeof Avatar> = {
|
||||||
|
title: 'Atoms/Avatar',
|
||||||
|
component: Avatar,
|
||||||
|
tags: ['autodocs'],
|
||||||
|
parameters: { layout: 'centered' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof Avatar>
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
args: { initials: 'DW' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Small: Story = {
|
||||||
|
args: { initials: 'SR', size: 'sm' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Large: Story = {
|
||||||
|
args: { initials: 'JB', size: 'lg' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SingleInitial: Story = {
|
||||||
|
name: 'Single initial',
|
||||||
|
args: { initials: 'R', size: 'default' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AllSizes: Story = {
|
||||||
|
name: 'All sizes',
|
||||||
|
render: () => (
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Avatar initials="SM" size="sm" />
|
||||||
|
<Avatar initials="MD" size="default" />
|
||||||
|
<Avatar initials="LG" size="lg" />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OnDarkBackground: Story = {
|
||||||
|
name: 'On dark background',
|
||||||
|
render: () => (
|
||||||
|
<div className="flex items-center gap-4 rounded-lg bg-primary-dark p-4">
|
||||||
|
<Avatar initials="DW" size="lg" />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}
|
||||||
46
src/components/atoms/Avatar/Avatar.tsx
Normal file
46
src/components/atoms/Avatar/Avatar.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { forwardRef, type HTMLAttributes } from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export interface AvatarProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
|
initials: string
|
||||||
|
src?: string
|
||||||
|
alt?: string
|
||||||
|
size?: 'sm' | 'default' | 'lg'
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeStyles = {
|
||||||
|
sm: 'size-8 text-caption',
|
||||||
|
default: 'size-10 text-body',
|
||||||
|
lg: 'size-12 text-[18px]',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Avatar = forwardRef<HTMLDivElement, AvatarProps>(
|
||||||
|
({ initials, src, alt, size = 'default', className, ...props }, ref) => {
|
||||||
|
const label = alt || initials
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
role="img"
|
||||||
|
aria-label={label}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex shrink-0 items-center justify-center rounded-full bg-avatar text-avatar-text',
|
||||||
|
sizeStyles[size],
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{src ? (
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={label}
|
||||||
|
className="size-full rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
initials.slice(0, 2).toUpperCase()
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
Avatar.displayName = 'Avatar'
|
||||||
2
src/components/atoms/Avatar/index.ts
Normal file
2
src/components/atoms/Avatar/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { Avatar } from './Avatar'
|
||||||
|
export type { AvatarProps } from './Avatar'
|
||||||
@@ -21,7 +21,7 @@ export interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
|
|||||||
const variantStyles: Record<string, string> = {
|
const variantStyles: Record<string, string> = {
|
||||||
navy: 'bg-badge-navy text-white',
|
navy: 'bg-badge-navy text-white',
|
||||||
info: 'bg-badge-info text-white',
|
info: 'bg-badge-info text-white',
|
||||||
'info-light': 'bg-badge-info-light text-primary-dark',
|
'info-light': 'bg-badge-info-light text-primary',
|
||||||
success: 'bg-badge-success text-white',
|
success: 'bg-badge-success text-white',
|
||||||
'success-light': 'bg-badge-success-light text-badge-on-success-light',
|
'success-light': 'bg-badge-success-light text-badge-on-success-light',
|
||||||
error: 'bg-badge-error text-white',
|
error: 'bg-badge-error text-white',
|
||||||
@@ -29,7 +29,7 @@ const variantStyles: Record<string, string> = {
|
|||||||
warning: 'bg-badge-warning text-white',
|
warning: 'bg-badge-warning text-white',
|
||||||
'warning-light': 'bg-badge-warning-light text-badge-on-warning-light',
|
'warning-light': 'bg-badge-warning-light text-badge-on-warning-light',
|
||||||
neutral: 'bg-badge-neutral text-text-secondary',
|
neutral: 'bg-badge-neutral text-text-secondary',
|
||||||
white: 'bg-surface text-primary-dark border border-primary-dark',
|
white: 'bg-surface text-primary border border-primary',
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Badge({
|
export function Badge({
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ const variantIntentStyles: Record<string, Record<string, string>> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sizeStyles: Record<string, string> = {
|
const sizeStyles: Record<string, string> = {
|
||||||
default: 'h-12 px-6 text-body gap-2',
|
default: 'h-11 px-6 text-body gap-2',
|
||||||
comfortable: 'h-10 px-5 text-body gap-2',
|
comfortable: 'h-10 px-5 text-body gap-2',
|
||||||
compact: 'h-9 px-4 text-small gap-1.5',
|
compact: 'h-9 px-4 text-small gap-1.5',
|
||||||
}
|
}
|
||||||
@@ -89,7 +89,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
|||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
aria-busy={loading || undefined}
|
aria-busy={loading || undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
'inline-flex items-center justify-center rounded-full font-bold transition-colors',
|
'inline-flex items-center justify-center rounded-full font-semibold transition-colors',
|
||||||
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-button-default',
|
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-button-default',
|
||||||
sizeStyles[size],
|
sizeStyles[size],
|
||||||
variantIntentStyles[variant][intent],
|
variantIntentStyles[variant][intent],
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from 'react'
|
import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from 'react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
export interface ChipProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'children'> {
|
export type ChipColor = 'default' | 'info' | 'error' | 'warning' | 'success'
|
||||||
|
|
||||||
|
export interface ChipProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'children' | 'color'> {
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
selected?: boolean
|
selected?: boolean
|
||||||
|
color?: ChipColor
|
||||||
onDismiss?: () => void
|
onDismiss?: () => void
|
||||||
rightIcon?: ReactNode
|
rightIcon?: ReactNode
|
||||||
}
|
}
|
||||||
@@ -24,8 +27,16 @@ const DismissIcon = () => (
|
|||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const colorStyles: Record<ChipColor, string> = {
|
||||||
|
default: 'bg-grey-04/40 text-text-secondary',
|
||||||
|
info: 'bg-blue-04/60 text-info',
|
||||||
|
error: 'bg-red-04/60 text-error',
|
||||||
|
warning: 'bg-orange-04/60 text-warning',
|
||||||
|
success: 'bg-green-04/60 text-success',
|
||||||
|
}
|
||||||
|
|
||||||
export const Chip = forwardRef<HTMLButtonElement, ChipProps>(
|
export const Chip = forwardRef<HTMLButtonElement, ChipProps>(
|
||||||
({ children, selected = false, onDismiss, rightIcon, disabled, className, onClick, ...props }, ref) => {
|
({ children, selected = false, color, onDismiss, rightIcon, disabled, className, onClick, ...props }, ref) => {
|
||||||
const isInteractive = !!(onClick || onDismiss)
|
const isInteractive = !!(onClick || onDismiss)
|
||||||
const Component = isInteractive ? 'button' : 'span'
|
const Component = isInteractive ? 'button' : 'span'
|
||||||
|
|
||||||
@@ -49,10 +60,12 @@ export const Chip = forwardRef<HTMLButtonElement, ChipProps>(
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
className={cn(
|
className={cn(
|
||||||
'inline-flex h-8 items-center gap-2 rounded-lg border px-3 py-1.5 text-small leading-[19px]',
|
'inline-flex h-[30px] items-center gap-2 rounded-default px-3 py-1.5 text-small font-bold leading-[19px]',
|
||||||
selected
|
selected
|
||||||
? 'border-chip-selected-bg bg-chip-selected-bg text-chip-selected-text'
|
? 'border border-chip-selected-bg bg-chip-selected-bg text-chip-selected-text'
|
||||||
: 'border-chip-border bg-chip-bg text-chip-text',
|
: color && color !== 'default'
|
||||||
|
? cn('border-transparent', colorStyles[color])
|
||||||
|
: 'border border-chip-border bg-chip-bg text-chip-text',
|
||||||
isInteractive && !disabled && !selected && 'hover:bg-chip-border/5 active:bg-chip-border/10',
|
isInteractive && !disabled && !selected && 'hover:bg-chip-border/5 active:bg-chip-border/10',
|
||||||
isInteractive && !disabled && selected && 'hover:bg-chip-selected-bg/90 active:bg-chip-selected-bg/80',
|
isInteractive && !disabled && selected && 'hover:bg-chip-selected-bg/90 active:bg-chip-selected-bg/80',
|
||||||
isInteractive && 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-chip-border',
|
isInteractive && 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-chip-border',
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
export { Chip } from './Chip'
|
export { Chip } from './Chip'
|
||||||
export type { ChipProps } from './Chip'
|
export type { ChipProps, ChipColor } from './Chip'
|
||||||
|
|||||||
42
src/components/atoms/FileInput/FileInput.stories.tsx
Normal file
42
src/components/atoms/FileInput/FileInput.stories.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react'
|
||||||
|
import { FileInput } from './FileInput'
|
||||||
|
|
||||||
|
const meta: Meta<typeof FileInput> = {
|
||||||
|
title: 'Atoms/FileInput',
|
||||||
|
component: FileInput,
|
||||||
|
tags: ['autodocs'],
|
||||||
|
parameters: { layout: 'padded' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof FileInput>
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
args: {
|
||||||
|
label: 'Upload document',
|
||||||
|
description: 'Supported formats: PDF, DOCX, PNG',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Multiple: Story = {
|
||||||
|
args: {
|
||||||
|
label: 'Upload files',
|
||||||
|
multiple: true,
|
||||||
|
accept: '.pdf,.docx,.png,.jpg',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WithError: Story = {
|
||||||
|
name: 'With error',
|
||||||
|
args: {
|
||||||
|
label: 'Upload evidence',
|
||||||
|
error: 'File size must be under 10MB',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Disabled: Story = {
|
||||||
|
args: {
|
||||||
|
label: 'Upload document',
|
||||||
|
disabled: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
120
src/components/atoms/FileInput/FileInput.tsx
Normal file
120
src/components/atoms/FileInput/FileInput.tsx
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import { forwardRef, useCallback, useRef, useState, type HTMLAttributes } from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export interface FileInputProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange'> {
|
||||||
|
label: string
|
||||||
|
description?: string
|
||||||
|
error?: string
|
||||||
|
accept?: string
|
||||||
|
multiple?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
onChange?: (files: File[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const PaperclipIcon = () => (
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M16.5 6v11.5c0 2.21-1.79 4-4 4s-4-1.79-4-4V5a2.5 2.5 0 015 0v10.5c0 .55-.45 1-1 1s-1-.45-1-1V6h-1.5v9.5a2.5 2.5 0 005 0V5c0-2.21-1.79-4-4-4S7 2.79 7 5v12.5c0 3.04 2.46 5.5 5.5 5.5s5.5-2.46 5.5-5.5V6H16.5z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
export const FileInput = forwardRef<HTMLDivElement, FileInputProps>(
|
||||||
|
({ label, description, error, accept, multiple = false, disabled = false, onChange, className, ...props }, ref) => {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const [files, setFiles] = useState<File[]>([])
|
||||||
|
|
||||||
|
const handleFiles = useCallback(
|
||||||
|
(fileList: FileList | null) => {
|
||||||
|
if (!fileList) return
|
||||||
|
const next = Array.from(fileList)
|
||||||
|
setFiles(next)
|
||||||
|
onChange?.(next)
|
||||||
|
},
|
||||||
|
[onChange],
|
||||||
|
)
|
||||||
|
|
||||||
|
const removeFile = useCallback(
|
||||||
|
(index: number) => {
|
||||||
|
setFiles((prev) => {
|
||||||
|
const next = prev.filter((_, i) => i !== index)
|
||||||
|
onChange?.(next)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
if (inputRef.current) inputRef.current.value = ''
|
||||||
|
},
|
||||||
|
[onChange],
|
||||||
|
)
|
||||||
|
|
||||||
|
const displayText = files.length > 0
|
||||||
|
? files.map((f) => f.name).join(', ')
|
||||||
|
: `Select file${multiple ? 's' : ''}…`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className={cn('flex flex-col gap-1.5', className)} {...props}>
|
||||||
|
<label className="text-small font-semibold text-control-label">{label}</label>
|
||||||
|
{description && <p className="text-small text-control-description">{description}</p>}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
className={cn(
|
||||||
|
'flex h-14 items-center gap-3 rounded-default border px-4 text-left transition-colors',
|
||||||
|
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-info',
|
||||||
|
error
|
||||||
|
? 'border-control-error'
|
||||||
|
: 'border-control-border hover:border-primary',
|
||||||
|
disabled && 'pointer-events-none opacity-55',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className={cn('size-6 shrink-0 [&>svg]:size-full', error ? 'text-control-error' : 'text-primary')}>
|
||||||
|
<PaperclipIcon />
|
||||||
|
</span>
|
||||||
|
<span className={cn(
|
||||||
|
'flex-1 truncate text-body',
|
||||||
|
files.length > 0 ? 'text-text' : 'text-text-secondary',
|
||||||
|
)}>
|
||||||
|
{displayText}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept={accept}
|
||||||
|
multiple={multiple}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => handleFiles(e.target.files)}
|
||||||
|
className="sr-only"
|
||||||
|
tabIndex={-1}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{files.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{files.map((file, i) => (
|
||||||
|
<span
|
||||||
|
key={`${file.name}-${i}`}
|
||||||
|
className="inline-flex items-center gap-1 rounded-default border border-control-border px-2.5 py-1 text-small text-text"
|
||||||
|
>
|
||||||
|
<span className="max-w-48 truncate">{file.name}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeFile(i)}
|
||||||
|
className="shrink-0 rounded-full p-0.5 hover:bg-text/[0.04]"
|
||||||
|
aria-label={`Remove ${file.name}`}
|
||||||
|
>
|
||||||
|
<svg className="size-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18" />
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <p className="text-small text-control-error">{error}</p>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
FileInput.displayName = 'FileInput'
|
||||||
2
src/components/atoms/FileInput/index.ts
Normal file
2
src/components/atoms/FileInput/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { FileInput } from './FileInput'
|
||||||
|
export type { FileInputProps } from './FileInput'
|
||||||
@@ -117,7 +117,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
|
|||||||
|
|
||||||
{leftIcon && (
|
{leftIcon && (
|
||||||
<span
|
<span
|
||||||
className={cn('inline-flex shrink-0 items-center justify-center text-primary-dark [&>svg]:size-full', styles.icon)}
|
className={cn('inline-flex shrink-0 items-center justify-center text-primary [&>svg]:size-full', styles.icon)}
|
||||||
>
|
>
|
||||||
{leftIcon}
|
{leftIcon}
|
||||||
</span>
|
</span>
|
||||||
@@ -144,7 +144,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
|
|||||||
|
|
||||||
{rightIcon && (
|
{rightIcon && (
|
||||||
<span
|
<span
|
||||||
className={cn('inline-flex shrink-0 items-center justify-center text-primary-dark [&>svg]:size-full', styles.icon)}
|
className={cn('inline-flex shrink-0 items-center justify-center text-primary [&>svg]:size-full', styles.icon)}
|
||||||
>
|
>
|
||||||
{rightIcon}
|
{rightIcon}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
57
src/components/atoms/List/List.stories.tsx
Normal file
57
src/components/atoms/List/List.stories.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react'
|
||||||
|
import { List, ListItem, ListSubheader, ListDivider } from './List'
|
||||||
|
|
||||||
|
const HomeIcon = () => (
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z" /></svg>
|
||||||
|
)
|
||||||
|
const StarIcon = () => (
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z" /></svg>
|
||||||
|
)
|
||||||
|
const SettingsIcon = () => (
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 00.12-.61l-1.92-3.32a.488.488 0 00-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 00-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.09.63-.09.94s.02.64.07.94l-2.03 1.58a.49.49 0 00-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6A3.6 3.6 0 1115.6 12 3.6 3.6 0 0112 15.6z" /></svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const meta: Meta<typeof List> = {
|
||||||
|
title: 'Atoms/List',
|
||||||
|
component: List,
|
||||||
|
tags: ['autodocs'],
|
||||||
|
parameters: { layout: 'padded' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof List>
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
render: () => (
|
||||||
|
<List className="w-80 rounded-default shadow-default">
|
||||||
|
<ListItem icon={<HomeIcon />}>Real-Time</ListItem>
|
||||||
|
<ListItem icon={<StarIcon />}>Audience</ListItem>
|
||||||
|
<ListItem icon={<SettingsIcon />}>Conversions</ListItem>
|
||||||
|
</List>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WithActive: Story = {
|
||||||
|
name: 'With active item',
|
||||||
|
render: () => (
|
||||||
|
<List className="w-80 rounded-default shadow-default">
|
||||||
|
<ListItem icon={<HomeIcon />} active>Real-Time</ListItem>
|
||||||
|
<ListItem icon={<StarIcon />}>Audience</ListItem>
|
||||||
|
<ListItem icon={<SettingsIcon />}>Conversions</ListItem>
|
||||||
|
</List>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WithSubheaders: Story = {
|
||||||
|
name: 'With subheaders',
|
||||||
|
render: () => (
|
||||||
|
<List className="w-80 rounded-default shadow-default">
|
||||||
|
<ListSubheader>Reports</ListSubheader>
|
||||||
|
<ListItem icon={<HomeIcon />}>Real-Time</ListItem>
|
||||||
|
<ListItem icon={<StarIcon />}>Audience</ListItem>
|
||||||
|
<ListDivider />
|
||||||
|
<ListSubheader>Settings</ListSubheader>
|
||||||
|
<ListItem icon={<SettingsIcon />}>Preferences</ListItem>
|
||||||
|
</List>
|
||||||
|
),
|
||||||
|
}
|
||||||
96
src/components/atoms/List/List.tsx
Normal file
96
src/components/atoms/List/List.tsx
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { forwardRef, type HTMLAttributes, type ReactNode } from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
// --- List ---
|
||||||
|
|
||||||
|
export interface ListProps extends HTMLAttributes<HTMLUListElement> {}
|
||||||
|
|
||||||
|
export const List = forwardRef<HTMLUListElement, ListProps>(
|
||||||
|
({ className, children, ...props }, ref) => (
|
||||||
|
<ul
|
||||||
|
ref={ref}
|
||||||
|
role="list"
|
||||||
|
className={cn('flex flex-col bg-surface', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ul>
|
||||||
|
),
|
||||||
|
)
|
||||||
|
List.displayName = 'List'
|
||||||
|
|
||||||
|
// --- ListItem ---
|
||||||
|
|
||||||
|
export interface ListItemProps extends HTMLAttributes<HTMLLIElement> {
|
||||||
|
icon?: ReactNode
|
||||||
|
active?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
href?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ListItem = forwardRef<HTMLLIElement, ListItemProps>(
|
||||||
|
({ icon, active = false, disabled = false, href, className, children, ...props }, ref) => {
|
||||||
|
const styles = cn(
|
||||||
|
'flex min-h-12 items-center gap-4 px-4 py-2 transition-colors',
|
||||||
|
active
|
||||||
|
? 'bg-info/12 text-info'
|
||||||
|
: 'text-text hover:bg-text/[0.04]',
|
||||||
|
disabled && 'pointer-events-none opacity-55',
|
||||||
|
className,
|
||||||
|
)
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<>
|
||||||
|
{icon && (
|
||||||
|
<span className={cn('size-6 shrink-0 [&>svg]:size-full', active ? 'text-info' : 'text-text-secondary')}>
|
||||||
|
{icon}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="flex-1 text-body">{children}</span>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (href) {
|
||||||
|
return (
|
||||||
|
<li ref={ref} {...props}>
|
||||||
|
<a href={href} className={styles}>{content}</a>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li ref={ref} role="listitem" className={styles} {...props}>
|
||||||
|
{content}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
ListItem.displayName = 'ListItem'
|
||||||
|
|
||||||
|
// --- ListSubheader ---
|
||||||
|
|
||||||
|
export interface ListSubheaderProps extends HTMLAttributes<HTMLLIElement> {}
|
||||||
|
|
||||||
|
export const ListSubheader = forwardRef<HTMLLIElement, ListSubheaderProps>(
|
||||||
|
({ className, children, ...props }, ref) => (
|
||||||
|
<li
|
||||||
|
ref={ref}
|
||||||
|
className={cn('flex min-h-10 items-center px-4 text-small font-semibold text-text-secondary', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</li>
|
||||||
|
),
|
||||||
|
)
|
||||||
|
ListSubheader.displayName = 'ListSubheader'
|
||||||
|
|
||||||
|
// --- ListDivider ---
|
||||||
|
|
||||||
|
export interface ListDividerProps extends HTMLAttributes<HTMLLIElement> {}
|
||||||
|
|
||||||
|
export const ListDivider = forwardRef<HTMLLIElement, ListDividerProps>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<li ref={ref} role="separator" className={cn('border-t border-border', className)} {...props} />
|
||||||
|
),
|
||||||
|
)
|
||||||
|
ListDivider.displayName = 'ListDivider'
|
||||||
2
src/components/atoms/List/index.ts
Normal file
2
src/components/atoms/List/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { List, ListItem, ListSubheader, ListDivider } from './List'
|
||||||
|
export type { ListProps, ListItemProps, ListSubheaderProps, ListDividerProps } from './List'
|
||||||
@@ -311,8 +311,8 @@ export const Select = forwardRef<HTMLDivElement, SelectProps>(
|
|||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
'cursor-pointer px-4 py-2.5 text-body text-text transition-colors',
|
'cursor-pointer px-4 py-2.5 text-body text-text transition-colors',
|
||||||
option.value === selectedValue && 'bg-primary/12 font-bold',
|
option.value === selectedValue && 'bg-info/12 font-bold',
|
||||||
index === activeIndex && option.value !== selectedValue && 'bg-primary/5',
|
index === activeIndex && option.value !== selectedValue && 'bg-info/5',
|
||||||
option.disabled && 'pointer-events-none text-text/30',
|
option.disabled && 'pointer-events-none text-text/30',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
63
src/components/atoms/Slider/Slider.stories.tsx
Normal file
63
src/components/atoms/Slider/Slider.stories.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Slider, RangeSlider } from './Slider'
|
||||||
|
|
||||||
|
const meta: Meta<typeof Slider> = {
|
||||||
|
title: 'Atoms/Slider',
|
||||||
|
component: Slider,
|
||||||
|
tags: ['autodocs'],
|
||||||
|
parameters: { layout: 'padded' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof Slider>
|
||||||
|
|
||||||
|
const SliderTemplate = () => {
|
||||||
|
const [value, setValue] = useState(40)
|
||||||
|
return (
|
||||||
|
<div className="w-80">
|
||||||
|
<Slider label="Volume" value={value} onChange={setValue} />
|
||||||
|
<p className="mt-2 text-small text-text-secondary">Value: {value}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
render: () => <SliderTemplate />,
|
||||||
|
}
|
||||||
|
|
||||||
|
const SteppedTemplate = () => {
|
||||||
|
const [value, setValue] = useState(50)
|
||||||
|
return (
|
||||||
|
<div className="w-80">
|
||||||
|
<Slider label="Brightness" value={value} onChange={setValue} min={0} max={100} step={10} />
|
||||||
|
<p className="mt-2 text-small text-text-secondary">Value: {value}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Stepped: Story = {
|
||||||
|
render: () => <SteppedTemplate />,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Disabled: Story = {
|
||||||
|
render: () => (
|
||||||
|
<div className="w-80">
|
||||||
|
<Slider label="Disabled" value={30} onChange={() => {}} disabled />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
const RangeTemplate = () => {
|
||||||
|
const [value, setValue] = useState<[number, number]>([20, 80])
|
||||||
|
return (
|
||||||
|
<div className="w-80">
|
||||||
|
<RangeSlider label="Price range" value={value} onChange={setValue} />
|
||||||
|
<p className="mt-2 text-small text-text-secondary">Range: {value[0]} – {value[1]}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Range: Story = {
|
||||||
|
render: () => <RangeTemplate />,
|
||||||
|
}
|
||||||
201
src/components/atoms/Slider/Slider.tsx
Normal file
201
src/components/atoms/Slider/Slider.tsx
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
import { forwardRef, useCallback, useRef, type HTMLAttributes } from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export interface SliderProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange'> {
|
||||||
|
label?: string
|
||||||
|
value: number
|
||||||
|
onChange: (value: number) => void
|
||||||
|
min?: number
|
||||||
|
max?: number
|
||||||
|
step?: number
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Slider = forwardRef<HTMLDivElement, SliderProps>(
|
||||||
|
({ label, value, onChange, min = 0, max = 100, step = 1, disabled = false, className, ...props }, ref) => {
|
||||||
|
const trackRef = useRef<HTMLDivElement>(null)
|
||||||
|
const percent = ((value - min) / (max - min)) * 100
|
||||||
|
|
||||||
|
const handlePointer = useCallback(
|
||||||
|
(clientX: number) => {
|
||||||
|
const track = trackRef.current
|
||||||
|
if (!track || disabled) return
|
||||||
|
const rect = track.getBoundingClientRect()
|
||||||
|
const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width))
|
||||||
|
const raw = min + ratio * (max - min)
|
||||||
|
const stepped = Math.round(raw / step) * step
|
||||||
|
onChange(Math.max(min, Math.min(max, stepped)))
|
||||||
|
},
|
||||||
|
[min, max, step, disabled, onChange],
|
||||||
|
)
|
||||||
|
|
||||||
|
const onPointerDown = (e: React.PointerEvent) => {
|
||||||
|
if (disabled) return
|
||||||
|
e.currentTarget.setPointerCapture(e.pointerId)
|
||||||
|
handlePointer(e.clientX)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onPointerMove = (e: React.PointerEvent) => {
|
||||||
|
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
|
||||||
|
handlePointer(e.clientX)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className={cn('flex flex-col gap-2', disabled && 'opacity-55', className)} {...props}>
|
||||||
|
{label && (
|
||||||
|
<label className="text-small font-semibold text-control-label">{label}</label>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
ref={trackRef}
|
||||||
|
role="slider"
|
||||||
|
aria-label={label}
|
||||||
|
aria-valuemin={min}
|
||||||
|
aria-valuemax={max}
|
||||||
|
aria-valuenow={value}
|
||||||
|
aria-disabled={disabled || undefined}
|
||||||
|
tabIndex={disabled ? -1 : 0}
|
||||||
|
className="relative flex h-10 cursor-pointer touch-none items-center"
|
||||||
|
onPointerDown={onPointerDown}
|
||||||
|
onPointerMove={onPointerMove}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (disabled) return
|
||||||
|
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault()
|
||||||
|
onChange(Math.min(max, value + step))
|
||||||
|
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault()
|
||||||
|
onChange(Math.max(min, value - step))
|
||||||
|
} else if (e.key === 'Home') {
|
||||||
|
e.preventDefault()
|
||||||
|
onChange(min)
|
||||||
|
} else if (e.key === 'End') {
|
||||||
|
e.preventDefault()
|
||||||
|
onChange(max)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="h-1 w-full rounded-full bg-grey-03">
|
||||||
|
<div className="h-full rounded-full bg-primary" style={{ width: `${percent}%` }} />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="absolute -ml-[9px] size-[18px] rounded-full bg-primary shadow-md transition-shadow focus-visible:ring-2 focus-visible:ring-control-focus-ring"
|
||||||
|
style={{ left: `${percent}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
Slider.displayName = 'Slider'
|
||||||
|
|
||||||
|
// --- RangeSlider ---
|
||||||
|
|
||||||
|
export interface RangeSliderProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange'> {
|
||||||
|
label?: string
|
||||||
|
value: [number, number]
|
||||||
|
onChange: (value: [number, number]) => void
|
||||||
|
min?: number
|
||||||
|
max?: number
|
||||||
|
step?: number
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RangeSlider = forwardRef<HTMLDivElement, RangeSliderProps>(
|
||||||
|
({ label, value, onChange, min = 0, max = 100, step = 1, disabled = false, className, ...props }, ref) => {
|
||||||
|
const trackRef = useRef<HTMLDivElement>(null)
|
||||||
|
const activeThumb = useRef<0 | 1>(0)
|
||||||
|
|
||||||
|
const toPercent = (v: number) => ((v - min) / (max - min)) * 100
|
||||||
|
const lowPct = toPercent(value[0])
|
||||||
|
const highPct = toPercent(value[1])
|
||||||
|
|
||||||
|
const snap = useCallback(
|
||||||
|
(clientX: number) => {
|
||||||
|
const track = trackRef.current
|
||||||
|
if (!track || disabled) return
|
||||||
|
const rect = track.getBoundingClientRect()
|
||||||
|
const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width))
|
||||||
|
const raw = min + ratio * (max - min)
|
||||||
|
const stepped = Math.round(raw / step) * step
|
||||||
|
const clamped = Math.max(min, Math.min(max, stepped))
|
||||||
|
|
||||||
|
const next: [number, number] = [...value]
|
||||||
|
if (activeThumb.current === 0) {
|
||||||
|
next[0] = Math.min(clamped, value[1])
|
||||||
|
} else {
|
||||||
|
next[1] = Math.max(clamped, value[0])
|
||||||
|
}
|
||||||
|
onChange(next)
|
||||||
|
},
|
||||||
|
[min, max, step, value, disabled, onChange],
|
||||||
|
)
|
||||||
|
|
||||||
|
const pickThumb = (clientX: number) => {
|
||||||
|
const track = trackRef.current
|
||||||
|
if (!track) return
|
||||||
|
const rect = track.getBoundingClientRect()
|
||||||
|
const ratio = (clientX - rect.left) / rect.width
|
||||||
|
const pos = min + ratio * (max - min)
|
||||||
|
activeThumb.current = Math.abs(pos - value[0]) <= Math.abs(pos - value[1]) ? 0 : 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className={cn('flex flex-col gap-2', disabled && 'opacity-55', className)} {...props}>
|
||||||
|
{label && (
|
||||||
|
<label className="text-small font-semibold text-control-label">{label}</label>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
ref={trackRef}
|
||||||
|
aria-label={label}
|
||||||
|
className="relative flex h-10 cursor-pointer touch-none items-center"
|
||||||
|
onPointerDown={(e) => {
|
||||||
|
if (disabled) return
|
||||||
|
e.currentTarget.setPointerCapture(e.pointerId)
|
||||||
|
pickThumb(e.clientX)
|
||||||
|
snap(e.clientX)
|
||||||
|
}}
|
||||||
|
onPointerMove={(e) => {
|
||||||
|
if (e.currentTarget.hasPointerCapture(e.pointerId)) snap(e.clientX)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="h-1 w-full rounded-full bg-grey-03">
|
||||||
|
<div
|
||||||
|
className="absolute h-1 rounded-full bg-primary"
|
||||||
|
style={{ left: `${lowPct}%`, width: `${highPct - lowPct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{[lowPct, highPct].map((pct, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
role="slider"
|
||||||
|
tabIndex={disabled ? -1 : 0}
|
||||||
|
aria-valuemin={min}
|
||||||
|
aria-valuemax={max}
|
||||||
|
aria-valuenow={value[i]}
|
||||||
|
aria-label={`${label || 'Range'} ${i === 0 ? 'minimum' : 'maximum'}`}
|
||||||
|
className="absolute -ml-[9px] size-[18px] rounded-full bg-primary shadow-md focus-visible:ring-2 focus-visible:ring-control-focus-ring"
|
||||||
|
style={{ left: `${pct}%` }}
|
||||||
|
onFocus={() => { activeThumb.current = i as 0 | 1 }}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (disabled) return
|
||||||
|
const delta = e.key === 'ArrowRight' || e.key === 'ArrowUp' ? step
|
||||||
|
: e.key === 'ArrowLeft' || e.key === 'ArrowDown' ? -step
|
||||||
|
: e.key === 'Home' ? min - value[i]
|
||||||
|
: e.key === 'End' ? max - value[i]
|
||||||
|
: 0
|
||||||
|
if (!delta) return
|
||||||
|
e.preventDefault()
|
||||||
|
const next: [number, number] = [...value]
|
||||||
|
next[i] = Math.max(min, Math.min(max, value[i] + delta))
|
||||||
|
if (next[0] > next[1]) return
|
||||||
|
onChange(next)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
RangeSlider.displayName = 'RangeSlider'
|
||||||
2
src/components/atoms/Slider/index.ts
Normal file
2
src/components/atoms/Slider/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { Slider, RangeSlider } from './Slider'
|
||||||
|
export type { SliderProps, RangeSliderProps } from './Slider'
|
||||||
@@ -40,9 +40,9 @@ export const Switch = forwardRef<HTMLButtonElement, SwitchProps>(
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={() => onChange?.(!checked)}
|
onClick={() => onChange?.(!checked)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full transition-colors duration-150',
|
'relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full transition-colors duration-150',
|
||||||
checked ? 'bg-control-checked' : 'bg-control-border',
|
checked ? 'bg-switch-on' : 'bg-control-border',
|
||||||
!disabled && checked && 'hover:bg-control-checked-hover',
|
!disabled && checked && 'hover:bg-switch-on-hover',
|
||||||
!disabled && !checked && 'hover:bg-control-border-hover',
|
!disabled && !checked && 'hover:bg-control-border-hover',
|
||||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-control-focus-ring focus-visible:ring-offset-2',
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-control-focus-ring focus-visible:ring-offset-2',
|
||||||
'active:scale-[0.97]',
|
'active:scale-[0.97]',
|
||||||
@@ -52,8 +52,8 @@ export const Switch = forwardRef<HTMLButtonElement, SwitchProps>(
|
|||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'pointer-events-none inline-block size-[18px] rounded-full bg-white shadow-default transition-transform duration-150',
|
'pointer-events-none inline-block size-3.5 rounded-full bg-white shadow-default transition-transform duration-150',
|
||||||
checked ? 'translate-x-[22px]' : 'translate-x-[3px]',
|
checked ? 'translate-x-[18px]' : 'translate-x-[2px]',
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
82
src/components/atoms/Tabs/Tabs.stories.tsx
Normal file
82
src/components/atoms/Tabs/Tabs.stories.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Tabs, TabList, Tab, TabPanel } from './Tabs'
|
||||||
|
|
||||||
|
const meta: Meta<typeof Tabs> = {
|
||||||
|
title: 'Atoms/Tabs',
|
||||||
|
component: Tabs,
|
||||||
|
tags: ['autodocs'],
|
||||||
|
parameters: { layout: 'padded' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof Tabs>
|
||||||
|
|
||||||
|
const BasicTemplate = () => {
|
||||||
|
const [value, setValue] = useState('tab1')
|
||||||
|
return (
|
||||||
|
<Tabs value={value} onChange={setValue}>
|
||||||
|
<TabList>
|
||||||
|
<Tab value="tab1">Overview</Tab>
|
||||||
|
<Tab value="tab2">Details</Tab>
|
||||||
|
<Tab value="tab3">History</Tab>
|
||||||
|
</TabList>
|
||||||
|
<TabPanel value="tab1">Overview content goes here.</TabPanel>
|
||||||
|
<TabPanel value="tab2">Details content goes here.</TabPanel>
|
||||||
|
<TabPanel value="tab3">History content goes here.</TabPanel>
|
||||||
|
</Tabs>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
render: () => <BasicTemplate />,
|
||||||
|
}
|
||||||
|
|
||||||
|
const WithIconsTemplate = () => {
|
||||||
|
const [value, setValue] = useState('status')
|
||||||
|
|
||||||
|
const StatusIcon = () => (
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" /></svg>
|
||||||
|
)
|
||||||
|
const DetailsIcon = () => (
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z" /></svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tabs value={value} onChange={setValue}>
|
||||||
|
<TabList>
|
||||||
|
<Tab value="status" icon={<StatusIcon />}>Status</Tab>
|
||||||
|
<Tab value="details" icon={<DetailsIcon />}>Details</Tab>
|
||||||
|
<Tab value="disabled" disabled>Disabled</Tab>
|
||||||
|
</TabList>
|
||||||
|
<TabPanel value="status">Status panel content.</TabPanel>
|
||||||
|
<TabPanel value="details">Details panel content.</TabPanel>
|
||||||
|
</Tabs>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WithIcons: Story = {
|
||||||
|
name: 'With icons',
|
||||||
|
render: () => <WithIconsTemplate />,
|
||||||
|
}
|
||||||
|
|
||||||
|
const ManyTabsTemplate = () => {
|
||||||
|
const [value, setValue] = useState('tab1')
|
||||||
|
return (
|
||||||
|
<Tabs value={value} onChange={setValue}>
|
||||||
|
<TabList>
|
||||||
|
{Array.from({ length: 8 }, (_, i) => (
|
||||||
|
<Tab key={i} value={`tab${i + 1}`}>Tab {i + 1}</Tab>
|
||||||
|
))}
|
||||||
|
</TabList>
|
||||||
|
{Array.from({ length: 8 }, (_, i) => (
|
||||||
|
<TabPanel key={i} value={`tab${i + 1}`}>Content for tab {i + 1}</TabPanel>
|
||||||
|
))}
|
||||||
|
</Tabs>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ManyTabs: Story = {
|
||||||
|
name: 'Many tabs',
|
||||||
|
render: () => <ManyTabsTemplate />,
|
||||||
|
}
|
||||||
141
src/components/atoms/Tabs/Tabs.tsx
Normal file
141
src/components/atoms/Tabs/Tabs.tsx
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
forwardRef,
|
||||||
|
useContext,
|
||||||
|
useId,
|
||||||
|
useMemo,
|
||||||
|
type HTMLAttributes,
|
||||||
|
type ReactNode,
|
||||||
|
} from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
// --- Context ---
|
||||||
|
|
||||||
|
interface TabsContextValue {
|
||||||
|
value: string
|
||||||
|
onChange: (value: string) => void
|
||||||
|
baseId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const TabsContext = createContext<TabsContextValue | null>(null)
|
||||||
|
|
||||||
|
function useTabsContext() {
|
||||||
|
const ctx = useContext(TabsContext)
|
||||||
|
if (!ctx) throw new Error('Tab components must be used within Tabs')
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Tabs ---
|
||||||
|
|
||||||
|
export interface TabsProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange'> {
|
||||||
|
value: string
|
||||||
|
onChange: (value: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Tabs = forwardRef<HTMLDivElement, TabsProps>(
|
||||||
|
({ value, onChange, className, children, ...props }, ref) => {
|
||||||
|
const baseId = useId()
|
||||||
|
const ctx = useMemo(() => ({ value, onChange, baseId }), [value, onChange, baseId])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TabsContext.Provider value={ctx}>
|
||||||
|
<div ref={ref} className={className} {...props}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</TabsContext.Provider>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
Tabs.displayName = 'Tabs'
|
||||||
|
|
||||||
|
// --- TabList ---
|
||||||
|
|
||||||
|
export interface TabListProps extends HTMLAttributes<HTMLDivElement> {}
|
||||||
|
|
||||||
|
export const TabList = forwardRef<HTMLDivElement, TabListProps>(
|
||||||
|
({ className, children, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
role="tablist"
|
||||||
|
className={cn('flex border-b border-border', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
)
|
||||||
|
TabList.displayName = 'TabList'
|
||||||
|
|
||||||
|
// --- Tab ---
|
||||||
|
|
||||||
|
export interface TabProps extends HTMLAttributes<HTMLButtonElement> {
|
||||||
|
value: string
|
||||||
|
icon?: ReactNode
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Tab = forwardRef<HTMLButtonElement, TabProps>(
|
||||||
|
({ value, icon, disabled = false, className, children, ...props }, ref) => {
|
||||||
|
const { value: selected, onChange, baseId } = useTabsContext()
|
||||||
|
const isSelected = value === selected
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
ref={ref}
|
||||||
|
role="tab"
|
||||||
|
type="button"
|
||||||
|
id={`${baseId}-tab-${value}`}
|
||||||
|
aria-selected={isSelected}
|
||||||
|
aria-controls={`${baseId}-panel-${value}`}
|
||||||
|
tabIndex={isSelected ? 0 : -1}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => onChange(value)}
|
||||||
|
className={cn(
|
||||||
|
'relative flex items-center gap-2 px-4 py-3 text-body font-semibold transition-colors',
|
||||||
|
'focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-info',
|
||||||
|
isSelected
|
||||||
|
? 'text-primary'
|
||||||
|
: 'text-text/80 hover:text-text',
|
||||||
|
isSelected && 'after:absolute after:bottom-0 after:left-0 after:right-0 after:h-1 after:bg-error',
|
||||||
|
disabled && 'pointer-events-none opacity-55',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{icon && <span className="size-5 shrink-0 [&>svg]:size-full">{icon}</span>}
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
Tab.displayName = 'Tab'
|
||||||
|
|
||||||
|
// --- TabPanel ---
|
||||||
|
|
||||||
|
export interface TabPanelProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TabPanel = forwardRef<HTMLDivElement, TabPanelProps>(
|
||||||
|
({ value, className, children, ...props }, ref) => {
|
||||||
|
const { value: selected, baseId } = useTabsContext()
|
||||||
|
const isSelected = value === selected
|
||||||
|
|
||||||
|
if (!isSelected) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
role="tabpanel"
|
||||||
|
id={`${baseId}-panel-${value}`}
|
||||||
|
aria-labelledby={`${baseId}-tab-${value}`}
|
||||||
|
tabIndex={0}
|
||||||
|
className={cn('pt-4', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
TabPanel.displayName = 'TabPanel'
|
||||||
2
src/components/atoms/Tabs/index.ts
Normal file
2
src/components/atoms/Tabs/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { Tabs, TabList, Tab, TabPanel } from './Tabs'
|
||||||
|
export type { TabsProps, TabListProps, TabProps, TabPanelProps } from './Tabs'
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import { forwardRef, type HTMLAttributes, type ReactNode } from 'react'
|
import { forwardRef, type HTMLAttributes, type ReactNode } from 'react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
export type TagColor = 'navy' | 'blue' | 'green' | 'red' | 'orange' | 'grey'
|
export type TagColor =
|
||||||
|
| 'navy' | 'blue' | 'green' | 'red' | 'orange' | 'grey'
|
||||||
|
| 'teal' | 'brown' | 'purple' | 'fuchsia' | 'yellow'
|
||||||
|
|
||||||
export interface TagProps extends HTMLAttributes<HTMLSpanElement> {
|
export interface TagProps extends HTMLAttributes<HTMLSpanElement> {
|
||||||
variant?: 'outline' | 'filled' | 'light'
|
variant?: 'outline' | 'filled' | 'light'
|
||||||
@@ -42,6 +44,31 @@ const colorVariantStyles: Record<TagColor, Record<string, string>> = {
|
|||||||
filled: 'bg-tag-grey text-white',
|
filled: 'bg-tag-grey text-white',
|
||||||
light: 'bg-tag-grey-light text-tag-grey',
|
light: 'bg-tag-grey-light text-tag-grey',
|
||||||
},
|
},
|
||||||
|
teal: {
|
||||||
|
outline: 'border border-tag-teal text-tag-teal',
|
||||||
|
filled: 'bg-tag-teal text-white',
|
||||||
|
light: 'bg-tag-teal-light text-tag-teal',
|
||||||
|
},
|
||||||
|
brown: {
|
||||||
|
outline: 'border border-tag-brown text-tag-brown',
|
||||||
|
filled: 'bg-tag-brown text-white',
|
||||||
|
light: 'bg-tag-brown-light text-tag-brown',
|
||||||
|
},
|
||||||
|
purple: {
|
||||||
|
outline: 'border border-tag-purple text-tag-purple',
|
||||||
|
filled: 'bg-tag-purple text-white',
|
||||||
|
light: 'bg-tag-purple-light text-tag-purple',
|
||||||
|
},
|
||||||
|
fuchsia: {
|
||||||
|
outline: 'border border-tag-fuchsia text-tag-fuchsia',
|
||||||
|
filled: 'bg-tag-fuchsia text-white',
|
||||||
|
light: 'bg-tag-fuchsia-light text-tag-fuchsia',
|
||||||
|
},
|
||||||
|
yellow: {
|
||||||
|
outline: 'border border-tag-yellow text-tag-yellow',
|
||||||
|
filled: 'bg-tag-yellow text-white',
|
||||||
|
light: 'bg-tag-yellow-light text-tag-yellow',
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
const sizeStyles: Record<string, string> = {
|
const sizeStyles: Record<string, string> = {
|
||||||
|
|||||||
@@ -39,8 +39,8 @@ export const Default: Story = {
|
|||||||
<AccordionItem value="item-1">
|
<AccordionItem value="item-1">
|
||||||
<AccordionTrigger>What is this design system?</AccordionTrigger>
|
<AccordionTrigger>What is this design system?</AccordionTrigger>
|
||||||
<AccordionContent>
|
<AccordionContent>
|
||||||
A React component library built for the Research Synthesiser, following NSW Design System
|
A React component library implementing the ADS 3.0 design language with custom tokens
|
||||||
patterns with custom tokens and Tailwind CSS v4.
|
and Tailwind CSS v4.
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
<AccordionItem value="item-2">
|
<AccordionItem value="item-2">
|
||||||
|
|||||||
@@ -189,8 +189,8 @@ export const AccordionTrigger = forwardRef<HTMLButtonElement, AccordionTriggerPr
|
|||||||
onClick={() => toggle(value)}
|
onClick={() => toggle(value)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex h-16 w-full items-center gap-3 px-6 text-left font-bold text-text transition-colors',
|
'flex h-16 w-full items-center gap-3 px-6 text-left font-bold text-text transition-colors',
|
||||||
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary',
|
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-info',
|
||||||
isOpen ? 'bg-primary/12' : 'bg-surface hover:bg-primary/5',
|
isOpen ? 'bg-info/12' : 'bg-surface hover:bg-info/5',
|
||||||
disabled && 'pointer-events-none opacity-50',
|
disabled && 'pointer-events-none opacity-50',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const variantStyles: Record<string, string> = {
|
|||||||
surface: 'bg-surface border border-border shadow-default',
|
surface: 'bg-surface border border-border shadow-default',
|
||||||
outlined: 'bg-surface border border-border',
|
outlined: 'bg-surface border border-border',
|
||||||
elevated: 'bg-surface shadow-md',
|
elevated: 'bg-surface shadow-md',
|
||||||
filled: 'bg-primary-dark text-white',
|
filled: 'bg-primary text-white',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Card = forwardRef<HTMLDivElement, CardProps>(
|
export const Card = forwardRef<HTMLDivElement, CardProps>(
|
||||||
|
|||||||
79
src/components/molecules/DataTable/DataTable.stories.tsx
Normal file
79
src/components/molecules/DataTable/DataTable.stories.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react'
|
||||||
|
import { DataTable, type DataTableColumn } from './DataTable'
|
||||||
|
|
||||||
|
type Dessert = {
|
||||||
|
name: string
|
||||||
|
calories: number
|
||||||
|
fat: number
|
||||||
|
carbs: number
|
||||||
|
protein: number
|
||||||
|
iron: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const desserts: Dessert[] = [
|
||||||
|
{ name: 'Frozen Yogurt', calories: 159, fat: 6, carbs: 24, protein: 4, iron: 1 },
|
||||||
|
{ name: 'Ice cream sandwich', calories: 237, fat: 9, carbs: 37, protein: 4.3, iron: 1 },
|
||||||
|
{ name: 'Eclair', calories: 262, fat: 16, carbs: 23, protein: 6, iron: 7 },
|
||||||
|
{ name: 'Cupcake', calories: 305, fat: 3.7, carbs: 67, protein: 4.3, iron: 8 },
|
||||||
|
{ name: 'Gingerbread', calories: 356, fat: 16, carbs: 49, protein: 3.9, iron: 16 },
|
||||||
|
{ name: 'Jelly bean', calories: 375, fat: 0, carbs: 94, protein: 0, iron: 0 },
|
||||||
|
{ name: 'Lollipop', calories: 392, fat: 0.2, carbs: 98, protein: 0, iron: 2 },
|
||||||
|
{ name: 'Honeycomb', calories: 408, fat: 3.2, carbs: 87, protein: 6.5, iron: 45 },
|
||||||
|
{ name: 'Donut', calories: 452, fat: 25, carbs: 51, protein: 4.9, iron: 22 },
|
||||||
|
{ name: 'KitKat', calories: 518, fat: 26, carbs: 65, protein: 7, iron: 6 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const columns: DataTableColumn<Dessert>[] = [
|
||||||
|
{ key: 'name', header: 'Dessert (100g serving)', sortable: true },
|
||||||
|
{ key: 'calories', header: 'Calories', sortable: true, align: 'right' },
|
||||||
|
{ key: 'fat', header: 'Fat (g)', sortable: true, align: 'right' },
|
||||||
|
{ key: 'carbs', header: 'Carbs (g)', align: 'right' },
|
||||||
|
{ key: 'protein', header: 'Protein (g)', align: 'right' },
|
||||||
|
{ key: 'iron', header: 'Iron (%)', align: 'right' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const meta: Meta = {
|
||||||
|
title: 'Molecules/DataTable',
|
||||||
|
tags: ['autodocs'],
|
||||||
|
parameters: { layout: 'padded' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
render: () => (
|
||||||
|
<DataTable columns={columns} data={desserts} />
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WithSelection: Story = {
|
||||||
|
name: 'With selection',
|
||||||
|
render: () => (
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={desserts}
|
||||||
|
selectable
|
||||||
|
onSelectionChange={(sel) => console.log('Selected:', sel)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CustomPageSize: Story = {
|
||||||
|
name: 'Custom page size',
|
||||||
|
render: () => (
|
||||||
|
<DataTable columns={columns} data={desserts} pageSize={3} pageSizeOptions={[3, 5, 10]} />
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Empty: Story = {
|
||||||
|
render: () => (
|
||||||
|
<DataTable columns={columns} data={[]} emptyMessage="No desserts found" />
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Loading: Story = {
|
||||||
|
render: () => (
|
||||||
|
<DataTable columns={columns} data={[]} loading />
|
||||||
|
),
|
||||||
|
}
|
||||||
282
src/components/molecules/DataTable/DataTable.tsx
Normal file
282
src/components/molecules/DataTable/DataTable.tsx
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
import {
|
||||||
|
forwardRef,
|
||||||
|
useCallback,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
type HTMLAttributes,
|
||||||
|
type ReactNode,
|
||||||
|
} from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
// --- Types ---
|
||||||
|
|
||||||
|
export interface DataTableColumn<T = Record<string, unknown>> {
|
||||||
|
key: string
|
||||||
|
header: string
|
||||||
|
sortable?: boolean
|
||||||
|
align?: 'left' | 'center' | 'right'
|
||||||
|
render?: (value: unknown, row: T, index: number) => ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DataTableProps<T = Record<string, unknown>> extends Omit<HTMLAttributes<HTMLDivElement>, 'children'> {
|
||||||
|
columns: DataTableColumn<T>[]
|
||||||
|
data: T[]
|
||||||
|
selectable?: boolean
|
||||||
|
pagination?: boolean
|
||||||
|
pageSize?: number
|
||||||
|
pageSizeOptions?: number[]
|
||||||
|
loading?: boolean
|
||||||
|
emptyMessage?: string
|
||||||
|
onSelectionChange?: (selected: T[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type SortState = { key: string; dir: 'asc' | 'desc' } | null
|
||||||
|
|
||||||
|
const ChevronUpIcon = () => (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" className="size-4">
|
||||||
|
<path d="m18 15-6-6-6 6" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const ChevronDownIcon = () => (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" className="size-4">
|
||||||
|
<path d="m6 9 6 6 6-6" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- DataTable ---
|
||||||
|
|
||||||
|
function DataTableInner<T extends Record<string, unknown>>(
|
||||||
|
{
|
||||||
|
columns,
|
||||||
|
data,
|
||||||
|
selectable = false,
|
||||||
|
pagination = true,
|
||||||
|
pageSize: initialPageSize = 5,
|
||||||
|
pageSizeOptions = [5, 10, 25],
|
||||||
|
loading = false,
|
||||||
|
emptyMessage = 'No data available',
|
||||||
|
onSelectionChange,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: DataTableProps<T>,
|
||||||
|
ref: React.ForwardedRef<HTMLDivElement>,
|
||||||
|
) {
|
||||||
|
const [sort, setSort] = useState<SortState>(null)
|
||||||
|
const [page, setPage] = useState(0)
|
||||||
|
const [pageSize, setPageSize] = useState(initialPageSize)
|
||||||
|
const [selected, setSelected] = useState<Set<number>>(new Set())
|
||||||
|
|
||||||
|
const sortedData = useMemo(() => {
|
||||||
|
if (!sort) return data
|
||||||
|
const { key, dir } = sort
|
||||||
|
return [...data].sort((a, b) => {
|
||||||
|
const va = a[key]
|
||||||
|
const vb = b[key]
|
||||||
|
if (va == null && vb == null) return 0
|
||||||
|
if (va == null) return 1
|
||||||
|
if (vb == null) return -1
|
||||||
|
const cmp = String(va).localeCompare(String(vb), undefined, { numeric: true })
|
||||||
|
return dir === 'asc' ? cmp : -cmp
|
||||||
|
})
|
||||||
|
}, [data, sort])
|
||||||
|
|
||||||
|
const pageCount = pagination ? Math.max(1, Math.ceil(sortedData.length / pageSize)) : 1
|
||||||
|
const pageData = pagination ? sortedData.slice(page * pageSize, (page + 1) * pageSize) : sortedData
|
||||||
|
const rangeStart = page * pageSize + 1
|
||||||
|
const rangeEnd = Math.min((page + 1) * pageSize, sortedData.length)
|
||||||
|
|
||||||
|
const toggleSort = useCallback((key: string) => {
|
||||||
|
setSort((prev) => {
|
||||||
|
if (prev?.key === key) {
|
||||||
|
return prev.dir === 'asc' ? { key, dir: 'desc' } : null
|
||||||
|
}
|
||||||
|
return { key, dir: 'asc' }
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const toggleRow = useCallback(
|
||||||
|
(index: number) => {
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(index)) next.delete(index)
|
||||||
|
else next.add(index)
|
||||||
|
onSelectionChange?.(
|
||||||
|
[...next].map((i) => sortedData[i]).filter(Boolean),
|
||||||
|
)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[sortedData, onSelectionChange],
|
||||||
|
)
|
||||||
|
|
||||||
|
const toggleAll = useCallback(() => {
|
||||||
|
setSelected((prev) => {
|
||||||
|
if (prev.size === sortedData.length) {
|
||||||
|
onSelectionChange?.([])
|
||||||
|
return new Set()
|
||||||
|
}
|
||||||
|
const all = new Set(sortedData.map((_, i) => i))
|
||||||
|
onSelectionChange?.([...sortedData])
|
||||||
|
return all
|
||||||
|
})
|
||||||
|
}, [sortedData, onSelectionChange])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className={cn('overflow-hidden rounded-default bg-surface', className)} {...props}>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-left text-body">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border">
|
||||||
|
{selectable && (
|
||||||
|
<th className="w-12 px-4 py-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selected.size === sortedData.length && sortedData.length > 0}
|
||||||
|
onChange={toggleAll}
|
||||||
|
className="accent-primary"
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
)}
|
||||||
|
{columns.map((col) => (
|
||||||
|
<th
|
||||||
|
key={col.key}
|
||||||
|
className={cn(
|
||||||
|
'px-4 py-3 font-normal text-primary',
|
||||||
|
col.sortable && 'cursor-pointer select-none hover:bg-text/[0.04]',
|
||||||
|
col.align === 'right' && 'text-right',
|
||||||
|
col.align === 'center' && 'text-center',
|
||||||
|
)}
|
||||||
|
onClick={col.sortable ? () => toggleSort(col.key) : undefined}
|
||||||
|
>
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
{col.header}
|
||||||
|
{col.sortable && sort?.key === col.key && (
|
||||||
|
sort.dir === 'asc' ? <ChevronUpIcon /> : <ChevronDownIcon />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{loading ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={columns.length + (selectable ? 1 : 0)} className="px-4 py-8 text-center text-text-secondary">
|
||||||
|
Loading…
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : pageData.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={columns.length + (selectable ? 1 : 0)} className="px-4 py-8 text-center text-text-secondary">
|
||||||
|
{emptyMessage}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
pageData.map((row, rowIdx) => {
|
||||||
|
const globalIdx = page * pageSize + rowIdx
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={globalIdx}
|
||||||
|
className={cn(
|
||||||
|
'border-b border-border last:border-b-0 transition-colors',
|
||||||
|
selected.has(globalIdx) ? 'bg-info/5' : 'hover:bg-text/[0.02]',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{selectable && (
|
||||||
|
<td className="w-12 px-4 py-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selected.has(globalIdx)}
|
||||||
|
onChange={() => toggleRow(globalIdx)}
|
||||||
|
className="accent-primary"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
{columns.map((col) => (
|
||||||
|
<td
|
||||||
|
key={col.key}
|
||||||
|
className={cn(
|
||||||
|
'px-4 py-3',
|
||||||
|
col.align === 'right' && 'text-right',
|
||||||
|
col.align === 'center' && 'text-center',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{col.render
|
||||||
|
? col.render(row[col.key], row, globalIdx)
|
||||||
|
: String(row[col.key] ?? '')}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{pagination && sortedData.length > 0 && (
|
||||||
|
<div className="flex items-center justify-end gap-4 border-t border-border px-4 py-2 text-small text-text-secondary">
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
Rows per page:
|
||||||
|
<select
|
||||||
|
value={pageSize}
|
||||||
|
onChange={(e) => {
|
||||||
|
setPageSize(Number(e.target.value))
|
||||||
|
setPage(0)
|
||||||
|
}}
|
||||||
|
className="rounded-sm border border-border bg-surface px-2 py-1 text-small text-text"
|
||||||
|
>
|
||||||
|
{pageSizeOptions.map((opt) => (
|
||||||
|
<option key={opt} value={opt}>{opt}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<span>{rangeStart}-{rangeEnd} of {sortedData.length}</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={page === 0}
|
||||||
|
onClick={() => setPage(0)}
|
||||||
|
className="rounded-sm p-1 hover:bg-text/[0.04] disabled:opacity-40"
|
||||||
|
aria-label="First page"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor" className="size-5"><path d="M18.41 16.59L13.82 12l4.59-4.59L17 6l-6 6 6 6zM6 6h2v12H6z" /></svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={page === 0}
|
||||||
|
onClick={() => setPage((p) => p - 1)}
|
||||||
|
className="rounded-sm p-1 hover:bg-text/[0.04] disabled:opacity-40"
|
||||||
|
aria-label="Previous page"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor" className="size-5"><path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z" /></svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={page >= pageCount - 1}
|
||||||
|
onClick={() => setPage((p) => p + 1)}
|
||||||
|
className="rounded-sm p-1 hover:bg-text/[0.04] disabled:opacity-40"
|
||||||
|
aria-label="Next page"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor" className="size-5"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z" /></svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={page >= pageCount - 1}
|
||||||
|
onClick={() => setPage(pageCount - 1)}
|
||||||
|
className="rounded-sm p-1 hover:bg-text/[0.04] disabled:opacity-40"
|
||||||
|
aria-label="Last page"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor" className="size-5"><path d="M5.59 7.41L10.18 12l-4.59 4.59L7 18l6-6-6-6zM16 6h2v12h-2z" /></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DataTable = forwardRef(DataTableInner) as <T extends Record<string, unknown>>(
|
||||||
|
props: DataTableProps<T> & { ref?: React.Ref<HTMLDivElement> },
|
||||||
|
) => React.ReactElement | null
|
||||||
2
src/components/molecules/DataTable/index.ts
Normal file
2
src/components/molecules/DataTable/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { DataTable } from './DataTable'
|
||||||
|
export type { DataTableProps, DataTableColumn } from './DataTable'
|
||||||
@@ -101,7 +101,7 @@ export const DialogHeader = forwardRef<HTMLDivElement, DialogHeaderProps>(
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="shrink-0 rounded-full p-1.5 text-text-secondary transition-colors hover:bg-primary/5 hover:text-text focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
|
className="shrink-0 rounded-full p-1.5 text-text-secondary transition-colors hover:bg-info/5 hover:text-text focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-info"
|
||||||
aria-label="Close dialog"
|
aria-label="Close dialog"
|
||||||
>
|
>
|
||||||
<svg className="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
<svg className="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
|||||||
97
src/components/organisms/PageHeader/PageHeader.stories.tsx
Normal file
97
src/components/organisms/PageHeader/PageHeader.stories.tsx
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react'
|
||||||
|
import { PageHeader } from './PageHeader'
|
||||||
|
|
||||||
|
const GridIcon = () => (
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M3 3h8v8H3zm0 10h8v8H3zm10-10h8v8h-8zm0 10h8v8h-8z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const BookIcon = () => (
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M18 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zM6 4h5v8l-2.5-1.5L6 12V4z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const meta: Meta<typeof PageHeader> = {
|
||||||
|
title: 'Organisms/PageHeader',
|
||||||
|
component: PageHeader,
|
||||||
|
tags: ['autodocs'],
|
||||||
|
parameters: { layout: 'fullscreen' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof PageHeader>
|
||||||
|
|
||||||
|
export const Light: Story = {
|
||||||
|
render: () => (
|
||||||
|
<PageHeader
|
||||||
|
title="Resources"
|
||||||
|
subtitle="Essential resources for my work"
|
||||||
|
icon={<GridIcon />}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Dark: Story = {
|
||||||
|
render: () => (
|
||||||
|
<PageHeader
|
||||||
|
title="Resources"
|
||||||
|
subtitle="Essential resources for my work"
|
||||||
|
icon={<GridIcon />}
|
||||||
|
theme="dark"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NoIcon: Story = {
|
||||||
|
name: 'No icon',
|
||||||
|
render: () => (
|
||||||
|
<PageHeader
|
||||||
|
title="My Documents"
|
||||||
|
subtitle="View and manage your uploaded documents"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Centered: Story = {
|
||||||
|
render: () => (
|
||||||
|
<PageHeader
|
||||||
|
title="Welcome to your PDP"
|
||||||
|
subtitle="Performance and Development Plan portal"
|
||||||
|
icon={<BookIcon />}
|
||||||
|
centered
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NoBackground: Story = {
|
||||||
|
name: 'No background',
|
||||||
|
render: () => (
|
||||||
|
<PageHeader
|
||||||
|
title="Settings"
|
||||||
|
subtitle="Manage your account preferences"
|
||||||
|
noBackground
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WithContent: Story = {
|
||||||
|
name: 'With content slot',
|
||||||
|
render: () => (
|
||||||
|
<PageHeader
|
||||||
|
title="Resources"
|
||||||
|
subtitle="Essential resources for my work"
|
||||||
|
icon={<GridIcon />}
|
||||||
|
>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button className="rounded-full bg-primary px-5 py-2 text-small font-semibold text-white">
|
||||||
|
Browse all
|
||||||
|
</button>
|
||||||
|
<button className="rounded-full border-2 border-primary px-5 py-2 text-small font-semibold text-primary">
|
||||||
|
My favourites
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</PageHeader>
|
||||||
|
),
|
||||||
|
}
|
||||||
91
src/components/organisms/PageHeader/PageHeader.tsx
Normal file
91
src/components/organisms/PageHeader/PageHeader.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { forwardRef, type HTMLAttributes, type ReactNode } from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export interface PageHeaderProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
|
title: string
|
||||||
|
subtitle?: string
|
||||||
|
icon?: ReactNode
|
||||||
|
iconSize?: string
|
||||||
|
theme?: 'light' | 'dark'
|
||||||
|
centered?: boolean
|
||||||
|
noBackground?: boolean
|
||||||
|
children?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
function DecoArcs({ isDark }: { isDark: boolean }) {
|
||||||
|
const stroke = isDark ? 'rgba(20, 108, 253, 0.25)' : 'rgba(0, 38, 100, 0.12)'
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className="pointer-events-none absolute right-0 top-0 h-full w-1/2"
|
||||||
|
viewBox="0 0 400 200"
|
||||||
|
preserveAspectRatio="xMaxYMid slice"
|
||||||
|
fill="none"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<circle cx="350" cy="100" r="160" stroke={stroke} strokeWidth="30" />
|
||||||
|
<circle cx="350" cy="100" r="100" stroke={stroke} strokeWidth="20" />
|
||||||
|
<circle cx="350" cy="100" r="50" stroke={stroke} strokeWidth="12" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PageHeader = forwardRef<HTMLDivElement, PageHeaderProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
icon,
|
||||||
|
iconSize = '50px',
|
||||||
|
theme = 'light',
|
||||||
|
centered = false,
|
||||||
|
noBackground = false,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) => {
|
||||||
|
const isDark = theme === 'dark'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative overflow-hidden px-9 py-11',
|
||||||
|
!noBackground && (isDark ? 'bg-primary text-white' : 'bg-secondary text-primary'),
|
||||||
|
noBackground && 'text-text',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{!noBackground && <DecoArcs isDark={isDark} />}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'relative z-10 flex gap-5',
|
||||||
|
centered ? 'flex-col items-center text-center' : 'items-start',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{icon && (
|
||||||
|
<span
|
||||||
|
className="shrink-0 [&>svg]:size-full"
|
||||||
|
style={{ width: iconSize, height: iconSize }}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<h1 className="text-h2-responsive">{title}</h1>
|
||||||
|
{subtitle && (
|
||||||
|
<p className={cn('mt-1 text-body', isDark ? 'text-white/80' : 'text-text-secondary')}>
|
||||||
|
{subtitle}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{children && <div className="mt-4">{children}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
PageHeader.displayName = 'PageHeader'
|
||||||
2
src/components/organisms/PageHeader/index.ts
Normal file
2
src/components/organisms/PageHeader/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { PageHeader } from './PageHeader'
|
||||||
|
export type { PageHeaderProps } from './PageHeader'
|
||||||
314
src/components/organisms/SideNav/SideNav.stories.tsx
Normal file
314
src/components/organisms/SideNav/SideNav.stories.tsx
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { SideNav, SideNavItem, SideNavGroup, SideNavDivider } from './SideNav'
|
||||||
|
|
||||||
|
const HomeIcon = () => (
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const PersonIcon = () => (
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const GridIcon = () => (
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M3 3h8v8H3zm0 10h8v8H3zm10-10h8v8h-8zm0 10h8v8h-8z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const BookIcon = () => (
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M18 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zM6 4h5v8l-2.5-1.5L6 12V4z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const FolderIcon = () => (
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M10 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const SchoolIcon = () => (
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M5 13.18v4L12 21l7-3.82v-4L12 17l-7-3.82zM12 3 1 9l11 6 9-4.91V17h2V9L12 3z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const SupportIcon = () => (
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 17h-2v-2h2v2zm2.07-7.75-.9.92C13.45 12.9 13 13.5 13 15h-2v-.5c0-1.1.45-2.1 1.17-2.83l1.24-1.26c.37-.36.59-.86.59-1.41 0-1.1-.9-2-2-2s-2 .9-2 2H8c0-2.21 1.79-4 4-4s4 1.79 4 4c0 .88-.36 1.68-.93 2.25z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const meta: Meta<typeof SideNav> = {
|
||||||
|
title: 'Organisms/SideNav',
|
||||||
|
component: SideNav,
|
||||||
|
tags: ['autodocs'],
|
||||||
|
parameters: {
|
||||||
|
layout: 'centered',
|
||||||
|
},
|
||||||
|
decorators: [
|
||||||
|
(Story) => (
|
||||||
|
<div className="h-[600px] border border-border rounded-lg overflow-hidden">
|
||||||
|
<Story />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof SideNav>
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
render: () => (
|
||||||
|
<SideNav>
|
||||||
|
<SideNavItem icon={<HomeIcon />} href="#" active>
|
||||||
|
My status
|
||||||
|
</SideNavItem>
|
||||||
|
<SideNavItem icon={<PersonIcon />} href="#">
|
||||||
|
My details
|
||||||
|
</SideNavItem>
|
||||||
|
<SideNavItem icon={<GridIcon />} href="#">
|
||||||
|
Workspace
|
||||||
|
</SideNavItem>
|
||||||
|
<SideNavItem icon={<BookIcon />} href="#">
|
||||||
|
Resources
|
||||||
|
</SideNavItem>
|
||||||
|
<SideNavItem icon={<FolderIcon />} href="#">
|
||||||
|
My documents & links
|
||||||
|
</SideNavItem>
|
||||||
|
<SideNavDivider />
|
||||||
|
<SideNavGroup icon={<SchoolIcon />} label="PDP" defaultOpen>
|
||||||
|
<SideNavItem href="#">My PDP</SideNavItem>
|
||||||
|
<SideNavItem href="#">PDP guide</SideNavItem>
|
||||||
|
<SideNavItem href="#">Management</SideNavItem>
|
||||||
|
<SideNavItem href="#">Useful links</SideNavItem>
|
||||||
|
</SideNavGroup>
|
||||||
|
<SideNavDivider />
|
||||||
|
<SideNavItem icon={<SupportIcon />} href="#">
|
||||||
|
Support
|
||||||
|
</SideNavItem>
|
||||||
|
</SideNav>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WithActiveNested: Story = {
|
||||||
|
name: 'Active nested item',
|
||||||
|
render: () => (
|
||||||
|
<SideNav>
|
||||||
|
<SideNavItem icon={<HomeIcon />} href="#">
|
||||||
|
My status
|
||||||
|
</SideNavItem>
|
||||||
|
<SideNavItem icon={<PersonIcon />} href="#">
|
||||||
|
My details
|
||||||
|
</SideNavItem>
|
||||||
|
<SideNavItem icon={<GridIcon />} href="#">
|
||||||
|
Workspace
|
||||||
|
</SideNavItem>
|
||||||
|
<SideNavItem icon={<BookIcon />} href="#">
|
||||||
|
Resources
|
||||||
|
</SideNavItem>
|
||||||
|
<SideNavItem icon={<FolderIcon />} href="#">
|
||||||
|
My documents & links
|
||||||
|
</SideNavItem>
|
||||||
|
<SideNavDivider />
|
||||||
|
<SideNavGroup icon={<SchoolIcon />} label="PDP" defaultOpen>
|
||||||
|
<SideNavItem href="#" active>
|
||||||
|
My PDP
|
||||||
|
</SideNavItem>
|
||||||
|
<SideNavItem href="#">PDP guide</SideNavItem>
|
||||||
|
<SideNavItem href="#">Management</SideNavItem>
|
||||||
|
<SideNavItem href="#">Useful links</SideNavItem>
|
||||||
|
</SideNavGroup>
|
||||||
|
<SideNavDivider />
|
||||||
|
<SideNavItem icon={<SupportIcon />} href="#">
|
||||||
|
Support
|
||||||
|
</SideNavItem>
|
||||||
|
</SideNav>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Collapsed: Story = {
|
||||||
|
render: () => (
|
||||||
|
<SideNav collapsed>
|
||||||
|
<SideNavItem icon={<HomeIcon />} href="#" active>
|
||||||
|
My status
|
||||||
|
</SideNavItem>
|
||||||
|
<SideNavItem icon={<PersonIcon />} href="#">
|
||||||
|
My details
|
||||||
|
</SideNavItem>
|
||||||
|
<SideNavItem icon={<GridIcon />} href="#">
|
||||||
|
Workspace
|
||||||
|
</SideNavItem>
|
||||||
|
<SideNavItem icon={<BookIcon />} href="#">
|
||||||
|
Resources
|
||||||
|
</SideNavItem>
|
||||||
|
<SideNavItem icon={<FolderIcon />} href="#">
|
||||||
|
My documents & links
|
||||||
|
</SideNavItem>
|
||||||
|
<SideNavDivider />
|
||||||
|
<SideNavGroup icon={<SchoolIcon />} label="PDP" active>
|
||||||
|
<SideNavItem href="#" active>
|
||||||
|
My PDP
|
||||||
|
</SideNavItem>
|
||||||
|
<SideNavItem href="#">PDP guide</SideNavItem>
|
||||||
|
</SideNavGroup>
|
||||||
|
<SideNavDivider />
|
||||||
|
<SideNavItem icon={<SupportIcon />} href="#">
|
||||||
|
Support
|
||||||
|
</SideNavItem>
|
||||||
|
</SideNav>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WithBadges: Story = {
|
||||||
|
name: 'With badges',
|
||||||
|
render: () => (
|
||||||
|
<SideNav>
|
||||||
|
<SideNavItem icon={<HomeIcon />} href="#" active>
|
||||||
|
My status
|
||||||
|
</SideNavItem>
|
||||||
|
<SideNavItem icon={<PersonIcon />} href="#">
|
||||||
|
My details
|
||||||
|
</SideNavItem>
|
||||||
|
<SideNavItem icon={<BookIcon />} href="#" badge={12}>
|
||||||
|
Resources
|
||||||
|
</SideNavItem>
|
||||||
|
<SideNavItem icon={<FolderIcon />} href="#" badge={3}>
|
||||||
|
My documents & links
|
||||||
|
</SideNavItem>
|
||||||
|
<SideNavDivider />
|
||||||
|
<SideNavGroup icon={<SchoolIcon />} label="PDP" badge={5} defaultOpen>
|
||||||
|
<SideNavItem href="#" badge={2}>
|
||||||
|
My PDP
|
||||||
|
</SideNavItem>
|
||||||
|
<SideNavItem href="#" badge={3}>
|
||||||
|
PDP guide
|
||||||
|
</SideNavItem>
|
||||||
|
</SideNavGroup>
|
||||||
|
<SideNavDivider />
|
||||||
|
<SideNavItem icon={<SupportIcon />} href="#">
|
||||||
|
Support
|
||||||
|
</SideNavItem>
|
||||||
|
</SideNav>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
const ToggleTemplate = () => {
|
||||||
|
const [collapsed, setCollapsed] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<SideNav collapsed={collapsed}>
|
||||||
|
<SideNavItem icon={<HomeIcon />} href="#" active>
|
||||||
|
My status
|
||||||
|
</SideNavItem>
|
||||||
|
<SideNavItem icon={<PersonIcon />} href="#">
|
||||||
|
My details
|
||||||
|
</SideNavItem>
|
||||||
|
<SideNavItem icon={<GridIcon />} href="#">
|
||||||
|
Workspace
|
||||||
|
</SideNavItem>
|
||||||
|
<SideNavItem icon={<BookIcon />} href="#">
|
||||||
|
Resources
|
||||||
|
</SideNavItem>
|
||||||
|
<SideNavItem icon={<FolderIcon />} href="#">
|
||||||
|
My documents & links
|
||||||
|
</SideNavItem>
|
||||||
|
<SideNavDivider />
|
||||||
|
<SideNavGroup icon={<SchoolIcon />} label="PDP" defaultOpen>
|
||||||
|
<SideNavItem href="#">My PDP</SideNavItem>
|
||||||
|
<SideNavItem href="#">PDP guide</SideNavItem>
|
||||||
|
<SideNavItem href="#">Management</SideNavItem>
|
||||||
|
<SideNavItem href="#">Useful links</SideNavItem>
|
||||||
|
</SideNavGroup>
|
||||||
|
<SideNavDivider />
|
||||||
|
<SideNavItem icon={<SupportIcon />} href="#">
|
||||||
|
Support
|
||||||
|
</SideNavItem>
|
||||||
|
</SideNav>
|
||||||
|
<button
|
||||||
|
onClick={() => setCollapsed((c) => !c)}
|
||||||
|
className="self-start rounded-lg border border-border px-4 py-2 text-small hover:bg-bg"
|
||||||
|
>
|
||||||
|
{collapsed ? 'Expand' : 'Collapse'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Interactive: Story = {
|
||||||
|
name: 'Toggle collapsed',
|
||||||
|
render: () => <ToggleTemplate />,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WithAlerts: Story = {
|
||||||
|
name: 'With alerts',
|
||||||
|
render: () => (
|
||||||
|
<SideNav>
|
||||||
|
<SideNavItem icon={<HomeIcon />} href="#" active>
|
||||||
|
My status
|
||||||
|
</SideNavItem>
|
||||||
|
<SideNavItem icon={<PersonIcon />} href="#" alert="error">
|
||||||
|
My details
|
||||||
|
</SideNavItem>
|
||||||
|
<SideNavItem icon={<GridIcon />} href="#">
|
||||||
|
Workspace
|
||||||
|
</SideNavItem>
|
||||||
|
<SideNavItem icon={<BookIcon />} href="#" alert="info">
|
||||||
|
Resources
|
||||||
|
</SideNavItem>
|
||||||
|
<SideNavItem icon={<FolderIcon />} href="#" badge={3} alert="warning">
|
||||||
|
My documents & links
|
||||||
|
</SideNavItem>
|
||||||
|
<SideNavDivider />
|
||||||
|
<SideNavGroup icon={<SchoolIcon />} label="PDP" alert="success" defaultOpen>
|
||||||
|
<SideNavItem href="#" active>
|
||||||
|
My PDP
|
||||||
|
</SideNavItem>
|
||||||
|
<SideNavItem href="#">PDP guide</SideNavItem>
|
||||||
|
</SideNavGroup>
|
||||||
|
<SideNavDivider />
|
||||||
|
<SideNavItem icon={<SupportIcon />} href="#">
|
||||||
|
Support
|
||||||
|
</SideNavItem>
|
||||||
|
</SideNav>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CollapsedWithAlerts: Story = {
|
||||||
|
name: 'Collapsed with alerts',
|
||||||
|
render: () => (
|
||||||
|
<SideNav collapsed>
|
||||||
|
<SideNavItem icon={<HomeIcon />} href="#" active>
|
||||||
|
My status
|
||||||
|
</SideNavItem>
|
||||||
|
<SideNavItem icon={<PersonIcon />} href="#" alert="error">
|
||||||
|
My details
|
||||||
|
</SideNavItem>
|
||||||
|
<SideNavItem icon={<GridIcon />} href="#">
|
||||||
|
Workspace
|
||||||
|
</SideNavItem>
|
||||||
|
<SideNavItem icon={<BookIcon />} href="#" alert="info">
|
||||||
|
Resources
|
||||||
|
</SideNavItem>
|
||||||
|
<SideNavItem icon={<FolderIcon />} href="#" badge={3} alert="warning">
|
||||||
|
My documents & links
|
||||||
|
</SideNavItem>
|
||||||
|
<SideNavDivider />
|
||||||
|
<SideNavGroup icon={<SchoolIcon />} label="PDP" alert="success">
|
||||||
|
<SideNavItem href="#" active>
|
||||||
|
My PDP
|
||||||
|
</SideNavItem>
|
||||||
|
</SideNavGroup>
|
||||||
|
<SideNavDivider />
|
||||||
|
<SideNavItem icon={<SupportIcon />} href="#">
|
||||||
|
Support
|
||||||
|
</SideNavItem>
|
||||||
|
</SideNav>
|
||||||
|
),
|
||||||
|
}
|
||||||
329
src/components/organisms/SideNav/SideNav.tsx
Normal file
329
src/components/organisms/SideNav/SideNav.tsx
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
forwardRef,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
type AnchorHTMLAttributes,
|
||||||
|
type ButtonHTMLAttributes,
|
||||||
|
type HTMLAttributes,
|
||||||
|
type ReactNode,
|
||||||
|
} from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { Tooltip } from '@/components/atoms/Tooltip/Tooltip'
|
||||||
|
|
||||||
|
type AlertVariant = 'info' | 'success' | 'warning' | 'error'
|
||||||
|
|
||||||
|
const alertStyles: Record<AlertVariant, string> = {
|
||||||
|
info: 'bg-info',
|
||||||
|
success: 'bg-success',
|
||||||
|
warning: 'bg-warning',
|
||||||
|
error: 'bg-error',
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAlertColor(alert: boolean | AlertVariant | undefined): string | null {
|
||||||
|
if (!alert) return null
|
||||||
|
return alertStyles[alert === true ? 'error' : alert]
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChevronIcon = () => (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="m6 9 6 6 6-6" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- Contexts ---
|
||||||
|
|
||||||
|
interface SideNavContextValue {
|
||||||
|
collapsed: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const SideNavContext = createContext<SideNavContextValue>({ collapsed: false })
|
||||||
|
|
||||||
|
const NestedContext = createContext(false)
|
||||||
|
|
||||||
|
// --- SideNav ---
|
||||||
|
|
||||||
|
export interface SideNavProps extends HTMLAttributes<HTMLElement> {
|
||||||
|
collapsed?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SideNav = forwardRef<HTMLElement, SideNavProps>(
|
||||||
|
({ collapsed = false, className, children, ...props }, ref) => {
|
||||||
|
const contextValue = useMemo(() => ({ collapsed }), [collapsed])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SideNavContext.Provider value={contextValue}>
|
||||||
|
<nav
|
||||||
|
ref={ref}
|
||||||
|
aria-label="Side navigation"
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col overflow-hidden bg-nav-bg px-2 py-2 transition-[width] duration-200',
|
||||||
|
collapsed ? 'w-20' : 'w-[360px]',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</nav>
|
||||||
|
</SideNavContext.Provider>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
SideNav.displayName = 'SideNav'
|
||||||
|
|
||||||
|
// --- SideNavDivider ---
|
||||||
|
|
||||||
|
export interface SideNavDividerProps extends HTMLAttributes<HTMLHRElement> {}
|
||||||
|
|
||||||
|
export const SideNavDivider = forwardRef<HTMLHRElement, SideNavDividerProps>(
|
||||||
|
({ className, ...props }, ref) => {
|
||||||
|
const { collapsed } = useContext(SideNavContext)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<hr
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'my-1 border-t border-nav-divider',
|
||||||
|
collapsed ? 'mx-1' : 'mx-2',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
SideNavDivider.displayName = 'SideNavDivider'
|
||||||
|
|
||||||
|
// --- Badge (internal) ---
|
||||||
|
|
||||||
|
function NavBadge({ count }: { count: number }) {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex h-5 min-w-5 shrink-0 items-center justify-center rounded-full bg-primary px-1.5 text-caption font-semibold leading-none text-white tabular-nums">
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Alert dot (internal) ---
|
||||||
|
|
||||||
|
function AlertDot({ alert }: { alert: boolean | AlertVariant | undefined }) {
|
||||||
|
const color = resolveAlertColor(alert)
|
||||||
|
if (!color) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span className={cn('size-2 rounded-full', color)} aria-hidden="true" />
|
||||||
|
<span className="sr-only">Has notifications</span>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Icon with optional alert overlay (collapsed mode) ---
|
||||||
|
|
||||||
|
function NavIcon({
|
||||||
|
icon,
|
||||||
|
alert,
|
||||||
|
}: {
|
||||||
|
icon: ReactNode
|
||||||
|
alert?: boolean | AlertVariant
|
||||||
|
}) {
|
||||||
|
const color = resolveAlertColor(alert)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="relative size-6 shrink-0 text-nav-icon [&>svg]:size-full">
|
||||||
|
{icon}
|
||||||
|
{color && (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
className={cn('absolute -right-1 -top-1 size-2.5 rounded-full ring-2 ring-nav-bg', color)}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span className="sr-only">Has notifications</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- SideNavItem ---
|
||||||
|
|
||||||
|
type SideNavItemBase = {
|
||||||
|
icon?: ReactNode
|
||||||
|
active?: boolean
|
||||||
|
badge?: number
|
||||||
|
alert?: boolean | AlertVariant
|
||||||
|
children: ReactNode
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type SideNavItemAsLink = SideNavItemBase & {
|
||||||
|
href: string
|
||||||
|
} & Omit<AnchorHTMLAttributes<HTMLAnchorElement>, keyof SideNavItemBase | 'href'>
|
||||||
|
|
||||||
|
type SideNavItemAsButton = SideNavItemBase & {
|
||||||
|
href?: undefined
|
||||||
|
} & Omit<ButtonHTMLAttributes<HTMLButtonElement>, keyof SideNavItemBase>
|
||||||
|
|
||||||
|
export type SideNavItemProps = SideNavItemAsLink | SideNavItemAsButton
|
||||||
|
|
||||||
|
export const SideNavItem = forwardRef<HTMLAnchorElement | HTMLButtonElement, SideNavItemProps>(
|
||||||
|
({ icon, active = false, badge, alert, className, children, ...props }, ref) => {
|
||||||
|
const { collapsed } = useContext(SideNavContext)
|
||||||
|
const isNested = useContext(NestedContext)
|
||||||
|
const isLink = 'href' in props && props.href !== undefined
|
||||||
|
|
||||||
|
const styles = cn(
|
||||||
|
'relative flex items-center rounded-full text-left transition-colors',
|
||||||
|
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-info',
|
||||||
|
isNested
|
||||||
|
? 'h-14 pl-14 pr-6'
|
||||||
|
: cn('h-14', collapsed ? 'size-14 justify-center' : 'pl-4 pr-6'),
|
||||||
|
active
|
||||||
|
? 'bg-nav-active/12 text-nav-text'
|
||||||
|
: 'text-nav-text hover:bg-nav-text/[0.04] active:bg-nav-text/[0.25]',
|
||||||
|
className,
|
||||||
|
)
|
||||||
|
|
||||||
|
const hasExtras = !collapsed && (alert || badge !== undefined)
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<>
|
||||||
|
{icon && !isNested && (
|
||||||
|
collapsed ? (
|
||||||
|
<NavIcon icon={icon} alert={alert} />
|
||||||
|
) : (
|
||||||
|
<span className="mr-4 size-6 shrink-0 text-nav-icon [&>svg]:size-full">{icon}</span>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
{!collapsed && (
|
||||||
|
<>
|
||||||
|
<span className="flex-1 truncate text-body leading-[1.5]">{children}</span>
|
||||||
|
{hasExtras && (
|
||||||
|
<span className="ml-2 flex shrink-0 items-center gap-2">
|
||||||
|
<AlertDot alert={alert} />
|
||||||
|
{badge !== undefined && <NavBadge count={badge} />}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
const element = isLink ? (
|
||||||
|
<a
|
||||||
|
ref={ref as React.Ref<HTMLAnchorElement>}
|
||||||
|
className={styles}
|
||||||
|
aria-current={active ? 'page' : undefined}
|
||||||
|
{...(props as Omit<AnchorHTMLAttributes<HTMLAnchorElement>, keyof SideNavItemBase>)}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
ref={ref as React.Ref<HTMLButtonElement>}
|
||||||
|
type="button"
|
||||||
|
className={styles}
|
||||||
|
aria-current={active ? 'page' : undefined}
|
||||||
|
{...(props as Omit<ButtonHTMLAttributes<HTMLButtonElement>, keyof SideNavItemBase>)}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (collapsed && !isNested) {
|
||||||
|
return (
|
||||||
|
<Tooltip content={children} placement="right" delay={{ open: 200, close: 0 }}>
|
||||||
|
{element}
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return element
|
||||||
|
},
|
||||||
|
)
|
||||||
|
SideNavItem.displayName = 'SideNavItem'
|
||||||
|
|
||||||
|
// --- SideNavGroup ---
|
||||||
|
|
||||||
|
export interface SideNavGroupProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
|
icon: ReactNode
|
||||||
|
label: string
|
||||||
|
defaultOpen?: boolean
|
||||||
|
badge?: number
|
||||||
|
alert?: boolean | AlertVariant
|
||||||
|
active?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SideNavGroup = forwardRef<HTMLDivElement, SideNavGroupProps>(
|
||||||
|
({ icon, label, defaultOpen = false, badge, alert, active = false, className, children, ...props }, ref) => {
|
||||||
|
const [open, setOpen] = useState(defaultOpen)
|
||||||
|
const { collapsed } = useContext(SideNavContext)
|
||||||
|
|
||||||
|
const toggle = useCallback(() => setOpen((prev) => !prev), [])
|
||||||
|
|
||||||
|
const triggerStyles = cn(
|
||||||
|
'relative flex h-14 w-full items-center rounded-full text-left transition-colors',
|
||||||
|
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-info',
|
||||||
|
collapsed ? 'size-14 justify-center' : 'pl-4 pr-6',
|
||||||
|
active && collapsed
|
||||||
|
? 'bg-nav-active/12 text-nav-text'
|
||||||
|
: 'text-nav-text hover:bg-nav-text/[0.04] active:bg-nav-text/[0.25]',
|
||||||
|
)
|
||||||
|
|
||||||
|
const trigger = (
|
||||||
|
<button type="button" onClick={toggle} aria-expanded={open} className={triggerStyles}>
|
||||||
|
{collapsed ? (
|
||||||
|
<NavIcon icon={icon} alert={alert} />
|
||||||
|
) : (
|
||||||
|
<span className="mr-4 size-6 shrink-0 text-nav-icon [&>svg]:size-full">{icon}</span>
|
||||||
|
)}
|
||||||
|
{!collapsed && (
|
||||||
|
<>
|
||||||
|
<span className="min-w-0 truncate text-body leading-[1.5]">{label}</span>
|
||||||
|
<span className="ml-2 flex shrink-0 items-center gap-2">
|
||||||
|
<AlertDot alert={alert} />
|
||||||
|
{badge !== undefined && <NavBadge count={badge} />}
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'size-5 shrink-0 transition-transform duration-200 [&>svg]:size-full',
|
||||||
|
open && 'rotate-180',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ChevronIcon />
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className={className} {...props}>
|
||||||
|
{collapsed ? (
|
||||||
|
<Tooltip content={label} placement="right" delay={{ open: 200, close: 0 }}>
|
||||||
|
{trigger}
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
trigger
|
||||||
|
)}
|
||||||
|
{open && !collapsed && (
|
||||||
|
<NestedContext.Provider value={true}>
|
||||||
|
<div role="group" aria-label={label} className="flex flex-col">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</NestedContext.Provider>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
SideNavGroup.displayName = 'SideNavGroup'
|
||||||
7
src/components/organisms/SideNav/index.ts
Normal file
7
src/components/organisms/SideNav/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export { SideNav, SideNavItem, SideNavGroup, SideNavDivider } from './SideNav'
|
||||||
|
export type {
|
||||||
|
SideNavProps,
|
||||||
|
SideNavItemProps,
|
||||||
|
SideNavGroupProps,
|
||||||
|
SideNavDividerProps,
|
||||||
|
} from './SideNav'
|
||||||
190
src/components/organisms/TopBar/TopBar.stories.tsx
Normal file
190
src/components/organisms/TopBar/TopBar.stories.tsx
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { TopBar } from './TopBar'
|
||||||
|
import { Avatar } from '@/components/atoms/Avatar/Avatar'
|
||||||
|
|
||||||
|
// --- Story icons ---
|
||||||
|
|
||||||
|
const MenuIcon = () => (
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M3 18h18v-2H3v2zm0-5h18v-2H3v2zm0-7v2h18V6H3z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const CloseIcon = () => (
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const BackIcon = () => (
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const HelpIcon = () => (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<path d="M9.09 9a3 3 0 015.83 1c0 2-3 3-3 3" />
|
||||||
|
<circle cx="12" cy="17" r=".5" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const BellIcon = () => (
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.89 2 2 2zm6-6v-5c0-3.07-1.64-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.63 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const DotsIcon = () => (
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<circle cx="12" cy="5" r="2" />
|
||||||
|
<circle cx="12" cy="12" r="2" />
|
||||||
|
<circle cx="12" cy="19" r="2" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const NswLogo = () => (
|
||||||
|
<img src="/nsw-logo.svg" alt="NSW Government" className="h-6" />
|
||||||
|
)
|
||||||
|
|
||||||
|
function IconBtn({ icon, label }: { icon: React.ReactNode; label: string }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={label}
|
||||||
|
className="flex size-12 items-center justify-center rounded-full text-white/80 transition-colors hover:bg-white/10 hover:text-white"
|
||||||
|
>
|
||||||
|
<span className="size-6 [&>svg]:size-full">{icon}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Stories ---
|
||||||
|
|
||||||
|
const meta: Meta<typeof TopBar> = {
|
||||||
|
title: 'Organisms/TopBar',
|
||||||
|
component: TopBar,
|
||||||
|
tags: ['autodocs'],
|
||||||
|
parameters: { layout: 'fullscreen' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof TopBar>
|
||||||
|
|
||||||
|
const trailingActions = (
|
||||||
|
<>
|
||||||
|
<IconBtn icon={<HelpIcon />} label="Help" />
|
||||||
|
<IconBtn icon={<BellIcon />} label="Notifications" />
|
||||||
|
<IconBtn icon={<DotsIcon />} label="More options" />
|
||||||
|
<Avatar initials="DW" size="lg" />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
name: 'Top level (no menu)',
|
||||||
|
render: () => (
|
||||||
|
<TopBar
|
||||||
|
title="Performance and development plan"
|
||||||
|
leading={
|
||||||
|
<div className="flex size-14 items-center justify-center">
|
||||||
|
<NswLogo />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{trailingActions}
|
||||||
|
</TopBar>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MenuClosed: Story = {
|
||||||
|
name: 'Top level (menu closed)',
|
||||||
|
render: () => (
|
||||||
|
<TopBar
|
||||||
|
title="Performance and development plan"
|
||||||
|
leading={<IconBtn icon={<MenuIcon />} label="Open menu" />}
|
||||||
|
logo={<NswLogo />}
|
||||||
|
>
|
||||||
|
{trailingActions}
|
||||||
|
</TopBar>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MenuOpen: Story = {
|
||||||
|
name: 'Top level (menu open)',
|
||||||
|
render: () => (
|
||||||
|
<TopBar
|
||||||
|
title="Performance and development plan"
|
||||||
|
leading={<IconBtn icon={<CloseIcon />} label="Close menu" />}
|
||||||
|
logo={<NswLogo />}
|
||||||
|
>
|
||||||
|
{trailingActions}
|
||||||
|
</TopBar>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ChildLevel: Story = {
|
||||||
|
name: 'Child level',
|
||||||
|
render: () => (
|
||||||
|
<TopBar
|
||||||
|
title="PDP Guide"
|
||||||
|
leading={<IconBtn icon={<BackIcon />} label="Go back" />}
|
||||||
|
>
|
||||||
|
{trailingActions}
|
||||||
|
</TopBar>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FullscreenDialog: Story = {
|
||||||
|
name: 'Fullscreen dialog',
|
||||||
|
render: () => (
|
||||||
|
<TopBar
|
||||||
|
title="Edit PDP"
|
||||||
|
leading={<IconBtn icon={<CloseIcon />} label="Close" />}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="mr-2 rounded-full bg-blue-04 px-6 py-2.5 text-body font-bold text-primary-dark transition-colors hover:bg-blue-04/80"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</TopBar>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
const InteractiveTemplate = () => {
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<TopBar
|
||||||
|
title="Performance and development plan"
|
||||||
|
leading={
|
||||||
|
<IconBtn
|
||||||
|
icon={menuOpen ? <CloseIcon /> : <MenuIcon />}
|
||||||
|
label={menuOpen ? 'Close menu' : 'Open menu'}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
logo={<NswLogo />}
|
||||||
|
>
|
||||||
|
{trailingActions}
|
||||||
|
</TopBar>
|
||||||
|
<div className="p-4 text-small text-text-secondary">
|
||||||
|
Menu is {menuOpen ? 'open' : 'closed'} — click the hamburger/close icon to toggle
|
||||||
|
</div>
|
||||||
|
{/* Invisible click handler since IconBtn doesn't take onClick */}
|
||||||
|
<button
|
||||||
|
className="fixed left-3.5 top-0 z-10 size-12 opacity-0"
|
||||||
|
onClick={() => setMenuOpen((o) => !o)}
|
||||||
|
aria-hidden="true"
|
||||||
|
tabIndex={-1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Interactive: Story = {
|
||||||
|
name: 'Interactive menu toggle',
|
||||||
|
render: () => <InteractiveTemplate />,
|
||||||
|
}
|
||||||
44
src/components/organisms/TopBar/TopBar.tsx
Normal file
44
src/components/organisms/TopBar/TopBar.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { forwardRef, type HTMLAttributes, type ReactNode } from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export interface TopBarProps extends HTMLAttributes<HTMLElement> {
|
||||||
|
title: string
|
||||||
|
leading?: ReactNode
|
||||||
|
logo?: ReactNode
|
||||||
|
children?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TopBar = forwardRef<HTMLElement, TopBarProps>(
|
||||||
|
({ title, leading, logo, className, children, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<header
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'flex h-16 w-full items-center bg-topbar',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{leading && (
|
||||||
|
<div className="flex shrink-0 items-center pl-3.5">
|
||||||
|
{leading}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex min-w-0 flex-1 items-center gap-3 pl-5">
|
||||||
|
{logo && <div className="shrink-0">{logo}</div>}
|
||||||
|
<h1 className="truncate text-h4 font-bold leading-7 text-white">
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{children && (
|
||||||
|
<div className="flex shrink-0 items-center pr-2.5">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
TopBar.displayName = 'TopBar'
|
||||||
2
src/components/organisms/TopBar/index.ts
Normal file
2
src/components/organisms/TopBar/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { TopBar } from './TopBar'
|
||||||
|
export type { TopBarProps } from './TopBar'
|
||||||
98
src/components/templates/AppShell/AppShell.stories.tsx
Normal file
98
src/components/templates/AppShell/AppShell.stories.tsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { AppShell } from './AppShell'
|
||||||
|
import { TopBar } from '@/components/organisms/TopBar/TopBar'
|
||||||
|
import { SideNav, SideNavItem, SideNavGroup, SideNavDivider } from '@/components/organisms/SideNav/SideNav'
|
||||||
|
import { Avatar } from '@/components/atoms/Avatar/Avatar'
|
||||||
|
import { PageHeader } from '@/components/organisms/PageHeader/PageHeader'
|
||||||
|
import { NswLogo, TopBarAction } from '@/components/templates/_story-helpers'
|
||||||
|
import { Menu, Search, Bell, Home, FileText, LayoutGrid, Settings, Users, Link } from 'lucide-react'
|
||||||
|
|
||||||
|
const meta: Meta<typeof AppShell> = {
|
||||||
|
title: 'Templates/AppShell',
|
||||||
|
component: AppShell,
|
||||||
|
tags: ['autodocs', 'template'],
|
||||||
|
parameters: {
|
||||||
|
layout: 'fullscreen',
|
||||||
|
docs: {
|
||||||
|
description: {
|
||||||
|
component: 'Application shell layout that composes TopBar + SideNav + scrollable content area. All page templates should be rendered inside an AppShell.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
export default meta
|
||||||
|
|
||||||
|
type Story = StoryObj<typeof AppShell>
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
render: () => {
|
||||||
|
const [collapsed, setCollapsed] = useState(false)
|
||||||
|
return (
|
||||||
|
<AppShell
|
||||||
|
topBar={
|
||||||
|
<TopBar
|
||||||
|
title="My Application"
|
||||||
|
leading={<TopBarAction icon={<Menu />} label="Toggle menu" onClick={() => setCollapsed(!collapsed)} />}
|
||||||
|
logo={<NswLogo />}
|
||||||
|
>
|
||||||
|
<TopBarAction icon={<Search />} label="Search" />
|
||||||
|
<TopBarAction icon={<Bell />} label="Notifications" />
|
||||||
|
<Avatar initials="AB" size="sm" />
|
||||||
|
</TopBar>
|
||||||
|
}
|
||||||
|
sideNav={
|
||||||
|
<SideNav collapsed={collapsed}>
|
||||||
|
<SideNavItem icon={<Home />} active>Home</SideNavItem>
|
||||||
|
<SideNavItem icon={<FileText />}>Documents</SideNavItem>
|
||||||
|
<SideNavItem icon={<LayoutGrid />}>Workspace</SideNavItem>
|
||||||
|
<SideNavDivider />
|
||||||
|
<SideNavGroup icon={<Users />} label="Team" defaultOpen>
|
||||||
|
<SideNavItem>Members</SideNavItem>
|
||||||
|
<SideNavItem>Roles</SideNavItem>
|
||||||
|
</SideNavGroup>
|
||||||
|
<SideNavItem icon={<Link />}>Resources</SideNavItem>
|
||||||
|
<SideNavItem icon={<Settings />}>Settings</SideNavItem>
|
||||||
|
</SideNav>
|
||||||
|
}
|
||||||
|
sideNavCollapsed={collapsed}
|
||||||
|
>
|
||||||
|
<PageHeader title="Dashboard" subtitle="Welcome back" />
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="rounded-lg border border-border bg-surface p-8 text-center text-text-secondary">
|
||||||
|
Page content goes here
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AppShell>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Collapsed: Story = {
|
||||||
|
render: () => (
|
||||||
|
<AppShell
|
||||||
|
topBar={
|
||||||
|
<TopBar title="My Application" leading={<TopBarAction icon={<Menu />} label="Menu" />} logo={<NswLogo />}>
|
||||||
|
<TopBarAction icon={<Bell />} label="Notifications" />
|
||||||
|
<Avatar initials="AB" size="sm" />
|
||||||
|
</TopBar>
|
||||||
|
}
|
||||||
|
sideNav={
|
||||||
|
<SideNav collapsed>
|
||||||
|
<SideNavItem icon={<Home />} active>Home</SideNavItem>
|
||||||
|
<SideNavItem icon={<FileText />}>Documents</SideNavItem>
|
||||||
|
<SideNavItem icon={<LayoutGrid />}>Workspace</SideNavItem>
|
||||||
|
<SideNavItem icon={<Settings />}>Settings</SideNavItem>
|
||||||
|
</SideNav>
|
||||||
|
}
|
||||||
|
sideNavCollapsed
|
||||||
|
>
|
||||||
|
<PageHeader title="Dashboard" subtitle="SideNav collapsed to icon-only mode" />
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="rounded-lg border border-border bg-surface p-8 text-center text-text-secondary">
|
||||||
|
Content area is wider with collapsed sidebar
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AppShell>
|
||||||
|
),
|
||||||
|
}
|
||||||
30
src/components/templates/AppShell/AppShell.tsx
Normal file
30
src/components/templates/AppShell/AppShell.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { forwardRef, type HTMLAttributes, type ReactNode } from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export interface AppShellProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
|
/** TopBar component rendered fixed at the top */
|
||||||
|
topBar: ReactNode
|
||||||
|
/** SideNav component rendered in the left rail */
|
||||||
|
sideNav: ReactNode
|
||||||
|
/** Whether the SideNav is in collapsed (icon-only) mode */
|
||||||
|
sideNavCollapsed?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AppShell = forwardRef<HTMLDivElement, AppShellProps>(
|
||||||
|
({ topBar, sideNav, sideNavCollapsed = false, className, children, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<div ref={ref} className={cn('flex h-screen flex-col bg-bg', className)} {...props}>
|
||||||
|
<div className="shrink-0">{topBar}</div>
|
||||||
|
|
||||||
|
<div className="flex flex-1 overflow-hidden">
|
||||||
|
<aside className="flex shrink-0 overflow-y-auto">{sideNav}</aside>
|
||||||
|
|
||||||
|
<main className="flex-1 overflow-y-auto">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
AppShell.displayName = 'AppShell'
|
||||||
2
src/components/templates/AppShell/index.ts
Normal file
2
src/components/templates/AppShell/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { AppShell } from './AppShell'
|
||||||
|
export type { AppShellProps } from './AppShell'
|
||||||
108
src/components/templates/CenteredPage/CenteredPage.stories.tsx
Normal file
108
src/components/templates/CenteredPage/CenteredPage.stories.tsx
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react'
|
||||||
|
import { CenteredPage } from './CenteredPage'
|
||||||
|
import { TopBar } from '@/components/organisms/TopBar/TopBar'
|
||||||
|
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '@/components/molecules/Card/Card'
|
||||||
|
import { Input } from '@/components/atoms/Input/Input'
|
||||||
|
import { Button } from '@/components/atoms/Button/Button'
|
||||||
|
import { Checkbox } from '@/components/atoms/Checkbox/Checkbox'
|
||||||
|
import { Alert } from '@/components/molecules/Alert/Alert'
|
||||||
|
import { NswLogo } from '@/components/templates/_story-helpers'
|
||||||
|
|
||||||
|
const meta: Meta<typeof CenteredPage> = {
|
||||||
|
title: 'Templates/CenteredPage',
|
||||||
|
component: CenteredPage,
|
||||||
|
tags: ['autodocs', 'template'],
|
||||||
|
parameters: {
|
||||||
|
layout: 'fullscreen',
|
||||||
|
docs: {
|
||||||
|
description: {
|
||||||
|
component: 'Full-page layout with no sidebar and horizontally/vertically centered content. Use for login, sign-up, error pages, onboarding, or any focused single-task flow.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
export default meta
|
||||||
|
|
||||||
|
type Story = StoryObj<typeof CenteredPage>
|
||||||
|
|
||||||
|
export const Login: Story = {
|
||||||
|
name: 'Login page',
|
||||||
|
render: () => (
|
||||||
|
<CenteredPage
|
||||||
|
topBar={<TopBar title="" leading={<div className="flex size-14 items-center justify-center"><NswLogo /></div>} />}
|
||||||
|
>
|
||||||
|
<Card variant="elevated">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Sign in</CardTitle>
|
||||||
|
<CardDescription>Enter your credentials to access your account.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<Input label="Email address" type="email" placeholder="you@example.com" />
|
||||||
|
<Input label="Password" type="password" placeholder="Enter your password" />
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Checkbox label="Remember me" />
|
||||||
|
<a href="#" className="text-small text-info hover:underline">Forgot password?</a>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="flex-col gap-3">
|
||||||
|
<Button className="w-full">Sign in</Button>
|
||||||
|
<p className="text-center text-small text-text-secondary">
|
||||||
|
Don't have an account? <a href="#" className="text-info hover:underline">Create one</a>
|
||||||
|
</p>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
</CenteredPage>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ErrorPage: Story = {
|
||||||
|
name: 'Error page',
|
||||||
|
render: () => (
|
||||||
|
<CenteredPage
|
||||||
|
topBar={<TopBar title="" leading={<div className="flex size-14 items-center justify-center"><NswLogo /></div>} />}
|
||||||
|
maxWidth="sm"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center text-center">
|
||||||
|
<p className="text-[72px] font-bold leading-none text-primary">404</p>
|
||||||
|
<h1 className="mt-4 text-h2 font-bold text-text">Page not found</h1>
|
||||||
|
<p className="mt-2 text-body text-text-secondary">
|
||||||
|
The page you're looking for doesn't exist or has been moved.
|
||||||
|
</p>
|
||||||
|
<div className="mt-6 flex gap-3">
|
||||||
|
<Button variant="secondary">Go back</Button>
|
||||||
|
<Button>Home</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CenteredPage>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Onboarding: Story = {
|
||||||
|
name: 'Onboarding step',
|
||||||
|
render: () => (
|
||||||
|
<CenteredPage
|
||||||
|
topBar={<TopBar title="Getting Started" leading={<div className="flex size-14 items-center justify-center"><NswLogo /></div>} />}
|
||||||
|
maxWidth="lg"
|
||||||
|
>
|
||||||
|
<Card variant="surface">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Welcome to the platform</CardTitle>
|
||||||
|
<CardDescription>Let's set up your workspace. This will only take a minute.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<Alert variant="info" title="Step 1 of 3">
|
||||||
|
Tell us about your organisation so we can customise your experience.
|
||||||
|
</Alert>
|
||||||
|
<Input label="Organisation name" placeholder="Enter your organisation name" />
|
||||||
|
<Input label="Your role" placeholder="e.g. Manager, Coordinator" />
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter>
|
||||||
|
<div className="flex w-full justify-between">
|
||||||
|
<Button variant="tertiary">Skip for now</Button>
|
||||||
|
<Button>Continue</Button>
|
||||||
|
</div>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
</CenteredPage>
|
||||||
|
),
|
||||||
|
}
|
||||||
35
src/components/templates/CenteredPage/CenteredPage.tsx
Normal file
35
src/components/templates/CenteredPage/CenteredPage.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { forwardRef, type HTMLAttributes, type ReactNode } from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export interface CenteredPageProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
|
/** TopBar component rendered fixed at the top */
|
||||||
|
topBar?: ReactNode
|
||||||
|
/** Horizontally and vertically centered content */
|
||||||
|
children: ReactNode
|
||||||
|
/** Max width of the content area */
|
||||||
|
maxWidth?: 'sm' | 'md' | 'lg' | 'xl'
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxWidthStyles = {
|
||||||
|
sm: 'max-w-md',
|
||||||
|
md: 'max-w-xl',
|
||||||
|
lg: 'max-w-2xl',
|
||||||
|
xl: 'max-w-4xl',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CenteredPage = forwardRef<HTMLDivElement, CenteredPageProps>(
|
||||||
|
({ topBar, maxWidth = 'md', className, children, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<div ref={ref} className={cn('flex h-screen flex-col bg-bg', className)} {...props}>
|
||||||
|
{topBar && <div className="shrink-0">{topBar}</div>}
|
||||||
|
|
||||||
|
<main className="flex flex-1 items-center justify-center overflow-y-auto p-6">
|
||||||
|
<div className={cn('w-full', maxWidthStyles[maxWidth])}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
CenteredPage.displayName = 'CenteredPage'
|
||||||
2
src/components/templates/CenteredPage/index.ts
Normal file
2
src/components/templates/CenteredPage/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { CenteredPage } from './CenteredPage'
|
||||||
|
export type { CenteredPageProps } from './CenteredPage'
|
||||||
192
src/components/templates/DashboardPage/DashboardPage.stories.tsx
Normal file
192
src/components/templates/DashboardPage/DashboardPage.stories.tsx
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { DashboardPage } from './DashboardPage'
|
||||||
|
import { AppShell } from '@/components/templates/AppShell/AppShell'
|
||||||
|
import { TopBar } from '@/components/organisms/TopBar/TopBar'
|
||||||
|
import { SideNav, SideNavItem, SideNavDivider } from '@/components/organisms/SideNav/SideNav'
|
||||||
|
import { PageHeader } from '@/components/organisms/PageHeader/PageHeader'
|
||||||
|
import { Card, CardHeader, CardTitle, CardContent } from '@/components/molecules/Card/Card'
|
||||||
|
import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from '@/components/molecules/Accordion/Accordion'
|
||||||
|
import { Badge } from '@/components/atoms/Badge/Badge'
|
||||||
|
import { Avatar } from '@/components/atoms/Avatar/Avatar'
|
||||||
|
import { NswLogo, TopBarAction } from '@/components/templates/_story-helpers'
|
||||||
|
import { Menu, Bell, Home, FileText, LayoutGrid, Users, CheckCircle, Clock, BarChart3 } from 'lucide-react'
|
||||||
|
|
||||||
|
const meta: Meta<typeof DashboardPage> = {
|
||||||
|
title: 'Templates/DashboardPage',
|
||||||
|
component: DashboardPage,
|
||||||
|
tags: ['autodocs', 'template'],
|
||||||
|
parameters: {
|
||||||
|
layout: 'fullscreen',
|
||||||
|
docs: {
|
||||||
|
description: {
|
||||||
|
component: 'Dashboard page template with stat summary row and a responsive 2-column content grid. Use inside AppShell for the full page layout.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
export default meta
|
||||||
|
|
||||||
|
type Story = StoryObj<typeof DashboardPage>
|
||||||
|
|
||||||
|
export const WithAppShell: Story = {
|
||||||
|
name: 'Full page',
|
||||||
|
render: () => {
|
||||||
|
const [collapsed, setCollapsed] = useState(false)
|
||||||
|
return (
|
||||||
|
<AppShell
|
||||||
|
topBar={
|
||||||
|
<TopBar
|
||||||
|
title="Project Hub"
|
||||||
|
leading={<TopBarAction icon={<Menu />} label="Menu" onClick={() => setCollapsed(!collapsed)} />}
|
||||||
|
logo={<NswLogo />}
|
||||||
|
>
|
||||||
|
<TopBarAction icon={<Bell />} label="Notifications" />
|
||||||
|
<Avatar initials="AB" size="sm" />
|
||||||
|
</TopBar>
|
||||||
|
}
|
||||||
|
sideNav={
|
||||||
|
<SideNav collapsed={collapsed}>
|
||||||
|
<SideNavItem icon={<Home />} active>Overview</SideNavItem>
|
||||||
|
<SideNavItem icon={<Users />}>Team</SideNavItem>
|
||||||
|
<SideNavItem icon={<LayoutGrid />}>Projects</SideNavItem>
|
||||||
|
<SideNavItem icon={<FileText />}>Reports</SideNavItem>
|
||||||
|
<SideNavDivider />
|
||||||
|
<SideNavItem icon={<BarChart3 />}>Analytics</SideNavItem>
|
||||||
|
</SideNav>
|
||||||
|
}
|
||||||
|
sideNavCollapsed={collapsed}
|
||||||
|
>
|
||||||
|
<DashboardPage
|
||||||
|
header={<PageHeader title="Overview" subtitle="Your workspace at a glance" theme="dark" />}
|
||||||
|
stats={
|
||||||
|
<>
|
||||||
|
<Card variant="surface" className="min-w-[180px] flex-1">
|
||||||
|
<CardContent className="flex items-center gap-4 p-5">
|
||||||
|
<div className="flex size-10 shrink-0 items-center justify-center rounded-full bg-info/10 text-info"><Clock size={20} /></div>
|
||||||
|
<div>
|
||||||
|
<p className="text-h3 font-bold text-text">142</p>
|
||||||
|
<p className="text-small text-text-secondary">Hours logged</p>
|
||||||
|
<p className="text-caption text-text-secondary">Target 200h</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card variant="surface" className="min-w-[180px] flex-1">
|
||||||
|
<CardContent className="flex items-center gap-4 p-5">
|
||||||
|
<div className="flex size-10 shrink-0 items-center justify-center rounded-full bg-success/10 text-success"><CheckCircle size={20} /></div>
|
||||||
|
<div>
|
||||||
|
<p className="text-h3 font-bold text-text">24</p>
|
||||||
|
<p className="text-small text-text-secondary">Tasks completed</p>
|
||||||
|
<p className="text-caption text-text-secondary">This month</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card variant="surface" className="min-w-[180px] flex-1">
|
||||||
|
<CardContent className="flex items-center gap-4 p-5">
|
||||||
|
<div className="flex size-10 shrink-0 items-center justify-center rounded-full bg-info/10 text-info"><BarChart3 size={20} /></div>
|
||||||
|
<div>
|
||||||
|
<p className="text-h3 font-bold text-text">8</p>
|
||||||
|
<p className="text-small text-text-secondary">Active projects</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Card variant="surface">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Pending actions</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<Accordion type="single" collapsible>
|
||||||
|
<AccordionItem value="s1">
|
||||||
|
<AccordionTrigger>Complete onboarding checklist</AccordionTrigger>
|
||||||
|
<AccordionContent>Review and complete all required onboarding items.</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
<AccordionItem value="s2">
|
||||||
|
<AccordionTrigger>Submit quarterly report</AccordionTrigger>
|
||||||
|
<AccordionContent>Your Q2 report is due by end of month.</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
<AccordionItem value="s3">
|
||||||
|
<AccordionTrigger>Review team permissions</AccordionTrigger>
|
||||||
|
<AccordionContent>Audit team member access levels.</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<Card variant="surface">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Compliance status</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-center justify-between border-b border-border pb-3">
|
||||||
|
<span className="text-body font-medium">Security training</span>
|
||||||
|
<Badge variant="success" leftIcon={<CheckCircle size={14} />}>Complete</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex items-center justify-between">
|
||||||
|
<span className="text-body font-medium">Data privacy certification</span>
|
||||||
|
<Badge variant="warning">Due soon</Badge>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card variant="elevated" className="bg-info/5">
|
||||||
|
<CardContent className="flex gap-4 p-5">
|
||||||
|
<Avatar initials="HR" size="lg" />
|
||||||
|
<div className="text-small">
|
||||||
|
<p className="font-medium text-text">
|
||||||
|
Welcome to the team! Here are some resources to help you get started.
|
||||||
|
</p>
|
||||||
|
<div className="mt-3 flex flex-col gap-1">
|
||||||
|
<a href="#" className="text-info hover:underline">Getting started guide</a>
|
||||||
|
<a href="#" className="text-info hover:underline">Frequently asked questions</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</DashboardPage>
|
||||||
|
</AppShell>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Standalone: Story = {
|
||||||
|
name: 'Content only',
|
||||||
|
render: () => (
|
||||||
|
<DashboardPage
|
||||||
|
header={<PageHeader title="Dashboard" subtitle="Overview of your activity" />}
|
||||||
|
stats={
|
||||||
|
<>
|
||||||
|
<Card variant="surface" className="min-w-[180px] flex-1">
|
||||||
|
<CardContent className="flex items-center gap-4 p-5">
|
||||||
|
<div className="flex size-10 shrink-0 items-center justify-center rounded-full bg-info/10 text-info"><Clock size={20} /></div>
|
||||||
|
<div>
|
||||||
|
<p className="text-h3 font-bold text-text">21h</p>
|
||||||
|
<p className="text-small text-text-secondary">Hours logged</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card variant="surface" className="min-w-[180px] flex-1">
|
||||||
|
<CardContent className="flex items-center gap-4 p-5">
|
||||||
|
<div className="flex size-10 shrink-0 items-center justify-center rounded-full bg-info/10 text-info"><BarChart3 size={20} /></div>
|
||||||
|
<div>
|
||||||
|
<p className="text-h3 font-bold text-text">5</p>
|
||||||
|
<p className="text-small text-text-secondary">Activities</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Card variant="surface">
|
||||||
|
<CardContent className="p-8 text-center text-text-secondary">Left column</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card variant="surface">
|
||||||
|
<CardContent className="p-8 text-center text-text-secondary">Right column</CardContent>
|
||||||
|
</Card>
|
||||||
|
</DashboardPage>
|
||||||
|
),
|
||||||
|
}
|
||||||
30
src/components/templates/DashboardPage/DashboardPage.tsx
Normal file
30
src/components/templates/DashboardPage/DashboardPage.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { forwardRef, type HTMLAttributes, type ReactNode } from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export interface DashboardPageProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
|
/** PageHeader or custom header section */
|
||||||
|
header?: ReactNode
|
||||||
|
/** Row of stat cards or summary widgets displayed above the content grid */
|
||||||
|
stats?: ReactNode
|
||||||
|
/** Two-column responsive content grid area */
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DashboardPage = forwardRef<HTMLDivElement, DashboardPageProps>(
|
||||||
|
({ header, stats, className, children, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<div ref={ref} className={cn('flex flex-col', className)} {...props}>
|
||||||
|
{header}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-6 p-6">
|
||||||
|
{stats && <div className="flex flex-wrap gap-4">{stats}</div>}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
DashboardPage.displayName = 'DashboardPage'
|
||||||
2
src/components/templates/DashboardPage/index.ts
Normal file
2
src/components/templates/DashboardPage/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { DashboardPage } from './DashboardPage'
|
||||||
|
export type { DashboardPageProps } from './DashboardPage'
|
||||||
163
src/components/templates/DetailPage/DetailPage.stories.tsx
Normal file
163
src/components/templates/DetailPage/DetailPage.stories.tsx
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { DetailPage } from './DetailPage'
|
||||||
|
import { AppShell } from '@/components/templates/AppShell/AppShell'
|
||||||
|
import { TopBar } from '@/components/organisms/TopBar/TopBar'
|
||||||
|
import { SideNav, SideNavItem, SideNavGroup, SideNavDivider } from '@/components/organisms/SideNav/SideNav'
|
||||||
|
import { PageHeader } from '@/components/organisms/PageHeader/PageHeader'
|
||||||
|
import { Card, CardHeader, CardTitle, CardContent } from '@/components/molecules/Card/Card'
|
||||||
|
import { Badge } from '@/components/atoms/Badge/Badge'
|
||||||
|
import { Avatar } from '@/components/atoms/Avatar/Avatar'
|
||||||
|
import { Button } from '@/components/atoms/Button/Button'
|
||||||
|
import { Tabs, TabList, Tab, TabPanel } from '@/components/atoms/Tabs/Tabs'
|
||||||
|
import { NswLogo, TopBarAction } from '@/components/templates/_story-helpers'
|
||||||
|
import { Menu, Bell, Home, FileText, LayoutGrid, Users, Settings, Mail, Phone, MapPin, Edit } from 'lucide-react'
|
||||||
|
|
||||||
|
const meta: Meta<typeof DetailPage> = {
|
||||||
|
title: 'Templates/DetailPage',
|
||||||
|
component: DetailPage,
|
||||||
|
tags: ['autodocs', 'template'],
|
||||||
|
parameters: {
|
||||||
|
layout: 'fullscreen',
|
||||||
|
docs: {
|
||||||
|
description: {
|
||||||
|
component: 'Detail page template for viewing a single record, profile, or document. Single-column layout with constrained max-width for readability.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
export default meta
|
||||||
|
|
||||||
|
type Story = StoryObj<typeof DetailPage>
|
||||||
|
|
||||||
|
const InfoRow = ({ label, value, icon }: { label: string; value: string; icon: React.ReactNode }) => (
|
||||||
|
<div className="flex items-center gap-3 py-3">
|
||||||
|
<span className="flex size-8 shrink-0 items-center justify-center rounded-full bg-info/10 text-info [&>svg]:size-4">{icon}</span>
|
||||||
|
<div>
|
||||||
|
<p className="text-caption text-text-secondary">{label}</p>
|
||||||
|
<p className="text-body text-text">{value}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
export const ProfileView: Story = {
|
||||||
|
name: 'Profile detail',
|
||||||
|
render: () => {
|
||||||
|
const [collapsed, setCollapsed] = useState(false)
|
||||||
|
const [activeTab, setActiveTab] = useState('overview')
|
||||||
|
return (
|
||||||
|
<AppShell
|
||||||
|
topBar={
|
||||||
|
<TopBar
|
||||||
|
title="Team Directory"
|
||||||
|
leading={<TopBarAction icon={<Menu />} label="Menu" onClick={() => setCollapsed(!collapsed)} />}
|
||||||
|
logo={<NswLogo />}
|
||||||
|
>
|
||||||
|
<TopBarAction icon={<Bell />} label="Notifications" />
|
||||||
|
<Avatar initials="AB" size="sm" />
|
||||||
|
</TopBar>
|
||||||
|
}
|
||||||
|
sideNav={
|
||||||
|
<SideNav collapsed={collapsed}>
|
||||||
|
<SideNavItem icon={<Home />}>Home</SideNavItem>
|
||||||
|
<SideNavItem icon={<Users />} active>Team</SideNavItem>
|
||||||
|
<SideNavItem icon={<LayoutGrid />}>Projects</SideNavItem>
|
||||||
|
<SideNavItem icon={<FileText />}>Documents</SideNavItem>
|
||||||
|
<SideNavDivider />
|
||||||
|
<SideNavItem icon={<Settings />}>Settings</SideNavItem>
|
||||||
|
</SideNav>
|
||||||
|
}
|
||||||
|
sideNavCollapsed={collapsed}
|
||||||
|
>
|
||||||
|
<DetailPage
|
||||||
|
header={
|
||||||
|
<PageHeader title="Alex Chen" subtitle="Senior Engineer — Platform Team" theme="light">
|
||||||
|
<div className="mt-2 flex items-center gap-3">
|
||||||
|
<Badge variant="success">Active</Badge>
|
||||||
|
<Badge variant="info-light">Full-time</Badge>
|
||||||
|
</div>
|
||||||
|
</PageHeader>
|
||||||
|
}
|
||||||
|
actions={
|
||||||
|
<Tabs value={activeTab} onChange={setActiveTab}>
|
||||||
|
<TabList>
|
||||||
|
<Tab value="overview">Overview</Tab>
|
||||||
|
<Tab value="projects">Projects</Tab>
|
||||||
|
<Tab value="activity">Activity</Tab>
|
||||||
|
</TabList>
|
||||||
|
</Tabs>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Tabs value={activeTab} onChange={setActiveTab}>
|
||||||
|
<TabPanel value="overview">
|
||||||
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||||
|
<Card variant="surface" className="lg:col-span-2">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Contact information</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="divide-y divide-border">
|
||||||
|
<InfoRow icon={<Mail />} label="Email" value="alex.chen@example.com" />
|
||||||
|
<InfoRow icon={<Phone />} label="Phone" value="+61 2 9876 5432" />
|
||||||
|
<InfoRow icon={<MapPin />} label="Location" value="Sydney CBD, Level 12" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card variant="surface">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Quick actions</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-3">
|
||||||
|
<Button variant="secondary" leftIcon={<Mail size={18} />} className="w-full justify-start">Send email</Button>
|
||||||
|
<Button variant="secondary" leftIcon={<Edit size={18} />} className="w-full justify-start">Edit profile</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</TabPanel>
|
||||||
|
<TabPanel value="projects">
|
||||||
|
<Card variant="surface">
|
||||||
|
<CardContent className="p-8 text-center text-text-secondary">Project list content</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabPanel>
|
||||||
|
<TabPanel value="activity">
|
||||||
|
<Card variant="surface">
|
||||||
|
<CardContent className="p-8 text-center text-text-secondary">Activity feed content</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabPanel>
|
||||||
|
</Tabs>
|
||||||
|
</DetailPage>
|
||||||
|
</AppShell>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DocumentView: Story = {
|
||||||
|
name: 'Document detail',
|
||||||
|
render: () => (
|
||||||
|
<DetailPage
|
||||||
|
header={<PageHeader title="Project Brief" subtitle="Created 15 March 2024" noBackground />}
|
||||||
|
maxWidth="lg"
|
||||||
|
>
|
||||||
|
<Card variant="surface">
|
||||||
|
<CardContent className="space-y-4 p-6">
|
||||||
|
<h2 className="text-h3 font-bold text-text">Overview</h2>
|
||||||
|
<p className="text-body leading-relaxed text-text-secondary">
|
||||||
|
This document outlines the scope, objectives, and timeline for the upcoming platform migration project.
|
||||||
|
The project aims to consolidate three legacy systems into a single unified platform.
|
||||||
|
</p>
|
||||||
|
<h3 className="text-h4 font-bold text-text">Objectives</h3>
|
||||||
|
<ul className="list-disc space-y-2 pl-6 text-body text-text-secondary">
|
||||||
|
<li>Reduce operational costs by 40% through system consolidation</li>
|
||||||
|
<li>Improve data consistency across all business units</li>
|
||||||
|
<li>Provide a modern, accessible user interface</li>
|
||||||
|
<li>Enable real-time reporting and analytics</li>
|
||||||
|
</ul>
|
||||||
|
<h3 className="text-h4 font-bold text-text">Timeline</h3>
|
||||||
|
<p className="text-body leading-relaxed text-text-secondary">
|
||||||
|
The project is planned across three phases over 18 months, with the first phase targeting
|
||||||
|
core data migration and the second phase focusing on user-facing features.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</DetailPage>
|
||||||
|
),
|
||||||
|
}
|
||||||
43
src/components/templates/DetailPage/DetailPage.tsx
Normal file
43
src/components/templates/DetailPage/DetailPage.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { forwardRef, type HTMLAttributes, type ReactNode } from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export interface DetailPageProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
|
/** PageHeader or custom header section */
|
||||||
|
header?: ReactNode
|
||||||
|
/** Action bar below the header (e.g. tabs, buttons) */
|
||||||
|
actions?: ReactNode
|
||||||
|
/** Single-column content area (max-w constrained for readability) */
|
||||||
|
children: ReactNode
|
||||||
|
/** Max width of the content area */
|
||||||
|
maxWidth?: 'md' | 'lg' | 'xl' | 'full'
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxWidthStyles = {
|
||||||
|
md: 'max-w-2xl',
|
||||||
|
lg: 'max-w-3xl',
|
||||||
|
xl: 'max-w-5xl',
|
||||||
|
full: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DetailPage = forwardRef<HTMLDivElement, DetailPageProps>(
|
||||||
|
({ header, actions, maxWidth = 'xl', className, children, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<div ref={ref} className={cn('flex flex-col', className)} {...props}>
|
||||||
|
{header}
|
||||||
|
|
||||||
|
{actions && (
|
||||||
|
<div className="flex items-center gap-4 border-b border-border px-6 py-3">
|
||||||
|
{actions}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex-1 p-6">
|
||||||
|
<div className={cn(maxWidthStyles[maxWidth])}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
DetailPage.displayName = 'DetailPage'
|
||||||
2
src/components/templates/DetailPage/index.ts
Normal file
2
src/components/templates/DetailPage/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { DetailPage } from './DetailPage'
|
||||||
|
export type { DetailPageProps } from './DetailPage'
|
||||||
175
src/components/templates/FormPage/FormPage.stories.tsx
Normal file
175
src/components/templates/FormPage/FormPage.stories.tsx
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { FormPage } from './FormPage'
|
||||||
|
import { AppShell } from '@/components/templates/AppShell/AppShell'
|
||||||
|
import { TopBar } from '@/components/organisms/TopBar/TopBar'
|
||||||
|
import { SideNav, SideNavItem, SideNavGroup, SideNavDivider } from '@/components/organisms/SideNav/SideNav'
|
||||||
|
import { PageHeader } from '@/components/organisms/PageHeader/PageHeader'
|
||||||
|
import { Card, CardContent } from '@/components/molecules/Card/Card'
|
||||||
|
import { Alert } from '@/components/molecules/Alert/Alert'
|
||||||
|
import { Input } from '@/components/atoms/Input/Input'
|
||||||
|
import { Select } from '@/components/atoms/Select/Select'
|
||||||
|
import { Button } from '@/components/atoms/Button/Button'
|
||||||
|
import { Badge } from '@/components/atoms/Badge/Badge'
|
||||||
|
import { Avatar } from '@/components/atoms/Avatar/Avatar'
|
||||||
|
import { NswLogo, TopBarAction } from '@/components/templates/_story-helpers'
|
||||||
|
import { Menu, Bell, Home, FileText, LayoutGrid, Users, Link, ArrowRight, Settings } from 'lucide-react'
|
||||||
|
|
||||||
|
const meta: Meta<typeof FormPage> = {
|
||||||
|
title: 'Templates/FormPage',
|
||||||
|
component: FormPage,
|
||||||
|
tags: ['autodocs', 'template'],
|
||||||
|
parameters: {
|
||||||
|
layout: 'fullscreen',
|
||||||
|
docs: {
|
||||||
|
description: {
|
||||||
|
component: 'Form page template with optional vertical stepper and constrained-width form content. Use inside AppShell for the full page layout.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
export default meta
|
||||||
|
|
||||||
|
type Story = StoryObj<typeof FormPage>
|
||||||
|
|
||||||
|
export const WithStepper: Story = {
|
||||||
|
name: 'With stepper',
|
||||||
|
render: () => {
|
||||||
|
const [collapsed, setCollapsed] = useState(false)
|
||||||
|
return (
|
||||||
|
<AppShell
|
||||||
|
topBar={
|
||||||
|
<TopBar
|
||||||
|
title="Application Portal"
|
||||||
|
leading={<TopBarAction icon={<Menu />} label="Menu" onClick={() => setCollapsed(!collapsed)} />}
|
||||||
|
logo={<NswLogo />}
|
||||||
|
>
|
||||||
|
<TopBarAction icon={<Bell />} label="Notifications" />
|
||||||
|
<Avatar initials="SR" size="sm" />
|
||||||
|
</TopBar>
|
||||||
|
}
|
||||||
|
sideNav={
|
||||||
|
<SideNav collapsed={collapsed}>
|
||||||
|
<SideNavItem icon={<Home />}>Home</SideNavItem>
|
||||||
|
<SideNavItem icon={<Users />}>Profile</SideNavItem>
|
||||||
|
<SideNavItem icon={<LayoutGrid />}>Workspace</SideNavItem>
|
||||||
|
<SideNavItem icon={<Link />}>Resources</SideNavItem>
|
||||||
|
<SideNavItem icon={<FileText />}>Documents</SideNavItem>
|
||||||
|
<SideNavDivider />
|
||||||
|
<SideNavGroup icon={<FileText />} label="Applications" defaultOpen active>
|
||||||
|
<SideNavItem active>New application</SideNavItem>
|
||||||
|
<SideNavItem>Guidelines</SideNavItem>
|
||||||
|
<SideNavItem>History</SideNavItem>
|
||||||
|
<SideNavItem>Support</SideNavItem>
|
||||||
|
</SideNavGroup>
|
||||||
|
</SideNav>
|
||||||
|
}
|
||||||
|
sideNavCollapsed={collapsed}
|
||||||
|
>
|
||||||
|
<FormPage
|
||||||
|
header={
|
||||||
|
<PageHeader title="New Application" subtitle="Submit your application for review" theme="dark">
|
||||||
|
<div className="mt-2 flex items-center gap-4">
|
||||||
|
<Badge variant="warning">In progress</Badge>
|
||||||
|
</div>
|
||||||
|
</PageHeader>
|
||||||
|
}
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<Select
|
||||||
|
label=""
|
||||||
|
variant="stacked"
|
||||||
|
options={[{ value: '2026', label: '2026 — Application draft' }]}
|
||||||
|
defaultValue="2026"
|
||||||
|
/>
|
||||||
|
<Button variant="secondary">More actions</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
steps={[
|
||||||
|
{ label: 'Your details', status: 'current' },
|
||||||
|
{ label: 'Supporting documents', status: 'upcoming' },
|
||||||
|
{ label: 'Review & submit', status: 'upcoming' },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Card variant="surface">
|
||||||
|
<CardContent className="space-y-6 p-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-h3 font-bold text-text">Your details</h2>
|
||||||
|
<p className="mt-2 text-body text-text-secondary">
|
||||||
|
Provide the information below to begin your application. You can save and return at any time.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Alert variant="info" title="Before you start">
|
||||||
|
Make sure you have your identification documents and contact details ready.
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Input label="Full name" placeholder="Enter your full name" />
|
||||||
|
<Input label="Email address" type="email" placeholder="you@example.com" />
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-body font-semibold text-text">Role information</p>
|
||||||
|
<p className="mb-2 text-small text-text-secondary">Select the role that best describes your position.</p>
|
||||||
|
<Select
|
||||||
|
label="Role type"
|
||||||
|
options={[
|
||||||
|
{ value: 'manager', label: 'Manager' },
|
||||||
|
{ value: 'coordinator', label: 'Coordinator' },
|
||||||
|
{ value: 'specialist', label: 'Specialist' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-body font-semibold text-text">Supervisor details</p>
|
||||||
|
<p className="mb-2 text-small text-text-secondary">
|
||||||
|
Enter your supervisor's contact details for verification.
|
||||||
|
</p>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Input label="Supervisor email" type="email" placeholder="supervisor@example.com" />
|
||||||
|
<Input label="Supervisor location" placeholder="Office or site name" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Input label="Work location" placeholder="Your primary work location" />
|
||||||
|
|
||||||
|
<div className="flex justify-start pt-2">
|
||||||
|
<Button rightIcon={<ArrowRight size={18} />}>Proceed</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</FormPage>
|
||||||
|
</AppShell>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SimpleForm: Story = {
|
||||||
|
name: 'Simple form (no stepper)',
|
||||||
|
render: () => (
|
||||||
|
<FormPage
|
||||||
|
header={<PageHeader title="Create Account" subtitle="Set up your profile to get started" />}
|
||||||
|
>
|
||||||
|
<Card variant="surface">
|
||||||
|
<CardContent className="space-y-4 p-6">
|
||||||
|
<Input label="Full name" placeholder="Enter your full name" />
|
||||||
|
<Input label="Email address" type="email" placeholder="you@example.com" />
|
||||||
|
<Select
|
||||||
|
label="Role"
|
||||||
|
options={[
|
||||||
|
{ value: 'viewer', label: 'Viewer' },
|
||||||
|
{ value: 'editor', label: 'Editor' },
|
||||||
|
{ value: 'admin', label: 'Administrator' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end gap-3 pt-4">
|
||||||
|
<Button variant="tertiary">Cancel</Button>
|
||||||
|
<Button>Create Account</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</FormPage>
|
||||||
|
),
|
||||||
|
}
|
||||||
81
src/components/templates/FormPage/FormPage.tsx
Normal file
81
src/components/templates/FormPage/FormPage.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { forwardRef, type HTMLAttributes, type ReactNode } from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export interface FormPageStep {
|
||||||
|
label: string
|
||||||
|
status: 'complete' | 'current' | 'upcoming'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FormPageProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
|
/** PageHeader or custom header section */
|
||||||
|
header?: ReactNode
|
||||||
|
/** Action bar above the form content (e.g. dropdowns, buttons) */
|
||||||
|
actions?: ReactNode
|
||||||
|
/** Vertical stepper steps — renders a progress indicator alongside the form */
|
||||||
|
steps?: FormPageStep[]
|
||||||
|
/** Form content area */
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
const StepIndicator = ({ step, index }: { step: FormPageStep; index: number }) => {
|
||||||
|
const base = 'flex size-8 shrink-0 items-center justify-center rounded-full text-small font-bold'
|
||||||
|
const styles = {
|
||||||
|
complete: 'bg-success text-white',
|
||||||
|
current: 'bg-info text-white',
|
||||||
|
upcoming: 'bg-grey-04 text-text-secondary',
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className={cn(base, styles[step.status])}>
|
||||||
|
{step.status === 'complete' ? (
|
||||||
|
<svg className="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={3} strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<polyline points="20 6 9 17 4 12" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
index + 1
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className={cn(
|
||||||
|
'pt-1 text-small',
|
||||||
|
step.status === 'current' ? 'font-bold text-text' : 'text-text-secondary',
|
||||||
|
)}>
|
||||||
|
{step.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FormPage = forwardRef<HTMLDivElement, FormPageProps>(
|
||||||
|
({ header, actions, steps, className, children, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<div ref={ref} className={cn('flex flex-col', className)} {...props}>
|
||||||
|
{header}
|
||||||
|
|
||||||
|
{actions && (
|
||||||
|
<div className="flex items-center justify-between gap-4 border-b border-border px-6 py-3">
|
||||||
|
{actions}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex-1 p-6">
|
||||||
|
<div className="mx-auto max-w-3xl">
|
||||||
|
{steps ? (
|
||||||
|
<div className="flex gap-8">
|
||||||
|
<nav className="flex w-48 shrink-0 flex-col gap-4" aria-label="Form steps">
|
||||||
|
{steps.map((step, i) => (
|
||||||
|
<StepIndicator key={step.label} step={step} index={i} />
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
<div className="min-w-0 flex-1">{children}</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
children
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
FormPage.displayName = 'FormPage'
|
||||||
2
src/components/templates/FormPage/index.ts
Normal file
2
src/components/templates/FormPage/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { FormPage } from './FormPage'
|
||||||
|
export type { FormPageProps, FormPageStep } from './FormPage'
|
||||||
139
src/components/templates/ListPage/ListPage.stories.tsx
Normal file
139
src/components/templates/ListPage/ListPage.stories.tsx
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { ListPage } from './ListPage'
|
||||||
|
import { AppShell } from '@/components/templates/AppShell/AppShell'
|
||||||
|
import { TopBar } from '@/components/organisms/TopBar/TopBar'
|
||||||
|
import { SideNav, SideNavItem, SideNavDivider } from '@/components/organisms/SideNav/SideNav'
|
||||||
|
import { PageHeader } from '@/components/organisms/PageHeader/PageHeader'
|
||||||
|
import { Card, CardContent } from '@/components/molecules/Card/Card'
|
||||||
|
import { Badge } from '@/components/atoms/Badge/Badge'
|
||||||
|
import { Tag } from '@/components/atoms/Tag/Tag'
|
||||||
|
import { Button } from '@/components/atoms/Button/Button'
|
||||||
|
import { Avatar } from '@/components/atoms/Avatar/Avatar'
|
||||||
|
import { NswLogo, TopBarAction } from '@/components/templates/_story-helpers'
|
||||||
|
import { Menu, Bell, Home, LayoutGrid, FileText, Users, Clock, BarChart3, Plus, Check } from 'lucide-react'
|
||||||
|
|
||||||
|
const ListItem = ({ title, subtitle, status, date, tags }: { title: string; subtitle: string; status: string; date: string; tags: string[] }) => (
|
||||||
|
<div className="flex flex-col gap-2 px-6 py-4">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<a href="#" className="text-body font-semibold text-info hover:underline">{title}</a>
|
||||||
|
<div className="flex shrink-0 flex-col items-end gap-1 text-small text-text-secondary">
|
||||||
|
<span>{date}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Badge variant="success" leftIcon={<Check size={14} />}>{status}</Badge>
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<Tag key={tag} color="blue" variant="filled" size="sm">{tag}</Tag>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-small text-text-secondary">{subtitle}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const meta: Meta<typeof ListPage> = {
|
||||||
|
title: 'Templates/ListPage',
|
||||||
|
component: ListPage,
|
||||||
|
tags: ['autodocs', 'template'],
|
||||||
|
parameters: {
|
||||||
|
layout: 'fullscreen',
|
||||||
|
docs: {
|
||||||
|
description: {
|
||||||
|
component: 'List page template with stat summary row, list header with actions, and a scrollable item list. Use inside AppShell for the full page layout.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
export default meta
|
||||||
|
|
||||||
|
type Story = StoryObj<typeof ListPage>
|
||||||
|
|
||||||
|
export const WithAppShell: Story = {
|
||||||
|
name: 'Full page',
|
||||||
|
render: () => {
|
||||||
|
const [collapsed, setCollapsed] = useState(false)
|
||||||
|
return (
|
||||||
|
<AppShell
|
||||||
|
topBar={
|
||||||
|
<TopBar
|
||||||
|
title="Activity Tracker"
|
||||||
|
leading={<TopBarAction icon={<Menu />} label="Menu" onClick={() => setCollapsed(!collapsed)} />}
|
||||||
|
logo={<NswLogo />}
|
||||||
|
>
|
||||||
|
<TopBarAction icon={<Bell />} label="Notifications" />
|
||||||
|
<Avatar initials="JD" size="sm" />
|
||||||
|
</TopBar>
|
||||||
|
}
|
||||||
|
sideNav={
|
||||||
|
<SideNav collapsed={collapsed}>
|
||||||
|
<SideNavItem icon={<Home />} active>Home</SideNavItem>
|
||||||
|
<SideNavItem icon={<LayoutGrid />}>Workspace</SideNavItem>
|
||||||
|
<SideNavItem icon={<FileText />}>Reports</SideNavItem>
|
||||||
|
<SideNavDivider />
|
||||||
|
<SideNavItem icon={<Users />}>Team</SideNavItem>
|
||||||
|
</SideNav>
|
||||||
|
}
|
||||||
|
sideNavCollapsed={collapsed}
|
||||||
|
>
|
||||||
|
<ListPage
|
||||||
|
header={<PageHeader title="Activity Log" subtitle="Track and manage your recorded activities" theme="dark" />}
|
||||||
|
stats={
|
||||||
|
<>
|
||||||
|
<Card variant="surface" className="min-w-[180px] flex-1">
|
||||||
|
<CardContent className="flex items-center gap-4 p-5">
|
||||||
|
<div className="flex size-10 shrink-0 items-center justify-center rounded-full bg-info/10 text-info"><Clock size={20} /></div>
|
||||||
|
<div>
|
||||||
|
<p className="text-h3 font-bold text-text">64h</p>
|
||||||
|
<p className="text-small text-text-secondary">Total hours</p>
|
||||||
|
<p className="text-caption text-text-secondary">Target 100h</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card variant="surface" className="min-w-[180px] flex-1">
|
||||||
|
<CardContent className="flex items-center gap-4 p-5">
|
||||||
|
<div className="flex size-10 shrink-0 items-center justify-center rounded-full bg-info/10 text-info"><BarChart3 size={20} /></div>
|
||||||
|
<div>
|
||||||
|
<p className="text-h3 font-bold text-text">12</p>
|
||||||
|
<p className="text-small text-text-secondary">Activities logged</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
listHeader={
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-h4 font-bold text-text">Recent activities</h2>
|
||||||
|
<p className="text-small text-text-secondary">All recorded activities for the current period.</p>
|
||||||
|
</div>
|
||||||
|
<Button leftIcon={<Plus size={18} />}>Add Activity</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ListItem title="Quarterly compliance review" subtitle="Completed annual review of team compliance requirements and documentation." status="Approved" date="2024-03-15" tags={['Compliance', 'Q1']} />
|
||||||
|
<ListItem title="Team workshop: Data governance" subtitle="Facilitated a half-day workshop on data governance best practices." status="Approved" date="2024-03-10" tags={['Training', 'Data']} />
|
||||||
|
<ListItem title="System migration planning" subtitle="Documented migration plan for the legacy CRM to the new platform." status="Approved" date="2024-02-28" tags={['Infrastructure', 'Planning']} />
|
||||||
|
<ListItem title="New starter onboarding session" subtitle="Conducted orientation for three new team members joining the project." status="Approved" date="2024-02-20" tags={['Onboarding']} />
|
||||||
|
</ListPage>
|
||||||
|
</AppShell>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Standalone: Story = {
|
||||||
|
name: 'Content only',
|
||||||
|
render: () => (
|
||||||
|
<ListPage
|
||||||
|
header={<PageHeader title="Activity Log" subtitle="Your recent activities" />}
|
||||||
|
listHeader={
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-h4 font-bold text-text">Activities</h2>
|
||||||
|
<Button size="compact" leftIcon={<Plus size={18} />}>Add</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ListItem title="Sample activity" subtitle="A brief description of this activity." status="Complete" date="2024-03-15" tags={['Example']} />
|
||||||
|
<ListItem title="Another activity" subtitle="Another brief description." status="Complete" date="2024-03-10" tags={['Example']} />
|
||||||
|
</ListPage>
|
||||||
|
),
|
||||||
|
}
|
||||||
36
src/components/templates/ListPage/ListPage.tsx
Normal file
36
src/components/templates/ListPage/ListPage.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { forwardRef, type HTMLAttributes, type ReactNode } from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export interface ListPageProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
|
/** PageHeader or custom header section */
|
||||||
|
header?: ReactNode
|
||||||
|
/** Row of stat cards or summary widgets */
|
||||||
|
stats?: ReactNode
|
||||||
|
/** Section header area with title and optional action (e.g. "Add Activity" button) */
|
||||||
|
listHeader?: ReactNode
|
||||||
|
/** Scrollable list content area */
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ListPage = forwardRef<HTMLDivElement, ListPageProps>(
|
||||||
|
({ header, stats, listHeader, className, children, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<div ref={ref} className={cn('flex flex-col', className)} {...props}>
|
||||||
|
{header}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-6 p-6">
|
||||||
|
{stats && <div className="flex flex-wrap gap-4">{stats}</div>}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{listHeader}
|
||||||
|
|
||||||
|
<div className="divide-y divide-border rounded-lg bg-surface">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
ListPage.displayName = 'ListPage'
|
||||||
2
src/components/templates/ListPage/index.ts
Normal file
2
src/components/templates/ListPage/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { ListPage } from './ListPage'
|
||||||
|
export type { ListPageProps } from './ListPage'
|
||||||
18
src/components/templates/_story-helpers.tsx
Normal file
18
src/components/templates/_story-helpers.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { type ReactNode } from 'react'
|
||||||
|
|
||||||
|
export const NswLogo = () => (
|
||||||
|
<img src="/nsw-logo.svg" alt="NSW Government" className="h-6" />
|
||||||
|
)
|
||||||
|
|
||||||
|
export function TopBarAction({ icon, label, onClick }: { icon: ReactNode; label: string; onClick?: () => void }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={label}
|
||||||
|
onClick={onClick}
|
||||||
|
className="flex size-12 items-center justify-center rounded-full text-white/80 transition-colors hover:bg-white/10 hover:text-white"
|
||||||
|
>
|
||||||
|
<span className="size-6 [&>svg]:size-full">{icon}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
143
src/index.ts
Normal file
143
src/index.ts
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
// Utilities
|
||||||
|
export { cn } from './lib/utils'
|
||||||
|
|
||||||
|
// Atoms
|
||||||
|
export { Button } from './components/atoms/Button'
|
||||||
|
export type { ButtonProps } from './components/atoms/Button'
|
||||||
|
|
||||||
|
export { IconButton } from './components/atoms/IconButton'
|
||||||
|
export type { IconButtonProps } from './components/atoms/IconButton'
|
||||||
|
|
||||||
|
export { Input } from './components/atoms/Input'
|
||||||
|
export type { InputProps } from './components/atoms/Input'
|
||||||
|
|
||||||
|
export { Textarea } from './components/atoms/Textarea'
|
||||||
|
export type { TextareaProps } from './components/atoms/Textarea'
|
||||||
|
|
||||||
|
export { Select } from './components/atoms/Select'
|
||||||
|
export type { SelectProps, SelectOption } from './components/atoms/Select'
|
||||||
|
|
||||||
|
export { Autocomplete } from './components/atoms/Autocomplete'
|
||||||
|
export type { AutocompleteProps, AutocompleteOption } from './components/atoms/Autocomplete'
|
||||||
|
|
||||||
|
export { Checkbox } from './components/atoms/Checkbox'
|
||||||
|
export type { CheckboxProps } from './components/atoms/Checkbox'
|
||||||
|
|
||||||
|
export { Radio, RadioGroup } from './components/atoms/Radio'
|
||||||
|
export type { RadioProps, RadioGroupProps } from './components/atoms/Radio'
|
||||||
|
|
||||||
|
export { Switch } from './components/atoms/Switch'
|
||||||
|
export type { SwitchProps } from './components/atoms/Switch'
|
||||||
|
|
||||||
|
export { Slider, RangeSlider } from './components/atoms/Slider'
|
||||||
|
export type { SliderProps, RangeSliderProps } from './components/atoms/Slider'
|
||||||
|
|
||||||
|
export { FileInput } from './components/atoms/FileInput'
|
||||||
|
export type { FileInputProps } from './components/atoms/FileInput'
|
||||||
|
|
||||||
|
export { Badge } from './components/atoms/Badge'
|
||||||
|
export type { BadgeProps } from './components/atoms/Badge'
|
||||||
|
|
||||||
|
export { Tag } from './components/atoms/Tag'
|
||||||
|
export type { TagProps, TagColor } from './components/atoms/Tag'
|
||||||
|
|
||||||
|
export { Chip } from './components/atoms/Chip'
|
||||||
|
export type { ChipProps, ChipColor } from './components/atoms/Chip'
|
||||||
|
|
||||||
|
export { Tabs, TabList, Tab, TabPanel } from './components/atoms/Tabs'
|
||||||
|
export type { TabsProps, TabListProps, TabProps, TabPanelProps } from './components/atoms/Tabs'
|
||||||
|
|
||||||
|
export { List, ListItem, ListSubheader, ListDivider } from './components/atoms/List'
|
||||||
|
export type { ListProps, ListItemProps, ListSubheaderProps, ListDividerProps } from './components/atoms/List'
|
||||||
|
|
||||||
|
export { Avatar } from './components/atoms/Avatar'
|
||||||
|
export type { AvatarProps } from './components/atoms/Avatar'
|
||||||
|
|
||||||
|
export { Tooltip } from './components/atoms/Tooltip'
|
||||||
|
export type { TooltipProps } from './components/atoms/Tooltip'
|
||||||
|
|
||||||
|
// Molecules
|
||||||
|
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './components/molecules/Card'
|
||||||
|
export type {
|
||||||
|
CardProps,
|
||||||
|
CardHeaderProps,
|
||||||
|
CardTitleProps,
|
||||||
|
CardDescriptionProps,
|
||||||
|
CardContentProps,
|
||||||
|
CardFooterProps,
|
||||||
|
} from './components/molecules/Card'
|
||||||
|
|
||||||
|
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from './components/molecules/Accordion'
|
||||||
|
export type {
|
||||||
|
AccordionProps,
|
||||||
|
AccordionItemProps,
|
||||||
|
AccordionTriggerProps,
|
||||||
|
AccordionContentProps,
|
||||||
|
} from './components/molecules/Accordion'
|
||||||
|
|
||||||
|
export { Alert } from './components/molecules/Alert'
|
||||||
|
export type { AlertProps, AlertVariant } from './components/molecules/Alert'
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogClose,
|
||||||
|
} from './components/molecules/Dialog'
|
||||||
|
export type {
|
||||||
|
DialogProps,
|
||||||
|
DialogHeaderProps,
|
||||||
|
DialogTitleProps,
|
||||||
|
DialogDescriptionProps,
|
||||||
|
DialogContentProps,
|
||||||
|
DialogFooterProps,
|
||||||
|
DialogCloseProps,
|
||||||
|
} from './components/molecules/Dialog'
|
||||||
|
|
||||||
|
export { Popover, PopoverTrigger, PopoverContent, PopoverClose } from './components/molecules/Popover'
|
||||||
|
export type {
|
||||||
|
PopoverProps,
|
||||||
|
PopoverTriggerProps,
|
||||||
|
PopoverContentProps,
|
||||||
|
PopoverCloseProps,
|
||||||
|
} from './components/molecules/Popover'
|
||||||
|
|
||||||
|
export { DataTable } from './components/molecules/DataTable'
|
||||||
|
export type { DataTableProps, DataTableColumn } from './components/molecules/DataTable'
|
||||||
|
|
||||||
|
// Organisms
|
||||||
|
export { TopBar } from './components/organisms/TopBar'
|
||||||
|
export type { TopBarProps } from './components/organisms/TopBar'
|
||||||
|
|
||||||
|
export { SideNav, SideNavItem, SideNavGroup, SideNavDivider } from './components/organisms/SideNav'
|
||||||
|
export type {
|
||||||
|
SideNavProps,
|
||||||
|
SideNavItemProps,
|
||||||
|
SideNavGroupProps,
|
||||||
|
SideNavDividerProps,
|
||||||
|
} from './components/organisms/SideNav'
|
||||||
|
|
||||||
|
export { PageHeader } from './components/organisms/PageHeader'
|
||||||
|
export type { PageHeaderProps } from './components/organisms/PageHeader'
|
||||||
|
|
||||||
|
// Templates
|
||||||
|
export { AppShell } from './components/templates/AppShell'
|
||||||
|
export type { AppShellProps } from './components/templates/AppShell'
|
||||||
|
|
||||||
|
export { DashboardPage } from './components/templates/DashboardPage'
|
||||||
|
export type { DashboardPageProps } from './components/templates/DashboardPage'
|
||||||
|
|
||||||
|
export { ListPage } from './components/templates/ListPage'
|
||||||
|
export type { ListPageProps } from './components/templates/ListPage'
|
||||||
|
|
||||||
|
export { FormPage } from './components/templates/FormPage'
|
||||||
|
export type { FormPageProps, FormPageStep } from './components/templates/FormPage'
|
||||||
|
|
||||||
|
export { DetailPage } from './components/templates/DetailPage'
|
||||||
|
export type { DetailPageProps } from './components/templates/DetailPage'
|
||||||
|
|
||||||
|
export { CenteredPage } from './components/templates/CenteredPage'
|
||||||
|
export type { CenteredPageProps } from './components/templates/CenteredPage'
|
||||||
@@ -12,3 +12,19 @@
|
|||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--text-h1-responsive: 2.25rem;
|
||||||
|
--text-h2-responsive: 1.75rem;
|
||||||
|
--text-h3-responsive: 1.375rem;
|
||||||
|
--text-h4-responsive: 1.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 960px) {
|
||||||
|
:root {
|
||||||
|
--text-h1-responsive: 3rem;
|
||||||
|
--text-h2-responsive: 2rem;
|
||||||
|
--text-h3-responsive: 1.5rem;
|
||||||
|
--text-h4-responsive: 1.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
line-height: var(--text-body--line-height);
|
line-height: var(--text-body--line-height);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
text-decoration-line: underline;
|
text-decoration-line: underline;
|
||||||
color: var(--color-blue-02);
|
color: var(--color-info);
|
||||||
}
|
}
|
||||||
|
|
||||||
@utility text-small-link {
|
@utility text-small-link {
|
||||||
@@ -37,5 +37,35 @@
|
|||||||
line-height: var(--text-small--line-height);
|
line-height: var(--text-small--line-height);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
text-decoration-line: underline;
|
text-decoration-line: underline;
|
||||||
color: var(--color-blue-02);
|
color: var(--color-info);
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility text-button {
|
||||||
|
font-size: var(--text-body);
|
||||||
|
line-height: calc(19 / 16);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility text-h1-responsive {
|
||||||
|
font-size: var(--text-h1-responsive);
|
||||||
|
line-height: 1.25;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility text-h2-responsive {
|
||||||
|
font-size: var(--text-h2-responsive);
|
||||||
|
line-height: 1.25;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility text-h3-responsive {
|
||||||
|
font-size: var(--text-h3-responsive);
|
||||||
|
line-height: 1.333;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility text-h4-responsive {
|
||||||
|
font-size: var(--text-h4-responsive);
|
||||||
|
line-height: 1.4;
|
||||||
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,47 +27,76 @@
|
|||||||
/* Blues */
|
/* Blues */
|
||||||
--color-blue-01: #002664;
|
--color-blue-01: #002664;
|
||||||
--color-blue-02: #146CFD;
|
--color-blue-02: #146CFD;
|
||||||
--color-blue-03: #69B3E7;
|
--color-blue-03: #8CE0FF;
|
||||||
--color-blue-04: #CBEDFD;
|
--color-blue-04: #CBEDFD;
|
||||||
--color-blue-05: #EBF5FF; /* extrapolated: ultra-light background */
|
|
||||||
|
|
||||||
/* Reds */
|
/* Reds */
|
||||||
--color-red-01: #3E0014;
|
--color-red-01: #630019;
|
||||||
--color-red-02: #D7153A;
|
--color-red-02: #D7153A;
|
||||||
--color-red-03: #F5C5D0;
|
--color-red-03: #FFB8C1;
|
||||||
--color-red-04: #FDDDE5;
|
--color-red-04: #FFE6EA;
|
||||||
--color-red-05: #FFF5F8; /* extrapolated: ultra-light background */
|
|
||||||
|
|
||||||
/* Oranges */
|
/* Oranges */
|
||||||
--color-orange-01: #7A3300; /* extrapolated: dark */
|
--color-orange-01: #941B00;
|
||||||
--color-orange-02: #EC6608;
|
--color-orange-02: #F3631B;
|
||||||
--color-orange-03: #F5B98A;
|
--color-orange-03: #FFCE99;
|
||||||
--color-orange-04: #FEF0E4; /* extrapolated: light background */
|
--color-orange-04: #FDEDDF;
|
||||||
|
|
||||||
/* Greens */
|
/* Greens */
|
||||||
--color-green-01: #005C35; /* extrapolated: dark */
|
--color-green-01: #004000;
|
||||||
--color-green-02: #00A651;
|
--color-green-02: #00AA45;
|
||||||
--color-green-03: #89E5B3;
|
--color-green-03: #A8EDB3;
|
||||||
--color-green-04: #E0F8EA; /* extrapolated: light background */
|
--color-green-04: #DBFADF;
|
||||||
|
|
||||||
|
/* Teals */
|
||||||
|
--color-teal-01: #0B3F47;
|
||||||
|
--color-teal-02: #2E808E;
|
||||||
|
--color-teal-03: #8CDBE5;
|
||||||
|
--color-teal-04: #D1EEEA;
|
||||||
|
|
||||||
|
/* Browns */
|
||||||
|
--color-brown-01: #523719;
|
||||||
|
--color-brown-02: #B68D5D;
|
||||||
|
--color-brown-03: #E8D0B5;
|
||||||
|
--color-brown-04: #EDE3D7;
|
||||||
|
|
||||||
|
/* Purples */
|
||||||
|
--color-purple-01: #441170;
|
||||||
|
--color-purple-02: #8055F1;
|
||||||
|
--color-purple-03: #CEBFFF;
|
||||||
|
--color-purple-04: #E6E1FD;
|
||||||
|
|
||||||
|
/* Fuchsias */
|
||||||
|
--color-fuchsia-01: #65004D;
|
||||||
|
--color-fuchsia-02: #D912AE;
|
||||||
|
--color-fuchsia-03: #F4B5E6;
|
||||||
|
--color-fuchsia-04: #FDDEF2;
|
||||||
|
|
||||||
|
/* Yellows */
|
||||||
|
--color-yellow-01: #694800;
|
||||||
|
--color-yellow-02: #FAAF05;
|
||||||
|
--color-yellow-03: #FDE79A;
|
||||||
|
--color-yellow-04: #FFF4CF;
|
||||||
|
|
||||||
/* Greys */
|
/* Greys */
|
||||||
--color-grey-01: #22272B;
|
--color-grey-01: #22272B;
|
||||||
--color-grey-02: #6D7278;
|
--color-grey-02: #495054;
|
||||||
--color-grey-03: #C0C0C0;
|
--color-grey-03: #CDD3D6;
|
||||||
--color-grey-04: #E0E0E0;
|
--color-grey-04: #EBEBEB;
|
||||||
--color-off-white: #F4F4F4;
|
--color-grey-05: #F2F2F2;
|
||||||
--color-white: #FFFFFF;
|
--color-white: #FFFFFF;
|
||||||
|
|
||||||
/* Semantic Aliases */
|
/* Semantic Aliases */
|
||||||
--color-primary: var(--color-blue-02);
|
--color-primary: var(--color-blue-01);
|
||||||
--color-primary-dark: var(--color-blue-01);
|
--color-info: var(--color-blue-02);
|
||||||
|
--color-secondary: var(--color-blue-04);
|
||||||
--color-error: var(--color-red-02);
|
--color-error: var(--color-red-02);
|
||||||
--color-success: var(--color-green-02);
|
--color-success: var(--color-green-02);
|
||||||
--color-warning: var(--color-orange-02);
|
--color-warning: var(--color-orange-02);
|
||||||
--color-text: var(--color-grey-01);
|
--color-text: var(--color-grey-01);
|
||||||
--color-text-secondary: var(--color-grey-02);
|
--color-text-secondary: var(--color-grey-02);
|
||||||
--color-border: var(--color-grey-04);
|
--color-border: var(--color-grey-04);
|
||||||
--color-bg: var(--color-off-white);
|
--color-bg: var(--color-grey-05);
|
||||||
--color-surface: var(--color-white);
|
--color-surface: var(--color-white);
|
||||||
|
|
||||||
/* Form Controls */
|
/* Form Controls */
|
||||||
@@ -80,7 +109,7 @@
|
|||||||
--color-control-description: var(--color-grey-02);
|
--color-control-description: var(--color-grey-02);
|
||||||
--color-control-error: var(--color-red-02);
|
--color-control-error: var(--color-red-02);
|
||||||
--color-control-bg: var(--color-white);
|
--color-control-bg: var(--color-white);
|
||||||
--color-control-bg-readonly: var(--color-off-white);
|
--color-control-bg-readonly: var(--color-grey-05);
|
||||||
|
|
||||||
/* Button */
|
/* Button */
|
||||||
--color-button-default: var(--color-blue-01);
|
--color-button-default: var(--color-blue-01);
|
||||||
@@ -89,6 +118,10 @@
|
|||||||
--color-button-subtle-bg: var(--color-blue-04);
|
--color-button-subtle-bg: var(--color-blue-04);
|
||||||
--color-button-subtle-text: var(--color-blue-01);
|
--color-button-subtle-text: var(--color-blue-01);
|
||||||
|
|
||||||
|
/* Switch */
|
||||||
|
--color-switch-on: var(--color-success);
|
||||||
|
--color-switch-on-hover: var(--color-green-01);
|
||||||
|
|
||||||
/* Badge */
|
/* Badge */
|
||||||
--color-badge-navy: var(--color-blue-01);
|
--color-badge-navy: var(--color-blue-01);
|
||||||
--color-badge-info: var(--color-blue-02);
|
--color-badge-info: var(--color-blue-02);
|
||||||
@@ -124,29 +157,53 @@
|
|||||||
--color-tag-orange-light: var(--color-orange-04);
|
--color-tag-orange-light: var(--color-orange-04);
|
||||||
--color-tag-grey: var(--color-grey-02);
|
--color-tag-grey: var(--color-grey-02);
|
||||||
--color-tag-grey-light: var(--color-grey-04);
|
--color-tag-grey-light: var(--color-grey-04);
|
||||||
|
--color-tag-teal: var(--color-teal-01);
|
||||||
|
--color-tag-teal-light: var(--color-teal-04);
|
||||||
|
--color-tag-brown: var(--color-brown-01);
|
||||||
|
--color-tag-brown-light: var(--color-brown-04);
|
||||||
|
--color-tag-purple: var(--color-purple-01);
|
||||||
|
--color-tag-purple-light: var(--color-purple-04);
|
||||||
|
--color-tag-fuchsia: var(--color-fuchsia-01);
|
||||||
|
--color-tag-fuchsia-light: var(--color-fuchsia-04);
|
||||||
|
--color-tag-yellow: var(--color-yellow-01);
|
||||||
|
--color-tag-yellow-light: var(--color-yellow-04);
|
||||||
|
|
||||||
|
/* Avatar */
|
||||||
|
--color-avatar: var(--color-blue-04);
|
||||||
|
--color-avatar-text: var(--color-grey-01);
|
||||||
|
|
||||||
|
/* TopBar */
|
||||||
|
--color-topbar: var(--color-blue-01);
|
||||||
|
|
||||||
|
/* SideNav */
|
||||||
|
--color-nav-bg: var(--color-white);
|
||||||
|
--color-nav-text: var(--color-grey-01);
|
||||||
|
--color-nav-icon: var(--color-blue-01);
|
||||||
|
--color-nav-active: var(--color-info);
|
||||||
|
--color-nav-divider: var(--color-grey-03);
|
||||||
|
|
||||||
/* Alert */
|
/* Alert */
|
||||||
--color-alert-info-bg: var(--color-blue-05);
|
--color-alert-info-bg: var(--color-blue-04);
|
||||||
--color-alert-info-border: var(--color-blue-02);
|
--color-alert-info-border: var(--color-blue-02);
|
||||||
--color-alert-info-icon: var(--color-blue-02);
|
--color-alert-info-icon: var(--color-blue-02);
|
||||||
--color-alert-warning-bg: var(--color-orange-04);
|
--color-alert-warning-bg: var(--color-orange-04);
|
||||||
--color-alert-warning-border: var(--color-orange-02);
|
--color-alert-warning-border: var(--color-orange-02);
|
||||||
--color-alert-warning-icon: var(--color-orange-02);
|
--color-alert-warning-icon: var(--color-orange-02);
|
||||||
--color-alert-error-bg: var(--color-red-05);
|
--color-alert-error-bg: var(--color-red-04);
|
||||||
--color-alert-error-border: var(--color-red-02);
|
--color-alert-error-border: var(--color-red-02);
|
||||||
--color-alert-error-icon: var(--color-red-02);
|
--color-alert-error-icon: var(--color-red-02);
|
||||||
--color-alert-success-bg: var(--color-green-04);
|
--color-alert-success-bg: var(--color-green-04);
|
||||||
--color-alert-success-border: var(--color-green-02);
|
--color-alert-success-border: var(--color-green-02);
|
||||||
--color-alert-success-icon: var(--color-green-02);
|
--color-alert-success-icon: var(--color-green-02);
|
||||||
--color-alert-neutral-bg: var(--color-off-white);
|
--color-alert-neutral-bg: var(--color-grey-05);
|
||||||
--color-alert-neutral-border: var(--color-grey-03);
|
--color-alert-neutral-border: var(--color-grey-03);
|
||||||
--color-alert-neutral-icon: var(--color-blue-01);
|
--color-alert-neutral-icon: var(--color-blue-01);
|
||||||
|
|
||||||
/* Radius */
|
/* Radius */
|
||||||
--radius-sm: 4px;
|
--radius-sm: 4px;
|
||||||
--radius-default: 6px;
|
--radius-default: 8px;
|
||||||
--radius-lg: 10px;
|
--radius-lg: 16px;
|
||||||
--radius-xl: 16px;
|
--radius-xl: 24px;
|
||||||
--radius-full: 9999px;
|
--radius-full: 9999px;
|
||||||
|
|
||||||
/* Shadows */
|
/* Shadows */
|
||||||
|
|||||||
47
vite.lib.config.ts
Normal file
47
vite.lib.config.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import dts from 'vite-plugin-dts'
|
||||||
|
import { resolve } from 'node:path'
|
||||||
|
|
||||||
|
// Library build for distribution as an installable package (@geljic/ads3-design-system).
|
||||||
|
// Kept separate from vite.config.ts so the Storybook + Vitest setup there stays untouched.
|
||||||
|
// Build with: npm run build:lib
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
react(),
|
||||||
|
dts({
|
||||||
|
tsconfigPath: './tsconfig.app.json',
|
||||||
|
// Emit `dist/index.d.ts` (+ per-file declarations) rather than nesting under dist/src.
|
||||||
|
entryRoot: 'src',
|
||||||
|
include: ['src'],
|
||||||
|
exclude: [
|
||||||
|
'src/**/*.stories.tsx',
|
||||||
|
'src/**/*.test.tsx',
|
||||||
|
'src/main.tsx',
|
||||||
|
'src/App.tsx',
|
||||||
|
'src/**/_story-helpers*',
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
resolve: {
|
||||||
|
// Resolve ADS's 97 internal `@/...` imports at build time so they never leak to consumers.
|
||||||
|
alias: { '@': resolve(__dirname, 'src') },
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
lib: {
|
||||||
|
entry: resolve(__dirname, 'src/index.ts'),
|
||||||
|
formats: ['es'],
|
||||||
|
fileName: () => 'index.js',
|
||||||
|
},
|
||||||
|
rollupOptions: {
|
||||||
|
// React is a peer dep — externalise it (and the automatic JSX runtime) to avoid bundling a
|
||||||
|
// second copy. Everything else (clsx, tailwind-merge, @floating-ui) is small and bundled.
|
||||||
|
external: ['react', 'react-dom', 'react/jsx-runtime'],
|
||||||
|
output: { preserveModules: false },
|
||||||
|
},
|
||||||
|
sourcemap: true,
|
||||||
|
emptyOutDir: true,
|
||||||
|
// Library output only — don't copy public/ (favicons, logos) into the package.
|
||||||
|
copyPublicDir: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user