init
This commit is contained in:
17
.editorconfig
Normal file
17
.editorconfig
Normal file
@@ -0,0 +1,17 @@
|
||||
# Editor configuration, see https://editorconfig.org
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.ts]
|
||||
quote_type = single
|
||||
ij_typescript_use_double_quotes = false
|
||||
|
||||
[*.md]
|
||||
max_line_length = off
|
||||
trim_trailing_whitespace = false
|
||||
44
.gitignore
vendored
Normal file
44
.gitignore
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
|
||||
|
||||
# Compiled output
|
||||
/dist
|
||||
/tmp
|
||||
/out-tsc
|
||||
/bazel-out
|
||||
|
||||
# Node
|
||||
/node_modules
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
|
||||
# IDEs and editors
|
||||
.idea/
|
||||
.project
|
||||
.classpath
|
||||
.c9/
|
||||
*.launch
|
||||
.settings/
|
||||
*.sublime-workspace
|
||||
|
||||
# Visual Studio Code
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
!.vscode/mcp.json
|
||||
.history/*
|
||||
|
||||
# Miscellaneous
|
||||
/.angular/cache
|
||||
.sass-cache/
|
||||
/connect.lock
|
||||
/coverage
|
||||
/libpeerconnection.log
|
||||
testem.log
|
||||
/typings
|
||||
__screenshots__/
|
||||
|
||||
# System files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
12
.prettierrc
Normal file
12
.prettierrc
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"printWidth": 100,
|
||||
"singleQuote": true,
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.html",
|
||||
"options": {
|
||||
"parser": "angular"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
11
.storybook/main.ts
Normal file
11
.storybook/main.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { StorybookConfig } from '@storybook/angular';
|
||||
|
||||
const config: StorybookConfig = {
|
||||
stories: ['../projects/mrarm-ui/src/lib/**/*.stories.ts'],
|
||||
framework: {
|
||||
name: '@storybook/angular',
|
||||
options: {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
17
.storybook/preview.ts
Normal file
17
.storybook/preview.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { Preview } from '@storybook/angular';
|
||||
|
||||
const preview: Preview = {
|
||||
parameters: {
|
||||
backgrounds: {
|
||||
default: 'mra-panel',
|
||||
values: [
|
||||
{ name: 'mra-bg', value: '#b9bcc0' },
|
||||
{ name: 'mra-panel', value: '#d6d8db' },
|
||||
{ name: 'mra-surface', value: '#ffffff' },
|
||||
],
|
||||
},
|
||||
layout: 'padded',
|
||||
},
|
||||
};
|
||||
|
||||
export default preview;
|
||||
13
.storybook/tsconfig.json
Normal file
13
.storybook/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "bundler",
|
||||
"types": []
|
||||
},
|
||||
"include": [
|
||||
"../projects/mrarm-ui/src/**/*.ts",
|
||||
"./preview.ts"
|
||||
],
|
||||
"exclude": ["../**/*.spec.ts", "./main.ts"]
|
||||
}
|
||||
4
.vscode/extensions.json
vendored
Normal file
4
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
|
||||
"recommendations": ["angular.ng-template"]
|
||||
}
|
||||
20
.vscode/launch.json
vendored
Normal file
20
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "ng serve",
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "npm: start",
|
||||
"url": "http://localhost:4200/"
|
||||
},
|
||||
{
|
||||
"name": "ng test",
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "npm: test",
|
||||
"url": "http://localhost:9876/debug.html"
|
||||
}
|
||||
]
|
||||
}
|
||||
9
.vscode/mcp.json
vendored
Normal file
9
.vscode/mcp.json
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
// For more information, visit: https://angular.dev/ai/mcp
|
||||
"servers": {
|
||||
"angular-cli": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@angular/cli", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
42
.vscode/tasks.json
vendored
Normal file
42
.vscode/tasks.json
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "start",
|
||||
"isBackground": true,
|
||||
"problemMatcher": {
|
||||
"owner": "typescript",
|
||||
"pattern": "$tsc",
|
||||
"background": {
|
||||
"activeOnStart": true,
|
||||
"beginsPattern": {
|
||||
"regexp": "Changes detected"
|
||||
},
|
||||
"endsPattern": {
|
||||
"regexp": "bundle generation (complete|failed)"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "test",
|
||||
"isBackground": true,
|
||||
"problemMatcher": {
|
||||
"owner": "typescript",
|
||||
"pattern": "$tsc",
|
||||
"background": {
|
||||
"activeOnStart": true,
|
||||
"beginsPattern": {
|
||||
"regexp": "Changes detected"
|
||||
},
|
||||
"endsPattern": {
|
||||
"regexp": "bundle generation (complete|failed)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
56
PLAN.md
Normal file
56
PLAN.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# Angular Component Library Plan
|
||||
|
||||
## Summary
|
||||
|
||||
Create a fresh Angular workspace for @mrarm/ui because the repo currently contains only WEB_DESIGN_STYLE.md. Use Angular 21 LTS for compatibility with Storybook’s current Angular support, and build a reusable
|
||||
component library plus both Storybook and a working demo app.
|
||||
|
||||
Sources checked: Angular release support (https://angular.dev/reference/releases), Storybook Angular docs (https://storybook.js.org/docs/get-started/frameworks/angular).
|
||||
|
||||
## Key Changes
|
||||
|
||||
- Scaffold an Angular 21 workspace with:
|
||||
- projects/mrarm-ui as the publishable library package @mrarm/ui
|
||||
- projects/demo as the route-based demo app
|
||||
- Storybook configured for isolated component demos and documentation
|
||||
- Use selector prefix mra- and CSS token prefix --mra-.
|
||||
- Build custom visuals from WEB_DESIGN_STYLE.md: compact grey panels, square borders, small typography, dense tables/forms, sparse semantic color, minimal motion.
|
||||
- Use Angular CDK for overlays, focus trapping, menus, portals, accessibility helpers, and keyboard behavior while keeping all styling custom.
|
||||
|
||||
## Component Set
|
||||
|
||||
- Foundations: tokens, typography, reset, spacing, borders, focus styles, semantic colors, layout utilities.
|
||||
- Actions: button, icon button, button group, split button, link button, confirmation action.
|
||||
- Status/display: badge, alert, toast, progress bar, loading indicator, empty state, code box, log stream, markdown container.
|
||||
- Forms: field wrapper, input, textarea, select, checkbox, radio, switch, segmented control, slider, number stepper, validation message, form actions.
|
||||
- Navigation/overlays: top bar, tab nav, menu/dropdown, tooltip, popover, dialog, drawer, accordion/disclosure, pagination.
|
||||
- Data: table, sortable header, row actions, filter bar, description list, compact metric block, activity timeline.
|
||||
- Layouts: app shell, main panel, page header/action row, dashboard, list page, detail page, configuration page, review queue, split detail/log layout.
|
||||
|
||||
Advanced widgets such as autocomplete/combobox, datepicker/range picker, tree view, command palette, virtualized table, and drag/drop lists should be tracked as phase-two components after the foundation and
|
||||
broad common set are working.
|
||||
|
||||
## Demo And Documentation
|
||||
|
||||
- Storybook should include one story per component with default, variants, disabled/loading/error states, keyboard states, and mobile viewport checks.
|
||||
- The demo app should include route-based pages for Dashboard, List, Detail, Configuration, Review Queue, Forms, Tables, Overlays, and Logs.
|
||||
- Demo data should be realistic operational data: statuses, IDs, timestamps, endpoint URLs, logs, row actions, empty states, and validation errors.
|
||||
- Avoid marketing pages, hero sections, decorative cards, gradients, blobs, glass effects, and oversized typography.
|
||||
|
||||
## Test Plan
|
||||
|
||||
- Unit tests for component inputs, outputs, disabled states, semantic variants, and keyboard interactions.
|
||||
- Angular CDK harness-style tests where practical for menus, dialogs, tabs, and form controls.
|
||||
- Storybook interaction tests for overlays, forms, menus, and dialogs.
|
||||
- Accessibility checks for labels, focus order, visible focus states, contrast, reduced motion, and non-color-only status communication.
|
||||
- Build checks for library package, demo app, and Storybook.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- Package name: @mrarm/ui.
|
||||
- Selector prefix: mra-.
|
||||
- Token prefix: --mra-.
|
||||
- Angular target: 21 LTS.
|
||||
- Demo approach: Storybook plus Angular demo app.
|
||||
- Styling approach: custom CSS variables and component styles, no Angular Material visual theme.
|
||||
|
||||
59
README.md
Normal file
59
README.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# MrarmWs
|
||||
|
||||
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 21.2.15.
|
||||
|
||||
## Development server
|
||||
|
||||
To start a local development server, run:
|
||||
|
||||
```bash
|
||||
ng serve
|
||||
```
|
||||
|
||||
Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
|
||||
|
||||
## Code scaffolding
|
||||
|
||||
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
|
||||
|
||||
```bash
|
||||
ng generate component component-name
|
||||
```
|
||||
|
||||
For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
|
||||
|
||||
```bash
|
||||
ng generate --help
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
To build the project run:
|
||||
|
||||
```bash
|
||||
ng build
|
||||
```
|
||||
|
||||
This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
|
||||
|
||||
## Running unit tests
|
||||
|
||||
To execute unit tests with the [Vitest](https://vitest.dev/) test runner, use the following command:
|
||||
|
||||
```bash
|
||||
ng test
|
||||
```
|
||||
|
||||
## Running end-to-end tests
|
||||
|
||||
For end-to-end (e2e) testing, run:
|
||||
|
||||
```bash
|
||||
ng e2e
|
||||
```
|
||||
|
||||
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
|
||||
|
||||
## Additional Resources
|
||||
|
||||
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.
|
||||
811
WEB_DESIGN_STYLE.md
Normal file
811
WEB_DESIGN_STYLE.md
Normal file
@@ -0,0 +1,811 @@
|
||||
# Web Design Style Guide
|
||||
|
||||
Use this guide when designing or implementing web user interfaces that should feel compact, practical, durable, and optimized for repeated operational use. The preferred style is a restrained control-panel aesthetic: dense but readable, strongly structured, low-decoration, and built around clear data, direct actions, and predictable navigation.
|
||||
|
||||
This is not a landing-page style. It is not a marketing style. It is a working-interface style for tools, dashboards, admin areas, review queues, editors, monitoring pages, configuration screens, and other interfaces where users need to scan, compare, decide, and act efficiently.
|
||||
|
||||
## Design Intent
|
||||
|
||||
The interface should feel like dependable system software: plain, precise, and information-forward. Visual choices should support speed, legibility, and confidence rather than brand expression or emotional effect.
|
||||
|
||||
Favor:
|
||||
|
||||
- Compact layouts with clear grouping.
|
||||
- Dense tables, forms, status rows, and action bars.
|
||||
- Muted neutral surfaces.
|
||||
- Strong borders and obvious boundaries.
|
||||
- Small, consistent typography.
|
||||
- Direct labels and command text.
|
||||
- Minimal motion.
|
||||
- Semantic color used sparingly.
|
||||
- Interfaces that remain useful with large amounts of data.
|
||||
|
||||
Avoid:
|
||||
|
||||
- Marketing pages, hero sections, oversized feature copy, or decorative storytelling.
|
||||
- Large soft cards, glassmorphism, frosted panels, floating layouts, or excessive elevation.
|
||||
- Gradient backgrounds, decorative blobs, large illustration panels, and atmospheric imagery.
|
||||
- Big rounded corners, pill-heavy layouts, and oversized touch-target styling unless the product context requires it.
|
||||
- Spacious SaaS dashboard styling that sacrifices density.
|
||||
- One-note color themes where the entire screen is a variation of a single hue.
|
||||
- Animations that call attention to themselves.
|
||||
|
||||
## Overall Aesthetic
|
||||
|
||||
Use a restrained, old-school administrative console look. The screen should read as a serious work surface, not a consumer product.
|
||||
|
||||
The primary visual language is:
|
||||
|
||||
- Flat grey panels.
|
||||
- Square or nearly square corners.
|
||||
- Visible 1px or 2px borders.
|
||||
- Simple top bars and navigation strips.
|
||||
- Small controls.
|
||||
- Full-width data tables.
|
||||
- Monospace text for machine-oriented values.
|
||||
- Clear status badges with minimal decoration.
|
||||
|
||||
The design should be easy to inspect at a glance. A user should immediately understand which region is navigation, which region is content, which items are actionable, and which values are status indicators.
|
||||
|
||||
## Layout Principles
|
||||
|
||||
Use a fixed, practical page structure:
|
||||
|
||||
1. A compact top bar with product/page identity and optional account or global actions.
|
||||
2. A main wrapper with modest padding.
|
||||
3. A bordered main panel or unframed content area depending on the application shell.
|
||||
4. A compact navigation bar near the top of the working area.
|
||||
5. Page content arranged as dense sections with headings, tables, forms, and action rows.
|
||||
|
||||
Recommended spacing:
|
||||
|
||||
- Page padding: 10px to 14px.
|
||||
- Panel padding: 8px to 12px.
|
||||
- Section gap: 10px to 16px.
|
||||
- Table cell padding: 6px to 8px.
|
||||
- Form row gap: 6px to 10px.
|
||||
- Button padding: 6px 10px.
|
||||
|
||||
Use grid layouts only where they improve scanning. Two-column grids are useful for summary/detail layouts. Avoid decorative card grids unless each card is a true repeated data item.
|
||||
|
||||
Do not create nested cards. If a page needs multiple regions, use sections, tables, fieldsets, bordered panels, or full-width bands. Keep hierarchy clear through headings, borders, and spacing rather than shadows or depth effects.
|
||||
|
||||
## Density
|
||||
|
||||
The preferred density is high. Users should be able to see many controls or rows without excessive scrolling.
|
||||
|
||||
Keep:
|
||||
|
||||
- Headings small.
|
||||
- Controls compact.
|
||||
- Labels concise.
|
||||
- Tables full-width.
|
||||
- Actions close to the data they affect.
|
||||
- Repeated metadata in small text.
|
||||
|
||||
Do not use:
|
||||
|
||||
- Oversized page titles.
|
||||
- Tall cards for simple metrics.
|
||||
- Large icon-only empty states.
|
||||
- Wide blank gutters.
|
||||
- Large vertical gaps between related controls.
|
||||
|
||||
The layout should remain comfortable, but it should never feel like a marketing dashboard.
|
||||
|
||||
## Typography
|
||||
|
||||
Use system-adjacent, utilitarian fonts. Preferred font stack:
|
||||
|
||||
```css
|
||||
font-family: Tahoma, Verdana, Arial, sans-serif;
|
||||
```
|
||||
|
||||
Recommended type scale:
|
||||
|
||||
- Body: 13px.
|
||||
- Small text: 12px.
|
||||
- Headings: 14px, bold.
|
||||
- Table headers: 12px, bold.
|
||||
- Code, IDs, timestamps, URLs, logs: 12px monospace.
|
||||
|
||||
Recommended monospace stack:
|
||||
|
||||
```css
|
||||
font-family: "Courier New", Courier, monospace;
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- Keep line-height compact, around 1.25 to 1.35.
|
||||
- Do not use negative letter spacing.
|
||||
- Do not scale type with viewport width.
|
||||
- Do not use display-scale typography inside tool surfaces.
|
||||
- Use bold for hierarchy and active states, not for decoration.
|
||||
- Use muted small text for secondary metadata.
|
||||
|
||||
Headings should be practical section labels, not editorial copy. Examples:
|
||||
|
||||
- `Details`
|
||||
- `Actions`
|
||||
- `Recent Runs`
|
||||
- `Configuration`
|
||||
- `Import`
|
||||
- `Queue`
|
||||
- `Status`
|
||||
- `Logs`
|
||||
|
||||
## Color Palette
|
||||
|
||||
Use a neutral grey base with restrained semantic colors.
|
||||
|
||||
Recommended tokens:
|
||||
|
||||
```css
|
||||
:root {
|
||||
--bg: #b9bcc0;
|
||||
--panel: #d6d8db;
|
||||
--panel-2: #cfd2d6;
|
||||
--panel-3: #e7e9ec;
|
||||
--surface: #ffffff;
|
||||
--surface-alt: #f6f7f8;
|
||||
--border: #5c6268;
|
||||
--border-2: #8a9199;
|
||||
--line: #c0c4c9;
|
||||
--text: #101214;
|
||||
--muted: #2f343a;
|
||||
--accent: #0b5fa5;
|
||||
--ok: #1f7a3a;
|
||||
--warn: #b66b00;
|
||||
--bad: #a32828;
|
||||
}
|
||||
```
|
||||
|
||||
Use color roles consistently:
|
||||
|
||||
- Grey for layout, panels, controls, and table structure.
|
||||
- Blue for primary actions, links, active navigation accents, and selected states.
|
||||
- Green only for successful, enabled, healthy, accepted, or complete states.
|
||||
- Orange only for warning, pending, degraded, or attention-needed states.
|
||||
- Red only for destructive, failed, rejected, blocked, or dangerous states.
|
||||
|
||||
Do not make the whole UI blue, purple, green, beige, black, or any other single dominant hue. The screen should read as neutral grey with small semantic highlights.
|
||||
|
||||
## Borders And Corners
|
||||
|
||||
Borders are a primary design tool in this style.
|
||||
|
||||
Use:
|
||||
|
||||
- 1px borders for tables, fields, buttons, dropdowns, badges, and small panels.
|
||||
- 2px borders for the main application panel or important structural containers.
|
||||
- Medium grey border colors with enough contrast to define regions.
|
||||
- Border-only separation before using background changes.
|
||||
|
||||
Corners:
|
||||
|
||||
- Prefer 0px border radius.
|
||||
- If radius is needed, keep it at 2px to 4px.
|
||||
- Avoid rounded cards.
|
||||
- Avoid pill shapes except for rare compact tags or indicators where the pattern is already established.
|
||||
|
||||
Shadows:
|
||||
|
||||
- Avoid soft shadows.
|
||||
- If a menu needs separation, use a simple hard shadow such as `2px 2px 0 #8a9199`.
|
||||
- Do not use layered elevation systems.
|
||||
|
||||
## Navigation
|
||||
|
||||
Navigation should be compact, obvious, and operational.
|
||||
|
||||
Preferred patterns:
|
||||
|
||||
- Horizontal tab-like navigation near the top of the panel.
|
||||
- Dropdown groups for related sections.
|
||||
- Active item shown with white or lighter background, bold text, and a blue inset border/accent.
|
||||
- Simple text labels.
|
||||
- Small status chips embedded only when useful.
|
||||
|
||||
Navigation controls should look like rectangular system buttons, not marketing nav links.
|
||||
|
||||
Recommended behavior:
|
||||
|
||||
- Active top-level section remains visually distinct.
|
||||
- Dropdown menus align directly below their trigger.
|
||||
- Menus have a bordered, flat panel background.
|
||||
- Menu items are full-width rows with hover/focus states.
|
||||
- On narrow screens, navigation items stack full-width.
|
||||
|
||||
Avoid:
|
||||
|
||||
- Large sidebars unless the application has many persistent sections.
|
||||
- Icon-heavy navigation without text.
|
||||
- Animated mega menus.
|
||||
- Floating glass nav bars.
|
||||
- Breadcrumb-heavy layouts unless depth is genuinely complex.
|
||||
|
||||
## Panels And Containers
|
||||
|
||||
Use panels to define working regions, not to decorate.
|
||||
|
||||
Main panels:
|
||||
|
||||
- Light grey background.
|
||||
- 2px dark grey border.
|
||||
- 8px to 12px padding.
|
||||
- No shadow.
|
||||
- No rounded corners.
|
||||
|
||||
Secondary containers:
|
||||
|
||||
- White or very light grey background.
|
||||
- 1px medium grey border.
|
||||
- 8px padding.
|
||||
- Used for code boxes, markdown previews, log entries, and compact detail blocks.
|
||||
|
||||
Do not wrap every section in a card. Many sections can simply be headings followed by tables or forms.
|
||||
|
||||
## Tables
|
||||
|
||||
Tables are central to this style. They should be dense, legible, and full-width.
|
||||
|
||||
Table rules:
|
||||
|
||||
- Use `border-collapse: collapse`.
|
||||
- Use white table background.
|
||||
- Use 1px borders between all cells.
|
||||
- Use light grey headers.
|
||||
- Use subtle alternating row backgrounds.
|
||||
- Align text left.
|
||||
- Keep numeric or machine values in monospace when helpful.
|
||||
- Keep row actions in the last column.
|
||||
- Use badges for status cells.
|
||||
|
||||
Recommended CSS:
|
||||
|
||||
```css
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: #ffffff;
|
||||
border: 1px solid var(--border-2);
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
border: 1px solid var(--line);
|
||||
padding: 6px 7px;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
th {
|
||||
background: #e9ecef;
|
||||
text-align: left;
|
||||
font-weight: 700;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
tr:nth-child(even) td {
|
||||
background: var(--surface-alt);
|
||||
}
|
||||
```
|
||||
|
||||
Table content guidance:
|
||||
|
||||
- Use links for primary identifiers or names.
|
||||
- Use small muted text for metadata.
|
||||
- Use monospace for IDs, timestamps, paths, URLs, and machine values.
|
||||
- Use compact buttons for row actions.
|
||||
- Do not place large multiline content directly into a table unless it is preformatted operational output.
|
||||
- For long values, allow wrapping or provide a code box/detail row.
|
||||
|
||||
## Forms
|
||||
|
||||
Forms should be structured, compact, and predictable.
|
||||
|
||||
Preferred pattern:
|
||||
|
||||
- Two-column rows.
|
||||
- Fixed-width labels on the left.
|
||||
- Inputs on the right.
|
||||
- Small label text in muted color.
|
||||
- Actions in a compact row below the fields.
|
||||
|
||||
Recommended CSS:
|
||||
|
||||
```css
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 180px 1fr;
|
||||
gap: 8px;
|
||||
align-items: start;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.row .label {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
padding-top: 6px;
|
||||
}
|
||||
```
|
||||
|
||||
Inputs:
|
||||
|
||||
- Full-width by default.
|
||||
- White background.
|
||||
- 1px medium grey border.
|
||||
- 6px to 7px padding.
|
||||
- Same font as body.
|
||||
- Textareas use monospace when the expected content is prompts, logs, code, markdown, JSON, or configuration.
|
||||
|
||||
Avoid:
|
||||
|
||||
- Floating labels.
|
||||
- Large rounded inputs.
|
||||
- Oversized validation messages.
|
||||
- Multi-column forms with unclear reading order.
|
||||
- Placeholder text as the only label.
|
||||
|
||||
## Buttons And Actions
|
||||
|
||||
Buttons should be small, rectangular, and direct.
|
||||
|
||||
Base button:
|
||||
|
||||
- Light grey background.
|
||||
- 1px medium grey border.
|
||||
- 6px 10px padding.
|
||||
- 13px font.
|
||||
- No radius or minimal radius.
|
||||
- Pointer cursor.
|
||||
|
||||
Primary button:
|
||||
|
||||
- Pale blue background.
|
||||
- Blue-grey border.
|
||||
- Dark blue text.
|
||||
- Bold label.
|
||||
|
||||
Danger button:
|
||||
|
||||
- Pale red background.
|
||||
- Red border.
|
||||
- Dark red text.
|
||||
|
||||
Recommended CSS:
|
||||
|
||||
```css
|
||||
button,
|
||||
.btn {
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--border-2);
|
||||
background: #f2f4f6;
|
||||
cursor: pointer;
|
||||
font-family: Tahoma, Verdana, Arial, sans-serif;
|
||||
font-size: 13px;
|
||||
text-decoration: none;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
button.primary,
|
||||
.btn.primary {
|
||||
background: #e9f2fb;
|
||||
border-color: #6f99c2;
|
||||
color: #0a4778;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
button.danger,
|
||||
.btn.danger {
|
||||
background: #fdeaea;
|
||||
border-color: #c26969;
|
||||
color: #7a0f0f;
|
||||
}
|
||||
```
|
||||
|
||||
Action placement:
|
||||
|
||||
- Keep page-level actions near the top of a page.
|
||||
- Keep row-level actions in the relevant table row.
|
||||
- Keep destructive actions visually distinct but not oversized.
|
||||
- Use confirmation for irreversible destructive actions.
|
||||
- Use action groups with 8px gaps.
|
||||
|
||||
Button labels should be short commands:
|
||||
|
||||
- `Save`
|
||||
- `Import`
|
||||
- `Refresh`
|
||||
- `Review`
|
||||
- `Cancel`
|
||||
- `Delete`
|
||||
- `Export`
|
||||
- `Open`
|
||||
- `Apply`
|
||||
- `Retry`
|
||||
|
||||
## Links
|
||||
|
||||
Links should be straightforward and recognizable.
|
||||
|
||||
Use:
|
||||
|
||||
- Blue text for inline links.
|
||||
- Underline only if the broader environment requires it or readability suffers.
|
||||
- Links inside tables for primary record navigation.
|
||||
- Buttons for command-like actions.
|
||||
|
||||
Do not style command links as decorative text when they mutate state. Use a button or button-like form control.
|
||||
|
||||
## Badges And Status Indicators
|
||||
|
||||
Use badges for status, state, and small categorical labels.
|
||||
|
||||
Base badge:
|
||||
|
||||
- Inline-block.
|
||||
- 1px border.
|
||||
- Light grey background.
|
||||
- 12px font.
|
||||
- 1px 6px padding.
|
||||
- No icon unless required for accessibility or scanning.
|
||||
|
||||
Semantic variants:
|
||||
|
||||
- Success: green border and green text.
|
||||
- Warning: orange border and orange text.
|
||||
- Error: red border and red text.
|
||||
- Neutral: grey border and dark text.
|
||||
|
||||
Recommended CSS:
|
||||
|
||||
```css
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 1px 6px;
|
||||
border: 1px solid var(--border-2);
|
||||
background: #f4f6f8;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.badge.ok {
|
||||
border-color: #2b8b47;
|
||||
color: var(--ok);
|
||||
}
|
||||
|
||||
.badge.warn {
|
||||
border-color: #c9891b;
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
.badge.bad {
|
||||
border-color: #b94b4b;
|
||||
color: var(--bad);
|
||||
}
|
||||
```
|
||||
|
||||
Badges should not become the dominant visual element on the page. They are scanning aids.
|
||||
|
||||
## Code, Logs, And Preformatted Content
|
||||
|
||||
Machine-oriented text should be set apart clearly.
|
||||
|
||||
Use code boxes for:
|
||||
|
||||
- Logs.
|
||||
- JSON.
|
||||
- Configuration snippets.
|
||||
- Generated output.
|
||||
- URLs or endpoint summaries.
|
||||
- Error messages.
|
||||
- Technical descriptions that benefit from preserved whitespace.
|
||||
|
||||
Recommended CSS:
|
||||
|
||||
```css
|
||||
.mono {
|
||||
font-family: "Courier New", Courier, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.small {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.codebox {
|
||||
background: #ffffff;
|
||||
border: 1px solid var(--border-2);
|
||||
padding: 8px;
|
||||
overflow: auto;
|
||||
max-height: 280px;
|
||||
}
|
||||
```
|
||||
|
||||
For transcript or log streams:
|
||||
|
||||
- Use stacked code boxes.
|
||||
- Include a bold title and small timestamp.
|
||||
- Keep metadata compact.
|
||||
- Use `white-space: pre-wrap` for long output.
|
||||
- Avoid syntax highlighting unless it genuinely improves readability.
|
||||
|
||||
## Markdown Content
|
||||
|
||||
Rendered markdown should still match the utilitarian interface.
|
||||
|
||||
Use:
|
||||
|
||||
- White background.
|
||||
- 1px border.
|
||||
- 12px to 13px body size.
|
||||
- Compact margins.
|
||||
- Monospace code.
|
||||
- Light grey code backgrounds.
|
||||
- Simple blockquote left borders.
|
||||
|
||||
Avoid:
|
||||
|
||||
- Blog-style typography.
|
||||
- Large prose widths with oversized line-height.
|
||||
- Decorative heading scales.
|
||||
- Rich article styling that clashes with the application shell.
|
||||
|
||||
## Modals And Overlays
|
||||
|
||||
Use modals sparingly. Prefer inline controls and detail rows when possible.
|
||||
|
||||
When a modal is needed:
|
||||
|
||||
- Use a simple dark translucent backdrop.
|
||||
- Use a bordered rectangular panel.
|
||||
- Keep content constrained with scrolling.
|
||||
- Keep headings small.
|
||||
- Put actions in a compact row.
|
||||
- Avoid large rounded cards.
|
||||
- Avoid animated entrance effects.
|
||||
|
||||
Modals should feel like utility dialogs, not product-tour surfaces.
|
||||
|
||||
## Alerts And Flash Messages
|
||||
|
||||
Alerts should be plain and close to the relevant area.
|
||||
|
||||
Use:
|
||||
|
||||
- Small text.
|
||||
- Border and background changes.
|
||||
- Semantic color only when needed.
|
||||
- Clear, direct copy.
|
||||
|
||||
Do not use large banners unless the issue blocks the entire page.
|
||||
|
||||
## Progress And Live State
|
||||
|
||||
Progress indicators should be simple and compact.
|
||||
|
||||
Preferred patterns:
|
||||
|
||||
- Small status text.
|
||||
- Thin progress bars with borders.
|
||||
- Inline live indicators.
|
||||
- Badges for connection or queue state.
|
||||
|
||||
Avoid:
|
||||
|
||||
- Full-page loaders for local actions.
|
||||
- Large animated spinners.
|
||||
- Excessive real-time motion.
|
||||
|
||||
If something updates live, preserve layout stability. Row height, columns, and action placement should not shift unexpectedly.
|
||||
|
||||
## Responsive Behavior
|
||||
|
||||
The style should remain practical on small screens without becoming a different design.
|
||||
|
||||
Rules:
|
||||
|
||||
- Stack navigation items full-width on narrow screens.
|
||||
- Make dropdown menus static and full-width on narrow screens.
|
||||
- Collapse two-column grids into one column below tablet width.
|
||||
- Keep tables horizontally scrollable if necessary.
|
||||
- Preserve table structure for data-heavy views instead of converting everything into cards.
|
||||
- Form rows may collapse to one column on very narrow screens.
|
||||
|
||||
Do not create a separate mobile marketing layout. The mobile version is still a compact tool interface.
|
||||
|
||||
## Accessibility
|
||||
|
||||
The compact style must remain accessible.
|
||||
|
||||
Requirements:
|
||||
|
||||
- Use semantic HTML controls.
|
||||
- Preserve visible focus states.
|
||||
- Ensure text contrast is sufficient.
|
||||
- Use labels for form inputs.
|
||||
- Keep destructive actions explicit.
|
||||
- Provide accessible names for icon-only controls if any are used.
|
||||
- Do not rely on color alone for critical state.
|
||||
- Respect `prefers-reduced-motion`.
|
||||
|
||||
Dense does not mean ambiguous. Every control should be understandable and reachable by keyboard.
|
||||
|
||||
## Motion
|
||||
|
||||
Use minimal motion.
|
||||
|
||||
Acceptable:
|
||||
|
||||
- Short expand/collapse transitions for utility panels.
|
||||
- Simple hover/focus background changes.
|
||||
- Subtle progress updates.
|
||||
|
||||
Avoid:
|
||||
|
||||
- Page transitions.
|
||||
- Scroll-triggered animation.
|
||||
- Decorative animated backgrounds.
|
||||
- Springy controls.
|
||||
- Motion that distracts from reading data.
|
||||
|
||||
If an animation is not helping the user understand state, remove it.
|
||||
|
||||
## Icons
|
||||
|
||||
Text labels are usually preferred. Use icons only where they improve scanning or match a familiar tool action.
|
||||
|
||||
If using icons:
|
||||
|
||||
- Keep them small.
|
||||
- Pair unfamiliar icons with text.
|
||||
- Use tooltips or accessible labels.
|
||||
- Match stroke weight and size consistently.
|
||||
- Do not use icons as decoration.
|
||||
|
||||
Avoid illustration-style icons, large empty-state graphics, and decorative icon clusters.
|
||||
|
||||
## Empty States
|
||||
|
||||
Empty states should be short and functional.
|
||||
|
||||
Use:
|
||||
|
||||
- A single muted line such as `No entries yet.`
|
||||
- A compact primary action if there is an obvious next step.
|
||||
- Optional small explanation when the absence of data might be confusing.
|
||||
|
||||
Avoid:
|
||||
|
||||
- Large illustrations.
|
||||
- Inspirational copy.
|
||||
- Oversized centered panels.
|
||||
- Marketing-style onboarding.
|
||||
|
||||
## Copy Style
|
||||
|
||||
Use direct, operational language.
|
||||
|
||||
Good labels:
|
||||
|
||||
- `Status`
|
||||
- `Updated`
|
||||
- `Server URL`
|
||||
- `Import`
|
||||
- `Search`
|
||||
- `Delete selected`
|
||||
- `Review`
|
||||
- `Export`
|
||||
- `Run`
|
||||
- `Cancel`
|
||||
|
||||
Avoid:
|
||||
|
||||
- Playful copy.
|
||||
- Marketing claims.
|
||||
- Long explanations in the UI.
|
||||
- Feature descriptions that belong in documentation.
|
||||
- Ambiguous verbs like `Go`, `Do it`, or `Manage` when a specific action exists.
|
||||
|
||||
The user interface should speak in short labels and commands.
|
||||
|
||||
## Page Patterns
|
||||
|
||||
### Dashboard
|
||||
|
||||
Dashboards should emphasize current operational state and recent activity.
|
||||
|
||||
Use:
|
||||
|
||||
- Compact summary tables.
|
||||
- Small metric blocks only when they are styled consistently.
|
||||
- Recent activity tables.
|
||||
- Direct action buttons.
|
||||
- Endpoint or configuration summaries in code boxes.
|
||||
|
||||
Avoid oversized KPI cards. If metrics are shown as blocks, keep them compact and bordered.
|
||||
|
||||
### List Page
|
||||
|
||||
List pages should make filtering and row actions easy.
|
||||
|
||||
Use:
|
||||
|
||||
- Top action row.
|
||||
- Filter form.
|
||||
- Full-width table.
|
||||
- Status badges.
|
||||
- Row-level action buttons.
|
||||
- Compact empty row when no records exist.
|
||||
|
||||
### Detail Page
|
||||
|
||||
Detail pages should make metadata, actions, and history easy to inspect.
|
||||
|
||||
Use:
|
||||
|
||||
- Two-column grid for details and actions.
|
||||
- Tables for fixed metadata.
|
||||
- Code boxes for long text or output.
|
||||
- Action row near the top.
|
||||
- Subsequent sections for notes, logs, attempts, history, or related items.
|
||||
|
||||
### Configuration Page
|
||||
|
||||
Configuration pages should be clear and conservative.
|
||||
|
||||
Use:
|
||||
|
||||
- Two-column form rows.
|
||||
- Plain section headings.
|
||||
- Required fields marked through native validation or concise labels.
|
||||
- Save/apply actions close to the form.
|
||||
- Existing settings shown in tables or code boxes.
|
||||
|
||||
### Review Queue
|
||||
|
||||
Review pages should help users make decisions quickly.
|
||||
|
||||
Use:
|
||||
|
||||
- Dense rows.
|
||||
- Clear status badges.
|
||||
- Primary and danger actions.
|
||||
- Context in code boxes.
|
||||
- Links to related records.
|
||||
- Optional live refresh indicators.
|
||||
|
||||
## Implementation Checklist
|
||||
|
||||
Before considering a UI complete, verify:
|
||||
|
||||
- The page uses compact typography and spacing.
|
||||
- The main structure is obvious without decorative effects.
|
||||
- Tables are readable and dense.
|
||||
- Forms use consistent label/value rows.
|
||||
- Buttons are small, rectangular, and semantically styled.
|
||||
- Status color is sparse and meaningful.
|
||||
- Long machine text uses monospace/code boxes.
|
||||
- Navigation active states are visible.
|
||||
- Mobile layout preserves usability.
|
||||
- Text does not overflow buttons or controls.
|
||||
- There are no decorative gradients, blobs, glass effects, or large rounded cards.
|
||||
- The interface still works with realistic amounts of data.
|
||||
|
||||
## Agent Instructions
|
||||
|
||||
When implementing a web UI in this style:
|
||||
|
||||
1. Start with the working task, not a landing page.
|
||||
2. Build the primary tool surface as the first screen.
|
||||
3. Use compact neutral panels, tables, forms, and action rows.
|
||||
4. Apply semantic color only for state and action priority.
|
||||
5. Prefer standard HTML controls before custom widgets.
|
||||
6. Keep all spacing, typography, and borders consistent.
|
||||
7. Avoid decorative layout patterns.
|
||||
8. Check the interface at desktop and mobile widths.
|
||||
9. Revise any area that looks like a modern marketing dashboard.
|
||||
10. Optimize for repeated use by a knowledgeable operator.
|
||||
|
||||
The final result should feel plain in the best sense: fast to parse, hard to misread, and built for doing work.
|
||||
131
angular.json
Normal file
131
angular.json
Normal file
@@ -0,0 +1,131 @@
|
||||
{
|
||||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||
"version": 1,
|
||||
"cli": {
|
||||
"packageManager": "npm",
|
||||
"analytics": false
|
||||
},
|
||||
"newProjectRoot": "projects",
|
||||
"projects": {
|
||||
"mrarm-ui": {
|
||||
"projectType": "library",
|
||||
"root": "projects/mrarm-ui",
|
||||
"sourceRoot": "projects/mrarm-ui/src",
|
||||
"prefix": "mra",
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular/build:ng-packagr",
|
||||
"configurations": {
|
||||
"production": {
|
||||
"tsConfig": "projects/mrarm-ui/tsconfig.lib.prod.json"
|
||||
},
|
||||
"development": {
|
||||
"tsConfig": "projects/mrarm-ui/tsconfig.lib.json"
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "production"
|
||||
},
|
||||
"test": {
|
||||
"builder": "@angular/build:unit-test",
|
||||
"options": {
|
||||
"tsConfig": "projects/mrarm-ui/tsconfig.spec.json"
|
||||
}
|
||||
},
|
||||
"storybook": {
|
||||
"builder": "@storybook/angular:start-storybook",
|
||||
"options": {
|
||||
"browserTarget": "demo:build:development",
|
||||
"configDir": ".storybook",
|
||||
"tsConfig": ".storybook/tsconfig.json",
|
||||
"port": 6006,
|
||||
"compodoc": false
|
||||
}
|
||||
},
|
||||
"build-storybook": {
|
||||
"builder": "@storybook/angular:build-storybook",
|
||||
"options": {
|
||||
"browserTarget": "demo:build:development",
|
||||
"configDir": ".storybook",
|
||||
"tsConfig": ".storybook/tsconfig.json",
|
||||
"compodoc": false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"demo": {
|
||||
"projectType": "application",
|
||||
"schematics": {
|
||||
"@schematics/angular:component": {
|
||||
"style": "scss"
|
||||
}
|
||||
},
|
||||
"root": "projects/demo",
|
||||
"sourceRoot": "projects/demo/src",
|
||||
"prefix": "mra",
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular/build:application",
|
||||
"options": {
|
||||
"browser": "projects/demo/src/main.ts",
|
||||
"tsConfig": "projects/demo/tsconfig.app.json",
|
||||
"inlineStyleLanguage": "scss",
|
||||
"assets": [
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "projects/demo/public"
|
||||
}
|
||||
],
|
||||
"styles": [
|
||||
"projects/mrarm-ui/src/styles/tokens.css",
|
||||
"projects/mrarm-ui/src/styles/reset.css",
|
||||
"projects/mrarm-ui/src/styles/typography.css",
|
||||
"projects/mrarm-ui/src/styles/layout.css",
|
||||
"projects/demo/src/styles.scss"
|
||||
]
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "500kB",
|
||||
"maximumError": "1MB"
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
"maximumWarning": "4kB",
|
||||
"maximumError": "8kB"
|
||||
}
|
||||
],
|
||||
"outputHashing": "all"
|
||||
},
|
||||
"development": {
|
||||
"optimization": false,
|
||||
"extractLicenses": false,
|
||||
"sourceMap": true
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "production"
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular/build:dev-server",
|
||||
"configurations": {
|
||||
"production": {
|
||||
"buildTarget": "demo:build:production"
|
||||
},
|
||||
"development": {
|
||||
"buildTarget": "demo:build:development"
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "development"
|
||||
},
|
||||
"test": {
|
||||
"builder": "@angular/build:unit-test",
|
||||
"options": {
|
||||
"tsConfig": "projects/demo/tsconfig.spec.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
28
debug-storybook.log
Normal file
28
debug-storybook.log
Normal file
@@ -0,0 +1,28 @@
|
||||
[00:23:07.612] [INFO] storybook v10.4.6
|
||||
[00:23:08.342] [DEBUG] Getting package.json info for /home/jordan/projects/MrARM-UI/package.json...
|
||||
[00:23:08.509] [INFO] Storybook collects completely anonymous usage telemetry. We use it to shape Storybook's roadmap and prioritize features. You can learn more, including how to opt out, at https://storybook.js.org/telemetry
|
||||
[00:23:08.800] [INFO] Starting...
|
||||
[00:23:08.960] [DEBUG] Starting preview..
|
||||
[00:23:08.968] [INFO] Using implicit CSS loaders
|
||||
[00:23:09.472] [ERROR] Failed to build the preview
|
||||
[00:23:09.474] [ERROR] [38;2;241;97;97mSB_FRAMEWORK_ANGULAR_0001 (AngularLegacyBuildOptionsError): Your Storybook startup script uses a solution that is not supported anymore.
|
||||
You must use Angular builder to have an explicit configuration on the project used in angular.json.
|
||||
|
||||
Please run 'npx storybook automigrate' to automatically fix your config.
|
||||
|
||||
More info:
|
||||
- https://github.com/storybookjs/storybook/blob/next/MIGRATION.md#angular-drop-support-for-calling-storybook-directly
|
||||
- https://github.com/storybookjs/storybook/tree/next/code/frameworks/angular#how-do-i-migrate-to-an-angular-storybook-builder
|
||||
[39m
|
||||
at checkForLegacyBuildOptions (file://./node_modules/@storybook/angular/dist/server/framework-preset-angular-cli.js:159:11)
|
||||
at Object.webpackFinal (file://./node_modules/@storybook/angular/dist/server/framework-preset-angular-cli.js:93:3)
|
||||
at async Object.webpack (file://./node_modules/@storybook/builder-webpack5/dist/presets/custom-webpack-preset.js:282:28)
|
||||
at async starter (file://./node_modules/@storybook/builder-webpack5/dist/index.js:811:16)
|
||||
at async Module.start (file://./node_modules/@storybook/builder-webpack5/dist/index.js:915:14)
|
||||
at async storybookDevServer (file://./node_modules/storybook/dist/core-server/index.js:11496:158)
|
||||
at async buildOrThrow (file://./node_modules/storybook/dist/core-server/index.js:8205:12)
|
||||
at async buildDevStandalone (file://./node_modules/storybook/dist/core-server/index.js:12057:41)
|
||||
at async withTelemetry (file://./node_modules/storybook/dist/_node-chunks/chunk-G7NXW2C2.js:650:12)
|
||||
at async dev (file://./node_modules/storybook/dist/bin/core.js:2735:3)
|
||||
[00:23:09.480] [WARN] Broken build, fix the error above.
|
||||
You may need to refresh the browser.
|
||||
18007
package-lock.json
generated
Normal file
18007
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
38
package.json
Normal file
38
package.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "mrarm-ui-workspace",
|
||||
"version": "0.0.0",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
"start": "ng serve",
|
||||
"build": "ng build",
|
||||
"watch": "ng build --watch --configuration development",
|
||||
"test": "ng test",
|
||||
"storybook": "ng run mrarm-ui:storybook",
|
||||
"build-storybook": "ng run mrarm-ui:build-storybook"
|
||||
},
|
||||
"private": true,
|
||||
"packageManager": "npm@10.9.8",
|
||||
"dependencies": {
|
||||
"@angular/cdk": "^21.2.14",
|
||||
"@angular/common": "^21.2.0",
|
||||
"@angular/compiler": "^21.2.0",
|
||||
"@angular/core": "^21.2.0",
|
||||
"@angular/forms": "^21.2.0",
|
||||
"@angular/platform-browser": "^21.2.0",
|
||||
"@angular/router": "^21.2.0",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular/build": "^21.2.15",
|
||||
"@angular/cli": "^21.2.15",
|
||||
"@angular/compiler-cli": "^21.2.0",
|
||||
"@storybook/angular": "^10.4.6",
|
||||
"jsdom": "^28.0.0",
|
||||
"ng-packagr": "^21.2.0",
|
||||
"prettier": "^3.8.1",
|
||||
"storybook": "^10.4.6",
|
||||
"typescript": "~5.9.2",
|
||||
"vitest": "^4.0.8"
|
||||
}
|
||||
}
|
||||
BIN
projects/demo/public/favicon.ico
Normal file
BIN
projects/demo/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
8
projects/demo/src/app/app.config.ts
Normal file
8
projects/demo/src/app/app.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
|
||||
import { provideRouter } from '@angular/router';
|
||||
|
||||
import { routes } from './app.routes';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [provideBrowserGlobalErrorListeners(), provideRouter(routes)],
|
||||
};
|
||||
12
projects/demo/src/app/app.html
Normal file
12
projects/demo/src/app/app.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<mra-app-shell>
|
||||
<mra-top-bar brand="MrARM Ops" slot="header">
|
||||
<nav slot="nav">
|
||||
@for (link of navLinks; track link.path) {
|
||||
<a [routerLink]="link.path"
|
||||
routerLinkActive="mra-nav-active">{{ link.label }}</a>
|
||||
}
|
||||
</nav>
|
||||
</mra-top-bar>
|
||||
|
||||
<router-outlet />
|
||||
</mra-app-shell>
|
||||
51
projects/demo/src/app/app.routes.ts
Normal file
51
projects/demo/src/app/app.routes.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { Routes } from '@angular/router';
|
||||
|
||||
export const routes: Routes = [
|
||||
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
|
||||
{
|
||||
path: 'dashboard',
|
||||
loadComponent: () => import('./pages/dashboard').then((m) => m.DashboardPage),
|
||||
title: 'Dashboard — MrARM Ops',
|
||||
},
|
||||
{
|
||||
path: 'list',
|
||||
loadComponent: () => import('./pages/list').then((m) => m.ListPage),
|
||||
title: 'Incidents — MrARM Ops',
|
||||
},
|
||||
{
|
||||
path: 'detail',
|
||||
loadComponent: () => import('./pages/detail').then((m) => m.DetailPage),
|
||||
title: 'Detail — MrARM Ops',
|
||||
},
|
||||
{
|
||||
path: 'configuration',
|
||||
loadComponent: () => import('./pages/configuration').then((m) => m.ConfigurationPage),
|
||||
title: 'Configuration — MrARM Ops',
|
||||
},
|
||||
{
|
||||
path: 'review',
|
||||
loadComponent: () => import('./pages/review').then((m) => m.ReviewPage),
|
||||
title: 'Review Queue — MrARM Ops',
|
||||
},
|
||||
{
|
||||
path: 'forms',
|
||||
loadComponent: () => import('./pages/forms').then((m) => m.FormsPage),
|
||||
title: 'Forms — MrARM Ops',
|
||||
},
|
||||
{
|
||||
path: 'tables',
|
||||
loadComponent: () => import('./pages/tables').then((m) => m.TablesPage),
|
||||
title: 'Tables — MrARM Ops',
|
||||
},
|
||||
{
|
||||
path: 'overlays',
|
||||
loadComponent: () => import('./pages/overlays').then((m) => m.OverlaysPage),
|
||||
title: 'Overlays — MrARM Ops',
|
||||
},
|
||||
{
|
||||
path: 'logs',
|
||||
loadComponent: () => import('./pages/logs').then((m) => m.LogsPage),
|
||||
title: 'Logs — MrARM Ops',
|
||||
},
|
||||
{ path: '**', redirectTo: 'dashboard' },
|
||||
];
|
||||
20
projects/demo/src/app/app.scss
Normal file
20
projects/demo/src/app/app.scss
Normal file
@@ -0,0 +1,20 @@
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
nav[slot="nav"] a {
|
||||
padding: var(--mra-space-1) var(--mra-space-3);
|
||||
text-decoration: none;
|
||||
color: var(--mra-muted);
|
||||
font-size: var(--mra-font-size);
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: color var(--mra-motion), border-color var(--mra-motion);
|
||||
}
|
||||
nav[slot="nav"] a:hover {
|
||||
color: var(--mra-text);
|
||||
}
|
||||
.mra-nav-active {
|
||||
font-weight: 700;
|
||||
color: var(--mra-text);
|
||||
border-bottom: 2px solid var(--mra-accent);
|
||||
}
|
||||
34
projects/demo/src/app/app.spec.ts
Normal file
34
projects/demo/src/app/app.spec.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { App } from './app';
|
||||
|
||||
describe('App', () => {
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [App],
|
||||
providers: [provideRouter([])],
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
it('should create the app', () => {
|
||||
const fixture = TestBed.createComponent(App);
|
||||
expect(fixture.componentInstance).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render brand in top bar', async () => {
|
||||
const fixture = TestBed.createComponent(App);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
expect(compiled.textContent).toContain('MrARM Ops');
|
||||
});
|
||||
|
||||
it('should render nav links', async () => {
|
||||
const fixture = TestBed.createComponent(App);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
const links = (fixture.nativeElement as HTMLElement).querySelectorAll('nav a');
|
||||
expect(links.length).toBe(9);
|
||||
expect(links[0].textContent).toContain('Dashboard');
|
||||
});
|
||||
});
|
||||
23
projects/demo/src/app/app.ts
Normal file
23
projects/demo/src/app/app.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { RouterOutlet, RouterLink, RouterLinkActive } from '@angular/router';
|
||||
import { MraAppShell, MraTopBar } from '@mrarm/ui';
|
||||
|
||||
@Component({
|
||||
selector: 'mra-root',
|
||||
imports: [RouterOutlet, RouterLink, RouterLinkActive, MraAppShell, MraTopBar],
|
||||
templateUrl: './app.html',
|
||||
styleUrl: './app.scss',
|
||||
})
|
||||
export class App {
|
||||
protected readonly navLinks = [
|
||||
{ path: '/dashboard', label: 'Dashboard' },
|
||||
{ path: '/list', label: 'Incidents' },
|
||||
{ path: '/detail', label: 'Detail' },
|
||||
{ path: '/configuration', label: 'Config' },
|
||||
{ path: '/review', label: 'Review' },
|
||||
{ path: '/forms', label: 'Forms' },
|
||||
{ path: '/tables', label: 'Tables' },
|
||||
{ path: '/overlays', label: 'Overlays' },
|
||||
{ path: '/logs', label: 'Logs' },
|
||||
];
|
||||
}
|
||||
173
projects/demo/src/app/pages/configuration.ts
Normal file
173
projects/demo/src/app/pages/configuration.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import {
|
||||
MraConfigurationPage,
|
||||
MraField,
|
||||
MraInput,
|
||||
MraTextarea,
|
||||
MraSelect,
|
||||
MraCheckbox,
|
||||
MraSwitch,
|
||||
MraRadioGroup,
|
||||
MraSegmentedControl,
|
||||
MraSlider,
|
||||
MraNumberStepper,
|
||||
MraButton,
|
||||
MraFormActions,
|
||||
MraValidationMessage,
|
||||
MraSelectOption,
|
||||
MraRadioOption,
|
||||
MraSegmentedOption,
|
||||
MraAccordion,
|
||||
MraAccordionItem,
|
||||
} from '@mrarm/ui';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { DemoDataService } from '../services/demo-data';
|
||||
|
||||
@Component({
|
||||
selector: 'mra-demo-configuration',
|
||||
imports: [
|
||||
FormsModule,
|
||||
MraConfigurationPage,
|
||||
|
||||
MraField,
|
||||
MraInput,
|
||||
MraTextarea,
|
||||
MraSelect,
|
||||
MraCheckbox,
|
||||
MraSwitch,
|
||||
MraRadioGroup,
|
||||
MraSegmentedControl,
|
||||
MraSlider,
|
||||
MraNumberStepper,
|
||||
MraButton,
|
||||
MraFormActions,
|
||||
MraValidationMessage,
|
||||
|
||||
MraAccordion,
|
||||
MraAccordionItem,
|
||||
],
|
||||
template: `
|
||||
<mra-configuration-page title="Configuration" subtitle="Alerting, thresholds, and integration settings">
|
||||
<mra-button [variant]="'primary'" [slot]="'actions'">Save Changes</mra-button>
|
||||
|
||||
<mra-accordion>
|
||||
<mra-accordion-item title="Alerting" [defaultOpen]="true">
|
||||
<mra-field label="Alert email recipients" forId="alert-email" hint="Comma-separated list of email addresses">
|
||||
<mra-input id="alert-email" [placeholder]="'ops-team@mrarm.io'" [(ngModel)]="alertEmail" />
|
||||
</mra-field>
|
||||
|
||||
<mra-field label="Severity threshold" forId="severity" hint="Minimum severity to trigger alerts">
|
||||
<mra-select id="severity" [options]="severityOptions" [(ngModel)]="severity" />
|
||||
</mra-field>
|
||||
|
||||
<mra-field label="Disk usage threshold" forId="disk-threshold" hint="Percentage at which to alert">
|
||||
<mra-slider id="disk-threshold" [min]="50" [max]="100" [step]="5" [unit]="'%'" [(ngModel)]="diskThreshold" />
|
||||
</mra-field>
|
||||
|
||||
<mra-field label="Max retries before escalation" forId="max-retries">
|
||||
<mra-number-stepper id="max-retries" [min]="1" [max]="10" [step]="1" [(ngModel)]="maxRetries" />
|
||||
</mra-field>
|
||||
|
||||
<mra-field label="Notification mode" hint="How alerts are delivered">
|
||||
<mra-segmented-control [options]="notifyOptions" [(ngModel)]="notifyMode" />
|
||||
</mra-field>
|
||||
</mra-accordion-item>
|
||||
|
||||
<mra-accordion-item title="Integrations" [defaultOpen]="false">
|
||||
<mra-field label="PagerDuty webhook URL" forId="pd-url">
|
||||
<mra-input id="pd-url" [placeholder]="'https://events.pagerduty.com/…'" [(ngModel)]="pdUrl" />
|
||||
</mra-field>
|
||||
|
||||
<mra-field label="Slack channel" forId="slack-channel">
|
||||
<mra-input id="slack-channel" [placeholder]="'#ops-alerts'" [(ngModel)]="slackChannel" />
|
||||
</mra-field>
|
||||
|
||||
<div class="toggle-row">
|
||||
<mra-switch id="pd-enabled" label="PagerDuty enabled" [(ngModel)]="pdEnabled" />
|
||||
<mra-switch id="slack-enabled" label="Slack enabled" [(ngModel)]="slackEnabled" />
|
||||
<mra-switch id="email-enabled" label="Email enabled" [(ngModel)]="emailEnabled" />
|
||||
</div>
|
||||
</mra-accordion-item>
|
||||
|
||||
<mra-accordion-item title="Maintenance Windows" [defaultOpen]="false">
|
||||
<mra-field label="Window description" forId="mw-desc">
|
||||
<mra-textarea id="mw-desc" [rows]="3" [placeholder]="'Describe the maintenance window…'" [(ngModel)]="mwDesc" />
|
||||
</mra-field>
|
||||
|
||||
<mra-field label="Window type">
|
||||
<mra-radio-group [name]="'mw-type'" [options]="mwTypeOptions" [(ngModel)]="mwType" />
|
||||
</mra-field>
|
||||
|
||||
<mra-field label="Suppress all alerts during window">
|
||||
<mra-checkbox id="mw-suppress" label="Suppress all alerting" [(ngModel)]="mwSuppress" />
|
||||
</mra-field>
|
||||
</mra-accordion-item>
|
||||
|
||||
<mra-accordion-item title="Advanced" [defaultOpen]="false">
|
||||
<mra-field label="Custom webhook payload (JSON)" forId="payload">
|
||||
<mra-textarea id="payload" [rows]="6" [placeholder]="'{ … }'" [(ngModel)]="customPayload" />
|
||||
</mra-field>
|
||||
|
||||
<mra-field label="Custom payload" [invalid]="payloadInvalid">
|
||||
<mra-input id="payload-url" [placeholder]="'https://…'" [(ngModel)]="payloadUrl" [invalid]="payloadInvalid" />
|
||||
<mra-validation-message [show]="payloadInvalid">URL must start with https://</mra-validation-message>
|
||||
</mra-field>
|
||||
</mra-accordion-item>
|
||||
</mra-accordion>
|
||||
|
||||
<mra-form-actions>
|
||||
<mra-button [variant]="'default'">Reset</mra-button>
|
||||
<mra-button [variant]="'primary'">Save Configuration</mra-button>
|
||||
</mra-form-actions>
|
||||
</mra-configuration-page>
|
||||
`,
|
||||
styles: `
|
||||
:host { display: block; }
|
||||
.toggle-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--mra-space-2);
|
||||
padding: var(--mra-space-2) 0;
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class ConfigurationPage {
|
||||
protected readonly data = inject(DemoDataService);
|
||||
|
||||
alertEmail = 'ops-team@mrarm.io';
|
||||
severity = 'high';
|
||||
diskThreshold = 90;
|
||||
maxRetries = 3;
|
||||
notifyMode = 'pagerduty';
|
||||
pdUrl = '';
|
||||
slackChannel = '#ops-alerts';
|
||||
pdEnabled = true;
|
||||
slackEnabled = true;
|
||||
emailEnabled = false;
|
||||
mwDesc = '';
|
||||
mwType = 'scheduled';
|
||||
mwSuppress = false;
|
||||
customPayload = '';
|
||||
payloadUrl = '';
|
||||
payloadInvalid = false;
|
||||
|
||||
readonly severityOptions: MraSelectOption[] = [
|
||||
{ value: 'low', label: 'Low' },
|
||||
{ value: 'medium', label: 'Medium' },
|
||||
{ value: 'high', label: 'High' },
|
||||
{ value: 'critical', label: 'Critical' },
|
||||
];
|
||||
|
||||
readonly notifyOptions: MraSegmentedOption[] = [
|
||||
{ value: 'email', label: 'Email' },
|
||||
{ value: 'pagerduty', label: 'PagerDuty' },
|
||||
{ value: 'slack', label: 'Slack' },
|
||||
{ value: 'all', label: 'All' },
|
||||
];
|
||||
|
||||
readonly mwTypeOptions: MraRadioOption[] = [
|
||||
{ value: 'scheduled', label: 'Scheduled' },
|
||||
{ value: 'emergency', label: 'Emergency' },
|
||||
{ value: 'recurring', label: 'Recurring' },
|
||||
];
|
||||
}
|
||||
95
projects/demo/src/app/pages/dashboard.ts
Normal file
95
projects/demo/src/app/pages/dashboard.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import {
|
||||
MraDashboard,
|
||||
MraMainPanel,
|
||||
MraPageHeader,
|
||||
MraMetricBlock,
|
||||
MraActivityTimeline,
|
||||
MraBadge,
|
||||
MraAlert,
|
||||
MraProgressBar,
|
||||
MraSplitDetailLog,
|
||||
MraLogStream,
|
||||
} from '@mrarm/ui';
|
||||
import { DemoDataService } from '../services/demo-data';
|
||||
|
||||
@Component({
|
||||
selector: 'mra-dashboard-page',
|
||||
imports: [
|
||||
MraDashboard,
|
||||
MraMainPanel,
|
||||
MraPageHeader,
|
||||
MraMetricBlock,
|
||||
MraActivityTimeline,
|
||||
MraBadge,
|
||||
MraAlert,
|
||||
MraProgressBar,
|
||||
MraSplitDetailLog,
|
||||
MraLogStream,
|
||||
],
|
||||
template: `
|
||||
<mra-page-header title="Operations Dashboard" subtitle="Real-time system health for MrARM infrastructure">
|
||||
<mra-badge [variant]="'warn'">{{ data.metrics().openIncidents }} open</mra-badge>
|
||||
<mra-badge [variant]="'bad'">{{ data.metrics().investigating }} investigating</mra-badge>
|
||||
</mra-page-header>
|
||||
|
||||
<mra-dashboard [columns]="4">
|
||||
<mra-metric-block label="Open Incidents" [value]="data.metrics().openIncidents" [delta]="'−2 vs yesterday'" [deltaVariant]="'ok'" />
|
||||
<mra-metric-block label="P99 Latency" [value]="data.metrics().p99Latency" [delta]="'+340%'" [deltaVariant]="'bad'" hint="threshold 800ms" />
|
||||
<mra-metric-block label="Queue Depth" [value]="data.metrics().queueDepth" [delta]="'draining'" [deltaVariant]="'warn'" />
|
||||
<mra-metric-block label="Uptime 30d" [value]="data.metrics().uptime" [deltaVariant]="'ok'" [delta]="'SLA met'" />
|
||||
<mra-metric-block label="Resolved 24h" [value]="data.metrics().resolved24h" [deltaVariant]="'ok'" [delta]="'+4'" />
|
||||
<mra-metric-block label="Avg Resolution" [value]="data.metrics().avgResolution" [delta]="'−18m'" [deltaVariant]="'ok'" />
|
||||
<mra-metric-block label="Backup Status" value="OK" [deltaVariant]="'ok'" [delta]="'last 07:30'" />
|
||||
<mra-metric-block label="Avg Load" value="2.71" unit="cores" [delta]="'elevated'" [deltaVariant]="'warn'" />
|
||||
</mra-dashboard>
|
||||
|
||||
<mra-alert [variant]="'bad'" title="INC-2041: Database connection pool exhaustion" [closable]="true">
|
||||
db-prod-03 connection pool at 480/500. Failover to db-prod-04 initiated. Application latency p99 = 4.2s.
|
||||
</mra-alert>
|
||||
|
||||
<mra-split-detail-log>
|
||||
<mra-main-panel title="Active Incidents" [subtitle]="'3 incidents under active investigation'">
|
||||
@for (inc of data.incidents().slice(0, 3); track inc.id) {
|
||||
<div class="inc-row">
|
||||
<div class="inc-row__head">
|
||||
<strong>{{ inc.id }}</strong>
|
||||
<mra-badge [variant]="inc.severity === 'critical' ? 'bad' : inc.severity === 'high' ? 'warn' : 'neutral'">{{ inc.severity }}</mra-badge>
|
||||
<mra-badge [variant]="inc.status === 'resolved' ? 'ok' : inc.status === 'investigating' ? 'warn' : 'neutral'">{{ inc.status }}</mra-badge>
|
||||
<span class="inc-row__time">{{ inc.updatedAt }}</span>
|
||||
</div>
|
||||
<div class="inc-row__title">{{ inc.title }}</div>
|
||||
<div class="inc-row__meta">{{ inc.host }} · {{ inc.service }} · assigned to {{ inc.assignee }}</div>
|
||||
<mra-progress-bar [value]="inc.severity === 'critical' ? 92 : inc.severity === 'high' ? 70 : 40" />
|
||||
</div>
|
||||
}
|
||||
</mra-main-panel>
|
||||
|
||||
<mra-log-stream [entries]="data.logEntries()" [slot]="'log'" />
|
||||
</mra-split-detail-log>
|
||||
|
||||
<mra-main-panel title="Activity Timeline" subtitle="Last 24 hours">
|
||||
<mra-activity-timeline [items]="data.activity()" />
|
||||
</mra-main-panel>
|
||||
`,
|
||||
styles: `
|
||||
:host { display: flex; flex-direction: column; gap: var(--mra-space-4); }
|
||||
.inc-row {
|
||||
padding: var(--mra-space-3) 0;
|
||||
border-bottom: 1px solid var(--mra-line);
|
||||
}
|
||||
.inc-row:last-child { border-bottom: 0; }
|
||||
.inc-row__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--mra-space-2);
|
||||
margin-bottom: var(--mra-space-1);
|
||||
}
|
||||
.inc-row__time { margin-left: auto; color: var(--mra-muted); font-size: var(--mra-font-size-sm); }
|
||||
.inc-row__title { margin-bottom: var(--mra-space-1); }
|
||||
.inc-row__meta { color: var(--mra-muted); font-size: var(--mra-font-size-sm); margin-bottom: var(--mra-space-2); }
|
||||
`,
|
||||
})
|
||||
export class DashboardPage {
|
||||
protected readonly data = inject(DemoDataService);
|
||||
}
|
||||
110
projects/demo/src/app/pages/detail.ts
Normal file
110
projects/demo/src/app/pages/detail.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import {
|
||||
MraDetailPage,
|
||||
MraButton,
|
||||
MraMainPanel,
|
||||
MraDescriptionList,
|
||||
MraDescriptionItem,
|
||||
MraAlert,
|
||||
MraLogStream,
|
||||
MraSplitDetailLog,
|
||||
MraActivityTimeline,
|
||||
MraCodeBox,
|
||||
MraConfirmationAction,
|
||||
} from '@mrarm/ui';
|
||||
import { DemoDataService } from '../services/demo-data';
|
||||
|
||||
@Component({
|
||||
selector: 'mra-demo-detail',
|
||||
imports: [
|
||||
MraDetailPage,
|
||||
|
||||
MraButton,
|
||||
|
||||
MraMainPanel,
|
||||
MraDescriptionList,
|
||||
MraAlert,
|
||||
MraLogStream,
|
||||
MraSplitDetailLog,
|
||||
MraActivityTimeline,
|
||||
MraCodeBox,
|
||||
MraConfirmationAction,
|
||||
],
|
||||
template: `
|
||||
<mra-detail-page [title]="incident.id + ': ' + incident.title" subtitle="Incident detail — under active investigation" [hasAside]="true">
|
||||
<div class="actions" [slot]="'actions'">
|
||||
<mra-button [variant]="'default'">Acknowledge</mra-button>
|
||||
<mra-button [variant]="'primary'">Assign</mra-button>
|
||||
<mra-confirmation-action [variant]="'danger'" [confirmLabel]="'Close incident'" [confirmText]="'Close ' + incident.id + '?'" (confirmed)="onClose()">Close</mra-confirmation-action>
|
||||
</div>
|
||||
|
||||
<mra-alert [variant]="'bad'" title="Critical: Connection pool exhaustion" [closable]="false">
|
||||
Connection pool on {{ incident.host }} reached 480/500. Failover to db-prod-04 initiated. P99 latency 4.2s.
|
||||
</mra-alert>
|
||||
|
||||
<mra-split-detail-log>
|
||||
<mra-main-panel title="Summary">
|
||||
<mra-description-list [items]="descriptionItems" />
|
||||
|
||||
<h3 class="section-head">Description</h3>
|
||||
<p class="mra-text">{{ incident.description }}</p>
|
||||
|
||||
<h3 class="section-head">Diagnostic Query</h3>
|
||||
<mra-code-box title="pg_stat_activity" [mono]="true"><code>SELECT state, count(*) FROM pg_stat_activity GROUP BY state ORDER BY count DESC;
|
||||
|
||||
state | count
|
||||
-----------+-------
|
||||
active | 412
|
||||
idle | 66
|
||||
idle_txn | 2</code></mra-code-box>
|
||||
|
||||
<h3 class="section-head">Activity</h3>
|
||||
<mra-activity-timeline [items]="data.activity().slice(0, 4)" />
|
||||
</mra-main-panel>
|
||||
|
||||
<mra-log-stream [entries]="data.logEntries()" [slot]="'log'" />
|
||||
</mra-split-detail-log>
|
||||
|
||||
<mra-main-panel [slot]="'aside'" title="Meta">
|
||||
<mra-description-list [items]="asideItems" />
|
||||
</mra-main-panel>
|
||||
</mra-detail-page>
|
||||
`,
|
||||
styles: `
|
||||
:host { display: block; }
|
||||
.actions { display: flex; gap: var(--mra-space-2); align-items: center; }
|
||||
.section-head {
|
||||
font-size: var(--mra-font-size-heading);
|
||||
font-weight: 700;
|
||||
margin: var(--mra-space-4) 0 var(--mra-space-2);
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class DetailPage {
|
||||
protected readonly data = inject(DemoDataService);
|
||||
protected readonly incident = this.data.incidents()[0];
|
||||
|
||||
readonly descriptionItems: MraDescriptionItem[] = [
|
||||
{ term: 'Severity', detail: 'Critical' },
|
||||
{ term: 'Status', detail: 'Investigating' },
|
||||
{ term: 'Source', detail: 'Monitoring' },
|
||||
{ term: 'Service', detail: 'PostgreSQL' },
|
||||
{ term: 'Host', detail: 'db-prod-03', mono: true },
|
||||
{ term: 'Assignee', detail: 'A. Okonkwo' },
|
||||
{ term: 'Created', detail: '2026-06-17 08:14:22' },
|
||||
{ term: 'Updated', detail: '2026-06-17 09:02:10' },
|
||||
];
|
||||
|
||||
readonly asideItems: MraDescriptionItem[] = [
|
||||
{ term: 'Region', detail: 'eu-west-1' },
|
||||
{ term: 'Environment', detail: 'production' },
|
||||
{ term: 'Pool size', detail: '500', mono: true },
|
||||
{ term: 'Active conns', detail: '480', mono: true },
|
||||
{ term: 'Failover target', detail: 'db-prod-04', mono: true },
|
||||
{ term: 'Runbook', detail: 'RB-postgres-pool' },
|
||||
];
|
||||
|
||||
onClose(): void {
|
||||
console.log('Incident closed');
|
||||
}
|
||||
}
|
||||
157
projects/demo/src/app/pages/forms.ts
Normal file
157
projects/demo/src/app/pages/forms.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { Component, signal } from '@angular/core';
|
||||
import {
|
||||
MraPageHeader,
|
||||
MraMainPanel,
|
||||
MraField,
|
||||
MraInput,
|
||||
MraTextarea,
|
||||
MraSelect,
|
||||
MraCheckbox,
|
||||
MraSwitch,
|
||||
MraRadioGroup,
|
||||
MraSegmentedControl,
|
||||
MraSlider,
|
||||
MraNumberStepper,
|
||||
MraButton,
|
||||
MraFormActions,
|
||||
MraValidationMessage,
|
||||
MraSelectOption,
|
||||
MraRadioOption,
|
||||
MraSegmentedOption,
|
||||
MraBadge,
|
||||
MraAlert,
|
||||
} from '@mrarm/ui';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
|
||||
@Component({
|
||||
selector: 'mra-forms-page',
|
||||
imports: [
|
||||
FormsModule,
|
||||
MraPageHeader,
|
||||
MraMainPanel,
|
||||
MraField,
|
||||
MraInput,
|
||||
MraTextarea,
|
||||
MraSelect,
|
||||
MraCheckbox,
|
||||
MraSwitch,
|
||||
MraRadioGroup,
|
||||
MraSegmentedControl,
|
||||
MraSlider,
|
||||
MraNumberStepper,
|
||||
MraButton,
|
||||
MraFormActions,
|
||||
MraValidationMessage,
|
||||
MraBadge,
|
||||
|
||||
],
|
||||
template: `
|
||||
<mra-page-header title="Form Components" subtitle="All form controls with validation states">
|
||||
<mra-badge [variant]="'accent'">{{ submitted() ? 'Submitted' : 'Unsaved changes' }}</mra-badge>
|
||||
</mra-page-header>
|
||||
|
||||
<mra-main-panel title="Text Inputs">
|
||||
<mra-field label="Standard input" forId="f-name" hint="Enter the resource name">
|
||||
<mra-input id="f-name" [placeholder]="'my-resource'" [(ngModel)]="name" />
|
||||
</mra-field>
|
||||
|
||||
<mra-field label="Read-only" forId="f-ro">
|
||||
<mra-input id="f-ro" [readonly]="true" [(ngModel)]="readonlyVal" />
|
||||
</mra-field>
|
||||
|
||||
<mra-field label="With validation error" forId="f-err" [invalid]="true">
|
||||
<mra-input id="f-err" [placeholder]="'https://…'" [invalid]="true" [(ngModel)]="url" />
|
||||
<mra-validation-message [show]="true">URL must start with https://</mra-validation-message>
|
||||
</mra-field>
|
||||
|
||||
<mra-field label="Textarea" forId="f-desc" hint="Max 500 characters">
|
||||
<mra-textarea id="f-desc" [rows]="4" [maxlength]="500" [placeholder]="'Describe the resource…'" [(ngModel)]="description" />
|
||||
</mra-field>
|
||||
</mra-main-panel>
|
||||
|
||||
<mra-main-panel title="Selection Controls">
|
||||
<mra-field label="Select dropdown" forId="f-env">
|
||||
<mra-select id="f-env" [options]="envOptions" [(ngModel)]="environment" />
|
||||
</mra-field>
|
||||
|
||||
<mra-field label="Radio group">
|
||||
<mra-radio-group [name]="'deploy'" [options]="deployOptions" [(ngModel)]="deployMode" />
|
||||
</mra-field>
|
||||
|
||||
<mra-field label="Segmented control">
|
||||
<mra-segmented-control [options]="segOptions" [(ngModel)]="segValue" />
|
||||
</mra-field>
|
||||
|
||||
<div class="toggle-group">
|
||||
<mra-switch id="f-notify" label="Enable notifications" [(ngModel)]="notify" />
|
||||
<mra-checkbox id="f-agree" label="I agree to the terms" [(ngModel)]="agree" />
|
||||
<mra-checkbox id="f-tls" label="Require TLS" [(ngModel)]="tls" />
|
||||
</div>
|
||||
</mra-main-panel>
|
||||
|
||||
<mra-main-panel title="Numeric Controls">
|
||||
<mra-field label="Slider — threshold" forId="f-slider" hint="Set the alerting threshold">
|
||||
<mra-slider id="f-slider" [min]="0" [max]="100" [step]="5" [unit]="'%'" [(ngModel)]="threshold" />
|
||||
</mra-field>
|
||||
|
||||
<mra-field label="Number stepper — port" forId="f-port">
|
||||
<mra-number-stepper id="f-port" [min]="1" [max]="65535" [step]="1" [(ngModel)]="port" />
|
||||
</mra-field>
|
||||
</mra-main-panel>
|
||||
|
||||
<mra-form-actions>
|
||||
<mra-button [variant]="'default'" (click)="reset()">Reset</mra-button>
|
||||
<mra-button [variant]="'primary'" (click)="submit()">Submit Form</mra-button>
|
||||
</mra-form-actions>
|
||||
`,
|
||||
styles: `
|
||||
:host { display: flex; flex-direction: column; gap: var(--mra-space-4); }
|
||||
.toggle-group { display: flex; flex-direction: column; gap: var(--mra-space-2); padding: var(--mra-space-2) 0; }
|
||||
`,
|
||||
})
|
||||
export class FormsPage {
|
||||
readonly submitted = signal(false);
|
||||
|
||||
name = '';
|
||||
readonlyVal = 'resource-0042 (locked)';
|
||||
url = 'http://example.com';
|
||||
description = '';
|
||||
environment = 'production';
|
||||
deployMode = 'blue-green';
|
||||
segValue = 'auto';
|
||||
notify = true;
|
||||
agree = false;
|
||||
tls = true;
|
||||
threshold = 85;
|
||||
port = 5432;
|
||||
|
||||
readonly envOptions: MraSelectOption[] = [
|
||||
{ value: 'dev', label: 'Development' },
|
||||
{ value: 'staging', label: 'Staging' },
|
||||
{ value: 'production', label: 'Production' },
|
||||
];
|
||||
|
||||
readonly deployOptions: MraRadioOption[] = [
|
||||
{ value: 'rolling', label: 'Rolling' },
|
||||
{ value: 'blue-green', label: 'Blue-Green' },
|
||||
{ value: 'canary', label: 'Canary' },
|
||||
];
|
||||
|
||||
readonly segOptions: MraSegmentedOption[] = [
|
||||
{ value: 'auto', label: 'Automatic' },
|
||||
{ value: 'manual', label: 'Manual' },
|
||||
{ value: 'hybrid', label: 'Hybrid' },
|
||||
];
|
||||
|
||||
submit(): void {
|
||||
this.submitted.set(true);
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.submitted.set(false);
|
||||
this.name = '';
|
||||
this.url = '';
|
||||
this.description = '';
|
||||
this.agree = false;
|
||||
}
|
||||
}
|
||||
100
projects/demo/src/app/pages/list.ts
Normal file
100
projects/demo/src/app/pages/list.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { Component, computed, inject, signal } from '@angular/core';
|
||||
import {
|
||||
MraListPage,
|
||||
MraTable,
|
||||
MraFilterBar,
|
||||
MraButton,
|
||||
MraButtonGroup,
|
||||
MraSortState,
|
||||
MraRowAction,
|
||||
} from '@mrarm/ui';
|
||||
import { DemoDataService, Incident } from '../services/demo-data';
|
||||
|
||||
@Component({
|
||||
selector: 'mra-demo-list',
|
||||
imports: [MraListPage, MraTable, MraFilterBar, MraButton, MraButtonGroup],
|
||||
template: `
|
||||
<mra-list-page title="Incidents" subtitle="All active and recent incidents across infrastructure" [hasFilters]="true">
|
||||
<mra-button [variant]="'primary'" [slot]="'actions'">New Incident</mra-button>
|
||||
|
||||
<mra-filter-bar
|
||||
[slot]="'filters'"
|
||||
[query]="query()"
|
||||
[placeholder]="'Search incidents…'"
|
||||
[count]="filtered().length"
|
||||
[total]="data.incidents().length"
|
||||
(queryChange)="query.set($event)"
|
||||
>
|
||||
<mra-button-group>
|
||||
<mra-button [size]="'sm'" [variant]="statusFilter() === 'all' ? 'primary' : 'default'" (click)="statusFilter.set('all')">All</mra-button>
|
||||
<mra-button [size]="'sm'" [variant]="statusFilter() === 'open' ? 'primary' : 'default'" (click)="statusFilter.set('open')">Open</mra-button>
|
||||
<mra-button [size]="'sm'" [variant]="statusFilter() === 'investigating' ? 'primary' : 'default'" (click)="statusFilter.set('investigating')">Investigating</mra-button>
|
||||
<mra-button [size]="'sm'" [variant]="statusFilter() === 'resolved' ? 'primary' : 'default'" (click)="statusFilter.set('resolved')">Resolved</mra-button>
|
||||
</mra-button-group>
|
||||
</mra-filter-bar>
|
||||
|
||||
<mra-table
|
||||
[columns]="data.incidentColumns"
|
||||
[rows]="tableRows()"
|
||||
[sortable]="true"
|
||||
[sortKey]="sort().key"
|
||||
[sortDir]="sort().dir"
|
||||
[rowActions]="data.rowActions"
|
||||
[clickableRows]="true"
|
||||
(sortChange)="onSort($event)"
|
||||
(rowAction)="onRowAction($event)"
|
||||
caption="Incidents table"
|
||||
/>
|
||||
|
||||
<div [slot]="'footer'" class="footer">
|
||||
<span class="mra-text-sm mra-muted">Showing {{ filtered().length }} of {{ data.incidents().length }}</span>
|
||||
</div>
|
||||
</mra-list-page>
|
||||
`,
|
||||
styles: `
|
||||
:host { display: block; }
|
||||
.footer { display: flex; justify-content: flex-end; padding: var(--mra-space-2) 0; }
|
||||
`,
|
||||
})
|
||||
export class ListPage {
|
||||
protected readonly data = inject(DemoDataService);
|
||||
readonly query = signal('');
|
||||
readonly statusFilter = signal<'all' | 'open' | 'investigating' | 'resolved'>('all');
|
||||
readonly sort = signal<MraSortState>({ key: 'updatedAt', dir: 'desc' });
|
||||
|
||||
readonly filtered = computed<Incident[]>(() => {
|
||||
let items = this.data.incidents();
|
||||
const q = this.query().toLowerCase().trim();
|
||||
if (q) {
|
||||
items = items.filter(
|
||||
(i) =>
|
||||
i.id.toLowerCase().includes(q) ||
|
||||
i.title.toLowerCase().includes(q) ||
|
||||
i.service.toLowerCase().includes(q) ||
|
||||
i.assignee.toLowerCase().includes(q),
|
||||
);
|
||||
}
|
||||
const sf = this.statusFilter();
|
||||
if (sf !== 'all') {
|
||||
items = items.filter((i) => i.status === sf);
|
||||
}
|
||||
const s = this.sort();
|
||||
items = [...items].sort((a, b) => {
|
||||
const av = String(a[s.key as keyof Incident] ?? '');
|
||||
const bv = String(b[s.key as keyof Incident] ?? '');
|
||||
const cmp = av.localeCompare(bv);
|
||||
return s.dir === 'asc' ? cmp : -cmp;
|
||||
});
|
||||
return items;
|
||||
});
|
||||
|
||||
onSort(state: MraSortState): void {
|
||||
this.sort.set(state);
|
||||
}
|
||||
|
||||
onRowAction(event: { action: MraRowAction; row: Record<string, unknown> }): void {
|
||||
console.log('Row action:', event.action.label, event.row['id']);
|
||||
}
|
||||
|
||||
readonly tableRows = computed(() => this.filtered() as unknown as readonly Record<string, unknown>[]);
|
||||
}
|
||||
60
projects/demo/src/app/pages/logs.ts
Normal file
60
projects/demo/src/app/pages/logs.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import {
|
||||
MraPageHeader,
|
||||
MraMainPanel,
|
||||
MraLogStream,
|
||||
MraCodeBox,
|
||||
MraButton,
|
||||
MraBadge,
|
||||
MraFilterBar,
|
||||
MraEmptyState,
|
||||
} from '@mrarm/ui';
|
||||
import { DemoDataService } from '../services/demo-data';
|
||||
|
||||
@Component({
|
||||
selector: 'mra-logs-page',
|
||||
imports: [MraPageHeader, MraMainPanel, MraLogStream, MraCodeBox, MraButton, MraBadge, MraFilterBar, MraEmptyState],
|
||||
template: `
|
||||
<mra-page-header title="Log Stream" subtitle="Real-time application and system logs">
|
||||
<mra-badge [variant]="'ok'" [slot]="'actions'">{{ entries.length }} entries</mra-badge>
|
||||
<mra-button [variant]="'default'" [slot]="'actions'">Export</mra-button>
|
||||
</mra-page-header>
|
||||
|
||||
<mra-main-panel title="Live Log Stream" subtitle="db-prod-03 / postgresql" [flush]="true">
|
||||
<mra-filter-bar [query]="''" [placeholder]="'Filter logs…'" [count]="entries.length" [total]="entries.length" />
|
||||
<mra-log-stream [entries]="entries" />
|
||||
</mra-main-panel>
|
||||
|
||||
<mra-main-panel title="Structured Log (JSON)">
|
||||
<mra-code-box title="event.json" [mono]="true"><code>{{ jsonLog }}</code></mra-code-box>
|
||||
</mra-main-panel>
|
||||
|
||||
<mra-main-panel title="Empty State Example">
|
||||
<mra-empty-state message="No log entries in selected time range" hint="Adjust the time filter or select a different service" />
|
||||
</mra-main-panel>
|
||||
`,
|
||||
styles: `
|
||||
:host { display: flex; flex-direction: column; gap: var(--mra-space-4); }
|
||||
`,
|
||||
})
|
||||
export class LogsPage {
|
||||
protected readonly data = inject(DemoDataService);
|
||||
protected readonly entries = this.data.logEntries();
|
||||
|
||||
readonly jsonLog = `{
|
||||
"timestamp": "2026-06-17T09:02:10.412Z",
|
||||
"level": "FATAL",
|
||||
"service": "postgresql",
|
||||
"host": "db-prod-03",
|
||||
"message": "connection pool exhausted (480/500)",
|
||||
"metadata": {
|
||||
"pool_size": 500,
|
||||
"active": 412,
|
||||
"idle": 66,
|
||||
"idle_in_txn": 2,
|
||||
"oldest_query_age_s": 184,
|
||||
"failover_initiated": true
|
||||
},
|
||||
"trace_id": "4f8a2b1c-9d3e-4a7f-8b2c-1e5f3a8d9c04"
|
||||
}`;
|
||||
}
|
||||
171
projects/demo/src/app/pages/overlays.ts
Normal file
171
projects/demo/src/app/pages/overlays.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { Component, signal } from '@angular/core';
|
||||
import {
|
||||
MraPageHeader,
|
||||
MraMainPanel,
|
||||
MraButton,
|
||||
MraBadge,
|
||||
MraDialog,
|
||||
MraDrawer,
|
||||
MraPopover,
|
||||
MraMenu,
|
||||
MraTooltip,
|
||||
MraMenuItem,
|
||||
MraSplitButton,
|
||||
MraSplitMenuItem,
|
||||
MraToastService,
|
||||
MraToastContainer,
|
||||
} from '@mrarm/ui';
|
||||
|
||||
@Component({
|
||||
selector: 'mra-overlays-page',
|
||||
imports: [
|
||||
MraPageHeader,
|
||||
MraMainPanel,
|
||||
MraButton,
|
||||
MraBadge,
|
||||
MraDialog,
|
||||
MraDrawer,
|
||||
MraPopover,
|
||||
MraMenu,
|
||||
MraTooltip,
|
||||
MraSplitButton,
|
||||
MraToastContainer,
|
||||
],
|
||||
template: `
|
||||
<mra-page-header title="Overlays" subtitle="Dialogs, drawers, popovers, menus, tooltips, and toasts">
|
||||
<mra-badge [variant]="'accent'" [slot]="'actions'">Interactive demos</mra-badge>
|
||||
</mra-page-header>
|
||||
|
||||
<mra-main-panel title="Dialogs & Drawers">
|
||||
<div class="overlay-grid">
|
||||
<mra-button [variant]="'primary'" (click)="dialogOpen.set(true)">Open Dialog</mra-button>
|
||||
<mra-button [variant]="'default'" (click)="drawerOpen.set(true)">Open Drawer</mra-button>
|
||||
<mra-button [variant]="'default'" (click)="leftDrawerOpen.set(true)">Open Left Drawer</mra-button>
|
||||
</div>
|
||||
</mra-main-panel>
|
||||
|
||||
<mra-main-panel title="Popovers & Menus">
|
||||
<div class="overlay-grid">
|
||||
<mra-popover #pop>
|
||||
<mra-button [variant]="'default'" (click)="pop.show()">Show Popover</mra-button>
|
||||
<div [slot]="'content'" class="pop-content">
|
||||
<strong>Popover content</strong>
|
||||
<p class="mra-text-sm mra-muted">This is a popover panel with custom content.</p>
|
||||
<mra-button [size]="'sm'" [variant]="'primary'" (click)="pop.hide()">Dismiss</mra-button>
|
||||
</div>
|
||||
</mra-popover>
|
||||
|
||||
<mra-menu #menu [items]="menuItems" (selectEvent)="onMenuSelect($event)">
|
||||
<mra-button [variant]="'default'" (click)="menu.toggle($event)">Open Menu</mra-button>
|
||||
</mra-menu>
|
||||
|
||||
<mra-split-button [variant]="'primary'" [items]="splitItems" (primaryClick)="toast.ok('Primary action triggered')" (menuSelect)="onSplitSelect($event)">
|
||||
Run Task
|
||||
</mra-split-button>
|
||||
</div>
|
||||
</mra-main-panel>
|
||||
|
||||
<mra-main-panel title="Tooltips">
|
||||
<div class="overlay-grid">
|
||||
<mra-tooltip [text]="'Top tooltip — additional context'" [side]="'top'">
|
||||
<mra-button [variant]="'default'">Hover me (top)</mra-button>
|
||||
</mra-tooltip>
|
||||
<mra-tooltip [text]="'Right tooltip'" [side]="'right'">
|
||||
<mra-button [variant]="'default'">Hover me (right)</mra-button>
|
||||
</mra-tooltip>
|
||||
<mra-tooltip [text]="'Bottom tooltip'" [side]="'bottom'">
|
||||
<mra-button [variant]="'default'">Hover me (bottom)</mra-button>
|
||||
</mra-tooltip>
|
||||
<mra-tooltip [text]="'Left tooltip'" [side]="'left'">
|
||||
<mra-button [variant]="'default'">Hover me (left)</mra-button>
|
||||
</mra-tooltip>
|
||||
</div>
|
||||
</mra-main-panel>
|
||||
|
||||
<mra-main-panel title="Toasts">
|
||||
<div class="overlay-grid">
|
||||
<mra-button [variant]="'default'" (click)="toast.info('Informational message')">Info Toast</mra-button>
|
||||
<mra-button [variant]="'primary'" (click)="toast.ok('Operation completed successfully')">OK Toast</mra-button>
|
||||
<mra-button [variant]="'default'" (click)="toast.warn('This action requires attention')">Warn Toast</mra-button>
|
||||
<mra-button [variant]="'danger'" (click)="toast.bad('Critical failure detected')">Bad Toast</mra-button>
|
||||
<mra-button [variant]="'default'" (click)="toast.clear()">Clear All</mra-button>
|
||||
</div>
|
||||
</mra-main-panel>
|
||||
|
||||
<mra-dialog [open]="dialogOpen()" title="Confirm Deployment" [closable]="true" (closed)="dialogOpen.set(false)">
|
||||
<p class="mra-text">Are you sure you want to deploy ingest-api v2.15.0 to production?</p>
|
||||
<p class="mra-text-sm mra-muted">This will affect 8 worker instances. The deployment will use a rolling strategy with 2 replicas at a time.</p>
|
||||
<div class="dialog-actions">
|
||||
<mra-button [variant]="'default'" (click)="dialogOpen.set(false)">Cancel</mra-button>
|
||||
<mra-button [variant]="'primary'" (click)="dialogOpen.set(false); toast.ok('Deployment initiated')">Deploy</mra-button>
|
||||
</div>
|
||||
</mra-dialog>
|
||||
|
||||
<mra-drawer [open]="drawerOpen()" title="Incident Notes" [side]="'right'" [closable]="true" (closed)="drawerOpen.set(false)">
|
||||
<p class="mra-text">Notes for INC-2041:</p>
|
||||
<ul class="mra-text-sm">
|
||||
<li>08:14 — First alert triggered</li>
|
||||
<li>08:31 — Failover initiated</li>
|
||||
<li>09:02 — Pool exhaustion confirmed at 480/500</li>
|
||||
</ul>
|
||||
<p class="mra-text-sm mra-muted">Connection pool max is 500. 12 idle connections were from stale workers.</p>
|
||||
</mra-drawer>
|
||||
|
||||
<mra-drawer [open]="leftDrawerOpen()" title="Filters" [side]="'left'" [closable]="true" (closed)="leftDrawerOpen.set(false)">
|
||||
<p class="mra-text">Filter options would go here.</p>
|
||||
</mra-drawer>
|
||||
|
||||
<mra-toast-container />
|
||||
`,
|
||||
styles: `
|
||||
:host { display: flex; flex-direction: column; gap: var(--mra-space-4); }
|
||||
.overlay-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--mra-space-3);
|
||||
align-items: center;
|
||||
}
|
||||
.pop-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--mra-space-2);
|
||||
padding: var(--mra-space-2);
|
||||
min-width: 220px;
|
||||
}
|
||||
.dialog-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--mra-space-2);
|
||||
margin-top: var(--mra-space-4);
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class OverlaysPage {
|
||||
readonly dialogOpen = signal(false);
|
||||
readonly drawerOpen = signal(false);
|
||||
readonly leftDrawerOpen = signal(false);
|
||||
|
||||
constructor(protected readonly toast: MraToastService) {}
|
||||
|
||||
readonly menuItems: MraMenuItem[] = [
|
||||
{ label: 'View details', value: 'view' },
|
||||
{ label: 'Edit', value: 'edit' },
|
||||
{ label: 'Duplicate', value: 'dup' },
|
||||
{ label: '', value: '', divider: true },
|
||||
{ label: 'Delete', value: 'delete', danger: true },
|
||||
];
|
||||
|
||||
readonly splitItems = [
|
||||
{ label: 'Run with dry-run', value: 'dry' },
|
||||
{ label: 'Run with verbose logging', value: 'verbose' },
|
||||
{ label: 'Schedule for later', value: 'schedule' },
|
||||
];
|
||||
|
||||
onMenuSelect(item: MraMenuItem): void {
|
||||
this.toast.info(`Selected: ${item.label}`);
|
||||
}
|
||||
|
||||
onSplitSelect(item: MraSplitMenuItem): void {
|
||||
this.toast.info(`Selected: ${item.label}`);
|
||||
}
|
||||
}
|
||||
62
projects/demo/src/app/pages/review.ts
Normal file
62
projects/demo/src/app/pages/review.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import {
|
||||
MraReviewQueue,
|
||||
MraBadge,
|
||||
MraButton,
|
||||
MraConfirmationAction,
|
||||
} from '@mrarm/ui';
|
||||
import { DemoDataService } from '../services/demo-data';
|
||||
|
||||
@Component({
|
||||
selector: 'mra-review-page',
|
||||
imports: [MraReviewQueue, MraBadge, MraButton, MraConfirmationAction],
|
||||
template: `
|
||||
<mra-review-queue title="Review Queue" subtitle="Pending approvals for changes, access, and policy updates">
|
||||
<mra-badge [variant]="'warn'" [slot]="'actions'">{{ data.reviewItems().length }} pending</mra-badge>
|
||||
|
||||
@for (item of data.reviewItems(); track item.id) {
|
||||
<li class="review-item">
|
||||
<div class="review-item__head">
|
||||
<span class="review-item__id">{{ item.id }}</span>
|
||||
<mra-badge [variant]="item.risk === 'high' ? 'bad' : item.risk === 'medium' ? 'warn' : 'ok'">{{ item.risk }}</mra-badge>
|
||||
<mra-badge [variant]="'neutral'">{{ item.type }}</mra-badge>
|
||||
<span class="review-item__time">{{ item.requestedAt }}</span>
|
||||
</div>
|
||||
<div class="review-item__title">{{ item.title }}</div>
|
||||
<div class="review-item__detail mra-text-sm mra-muted">{{ item.detail }}</div>
|
||||
<div class="review-item__meta">Requested by {{ item.requestedBy }}</div>
|
||||
<div class="review-item__actions">
|
||||
<mra-button [variant]="'default'">Ask for info</mra-button>
|
||||
<mra-button [variant]="'danger'">Reject</mra-button>
|
||||
<mra-confirmation-action [variant]="'primary'" [confirmLabel]="'Approve'" [confirmText]="'Approve ' + item.id + '?'">Approve</mra-confirmation-action>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
</mra-review-queue>
|
||||
`,
|
||||
styles: `
|
||||
:host { display: block; }
|
||||
.review-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--mra-space-1);
|
||||
}
|
||||
.review-item__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--mra-space-2);
|
||||
}
|
||||
.review-item__id { font-weight: 700; }
|
||||
.review-item__time { margin-left: auto; color: var(--mra-muted); font-size: var(--mra-font-size-sm); }
|
||||
.review-item__title { font-weight: 600; }
|
||||
.review-item__actions {
|
||||
display: flex;
|
||||
gap: var(--mra-space-2);
|
||||
align-items: center;
|
||||
margin-top: var(--mra-space-2);
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class ReviewPage {
|
||||
protected readonly data = inject(DemoDataService);
|
||||
}
|
||||
123
projects/demo/src/app/pages/tables.ts
Normal file
123
projects/demo/src/app/pages/tables.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { Component, computed, inject, signal } from '@angular/core';
|
||||
import {
|
||||
MraPageHeader,
|
||||
MraMainPanel,
|
||||
MraTable,
|
||||
MraFilterBar,
|
||||
MraButton,
|
||||
MraPagination,
|
||||
MraEmptyState,
|
||||
MraTableColumn,
|
||||
MraSortState,
|
||||
} from '@mrarm/ui';
|
||||
import { DemoDataService, Incident } from '../services/demo-data';
|
||||
|
||||
@Component({
|
||||
selector: 'mra-tables-page',
|
||||
imports: [MraPageHeader, MraMainPanel, MraTable, MraFilterBar, MraButton, MraPagination, MraEmptyState],
|
||||
template: `
|
||||
<mra-page-header title="Tables" subtitle="Sortable, filterable tables with row actions and pagination">
|
||||
<mra-button [variant]="'primary'" [slot]="'actions'">Export CSV</mra-button>
|
||||
</mra-page-header>
|
||||
|
||||
<mra-main-panel title="Incidents Table" subtitle="Full-featured data table with sorting and pagination">
|
||||
<mra-filter-bar
|
||||
[query]="query()"
|
||||
[placeholder]="'Filter…'"
|
||||
[count]="filtered().length"
|
||||
[total]="data.incidents().length"
|
||||
(queryChange)="query.set($event)"
|
||||
/>
|
||||
|
||||
<mra-table
|
||||
[columns]="columns"
|
||||
[rows]="pagedRows()"
|
||||
[sortable]="true"
|
||||
[sortKey]="sort().key"
|
||||
[sortDir]="sort().dir"
|
||||
[dense]="dense()"
|
||||
[rowActions]="data.rowActions"
|
||||
[clickableRows]="true"
|
||||
(sortChange)="onSort($event)"
|
||||
/>
|
||||
|
||||
@if (filtered().length === 0) {
|
||||
<mra-empty-state message="No incidents found" hint="Try adjusting your search query" />
|
||||
}
|
||||
|
||||
<mra-pagination
|
||||
[page]="page()"
|
||||
[total]="filtered().length"
|
||||
[perPage]="perPage()"
|
||||
[showSize]="true"
|
||||
(pageChange)="page.set($event)"
|
||||
(perPageChange)="onPerPage($event)"
|
||||
/>
|
||||
</mra-main-panel>
|
||||
|
||||
<mra-main-panel title="Compact Table" subtitle="Dense variant for dashboards">
|
||||
<mra-table
|
||||
[columns]="compactColumns"
|
||||
[rows]="compactRows()"
|
||||
[dense]="true"
|
||||
caption="Recent incidents (compact)"
|
||||
/>
|
||||
</mra-main-panel>
|
||||
|
||||
<div class="toggle-row">
|
||||
<mra-button [size]="'sm'" [variant]="dense() ? 'primary' : 'default'" (click)="dense.set(!dense())">
|
||||
{{ dense() ? 'Dense: ON' : 'Dense: OFF' }}
|
||||
</mra-button>
|
||||
</div>
|
||||
`,
|
||||
styles: `
|
||||
:host { display: flex; flex-direction: column; gap: var(--mra-space-4); }
|
||||
.toggle-row { display: flex; gap: var(--mra-space-2); }
|
||||
`,
|
||||
})
|
||||
export class TablesPage {
|
||||
protected readonly data = inject(DemoDataService);
|
||||
readonly query = signal('');
|
||||
readonly page = signal(1);
|
||||
readonly perPage = signal(5);
|
||||
readonly dense = signal(false);
|
||||
readonly sort = signal<MraSortState>({ key: 'updatedAt', dir: 'desc' });
|
||||
|
||||
readonly columns: MraTableColumn[] = this.data.incidentColumns;
|
||||
|
||||
readonly compactColumns: MraTableColumn[] = [
|
||||
{ key: 'id', label: 'ID', width: '100px' },
|
||||
{ key: 'title', label: 'Title' },
|
||||
{ key: 'severity', label: 'Sev', width: '80px' },
|
||||
{ key: 'status', label: 'Status', width: '110px' },
|
||||
];
|
||||
|
||||
readonly filtered = computed<Incident[]>(() => {
|
||||
const q = this.query().toLowerCase().trim();
|
||||
if (!q) return this.data.incidents();
|
||||
return this.data.incidents().filter(
|
||||
(i) =>
|
||||
i.id.toLowerCase().includes(q) ||
|
||||
i.title.toLowerCase().includes(q) ||
|
||||
i.service.toLowerCase().includes(q),
|
||||
);
|
||||
});
|
||||
|
||||
readonly paged = computed<Incident[]>(() => {
|
||||
const start = (this.page() - 1) * this.perPage();
|
||||
return this.filtered().slice(start, start + this.perPage());
|
||||
});
|
||||
|
||||
readonly pagedRows = computed(() => this.paged() as unknown as readonly Record<string, unknown>[]);
|
||||
readonly compactRows = computed(() => this.data.incidents().slice(0, 4) as unknown as readonly Record<string, unknown>[]);
|
||||
|
||||
onSort(state: MraSortState): void {
|
||||
this.sort.set(state);
|
||||
this.page.set(1);
|
||||
}
|
||||
|
||||
onPerPage(size: number): void {
|
||||
this.perPage.set(size);
|
||||
this.page.set(1);
|
||||
}
|
||||
}
|
||||
251
projects/demo/src/app/services/demo-data.ts
Normal file
251
projects/demo/src/app/services/demo-data.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
import {
|
||||
MraLogEntry,
|
||||
MraActivityItem,
|
||||
MraTableColumn,
|
||||
MraRowAction,
|
||||
} from '@mrarm/ui';
|
||||
|
||||
export interface Incident {
|
||||
id: string;
|
||||
title: string;
|
||||
severity: 'low' | 'medium' | 'high' | 'critical';
|
||||
status: 'open' | 'investigating' | 'resolved' | 'closed';
|
||||
assignee: string;
|
||||
source: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
host: string;
|
||||
service: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface ReviewItem {
|
||||
id: string;
|
||||
type: 'alert' | 'change' | 'access' | 'policy';
|
||||
title: string;
|
||||
requestedBy: string;
|
||||
requestedAt: string;
|
||||
risk: 'low' | 'medium' | 'high';
|
||||
detail: string;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class DemoDataService {
|
||||
readonly incidents = signal<Incident[]>([
|
||||
{
|
||||
id: 'INC-2041',
|
||||
title: 'Database connection pool exhaustion on db-prod-03',
|
||||
severity: 'critical',
|
||||
status: 'investigating',
|
||||
assignee: 'A. Okonkwo',
|
||||
source: 'monitoring',
|
||||
createdAt: '2026-06-17 08:14:22',
|
||||
updatedAt: '2026-06-17 09:02:10',
|
||||
host: 'db-prod-03',
|
||||
service: 'postgresql',
|
||||
description:
|
||||
'Connection pool reached 480/500. Application latency spiked to 4.2s p99. Failover to replica db-prod-04 initiated at 08:31.',
|
||||
},
|
||||
{
|
||||
id: 'INC-2040',
|
||||
title: 'API gateway 502 errors on /v1/ingest',
|
||||
severity: 'high',
|
||||
status: 'open',
|
||||
assignee: 'M. Chen',
|
||||
source: 'synthetic',
|
||||
createdAt: '2026-06-17 07:55:01',
|
||||
updatedAt: '2026-06-17 08:40:33',
|
||||
host: 'api-gw-02',
|
||||
service: 'ingest-api',
|
||||
description:
|
||||
'Synthetic checks report 12% 502 rate on ingest endpoint. Upstream workers show elevated memory. Investigating OOM kills.',
|
||||
},
|
||||
{
|
||||
id: 'INC-2039',
|
||||
title: 'Disk usage above 90% on log-aggregator-01',
|
||||
severity: 'medium',
|
||||
status: 'investigating',
|
||||
assignee: 'J. Patel',
|
||||
source: 'alerting',
|
||||
createdAt: '2026-06-17 06:30:15',
|
||||
updatedAt: '2026-06-17 07:15:00',
|
||||
host: 'log-agg-01',
|
||||
service: 'fluentbit',
|
||||
description:
|
||||
'Log volume increased 3x after deploy v2.14.2. Disk fill ETA 4h. Retention policy review pending.',
|
||||
},
|
||||
{
|
||||
id: 'INC-2038',
|
||||
title: 'TLS certificate expiring on auth.internal',
|
||||
severity: 'medium',
|
||||
status: 'resolved',
|
||||
assignee: 'K. Yusuf',
|
||||
source: 'cert-manager',
|
||||
createdAt: '2026-06-16 22:10:00',
|
||||
updatedAt: '2026-06-17 01:45:20',
|
||||
host: 'auth-svc-01',
|
||||
service: 'auth',
|
||||
description:
|
||||
'Certificate for auth.internal expiring in 48h. Renewed via cert-manager. Verified chain validity.',
|
||||
},
|
||||
{
|
||||
id: 'INC-2037',
|
||||
title: 'Queue depth spike on events-worker',
|
||||
severity: 'low',
|
||||
status: 'closed',
|
||||
assignee: 'S. Berg',
|
||||
source: 'monitoring',
|
||||
createdAt: '2026-06-16 18:22:11',
|
||||
updatedAt: '2026-06-16 19:00:00',
|
||||
host: 'events-w-04',
|
||||
service: 'events-worker',
|
||||
description:
|
||||
'Queue depth hit 42k messages. Consumer autoscale triggered. Drained within 38 minutes. Root cause: batch ingest from client.',
|
||||
},
|
||||
{
|
||||
id: 'INC-2036',
|
||||
title: 'Memory leak suspected in report-svc',
|
||||
severity: 'high',
|
||||
status: 'open',
|
||||
assignee: 'A. Okonkwo',
|
||||
source: 'profiler',
|
||||
createdAt: '2026-06-16 14:05:30',
|
||||
updatedAt: '2026-06-16 16:20:00',
|
||||
host: 'report-svc-02',
|
||||
service: 'report-svc',
|
||||
description:
|
||||
'RSS grows 200MB/hour under normal load. Heap dump captured. Suspecting PDF rendering library not releasing buffers.',
|
||||
},
|
||||
{
|
||||
id: 'INC-2035',
|
||||
title: 'DNS resolution latency on eu-west region',
|
||||
severity: 'low',
|
||||
status: 'resolved',
|
||||
assignee: 'M. Chen',
|
||||
source: 'synthetic',
|
||||
createdAt: '2026-06-16 10:15:00',
|
||||
updatedAt: '2026-06-16 11:30:00',
|
||||
host: 'dns-eu-01',
|
||||
service: 'coredns',
|
||||
description:
|
||||
'p99 DNS latency 45ms in eu-west. Resolver cache tuned. Latency back to 3ms.',
|
||||
},
|
||||
{
|
||||
id: 'INC-2034',
|
||||
title: 'Failed backup on db-analytics',
|
||||
severity: 'medium',
|
||||
status: 'investigating',
|
||||
assignee: 'J. Patel',
|
||||
source: 'backup-system',
|
||||
createdAt: '2026-06-16 03:00:00',
|
||||
updatedAt: '2026-06-16 08:12:00',
|
||||
host: 'db-analytics-01',
|
||||
service: 'postgresql',
|
||||
description:
|
||||
'Nightly snapshot failed with WAL segment missing. Manual checkpoint attempted. Investigating storage backend.',
|
||||
},
|
||||
]);
|
||||
|
||||
readonly reviewItems = signal<ReviewItem[]>([
|
||||
{
|
||||
id: 'REV-118',
|
||||
type: 'change',
|
||||
title: 'Deploy ingest-api v2.15.0 to production',
|
||||
requestedBy: 'M. Chen',
|
||||
requestedAt: '2026-06-17 09:20:00',
|
||||
risk: 'medium',
|
||||
detail: 'Includes connection retry logic + reduced batch size. Staged on 2 of 8 workers.',
|
||||
},
|
||||
{
|
||||
id: 'REV-117',
|
||||
type: 'access',
|
||||
title: 'Prod DB read access for data-team',
|
||||
requestedBy: 'K. Yusuf',
|
||||
requestedAt: '2026-06-17 08:45:00',
|
||||
risk: 'low',
|
||||
detail: 'Read-only role on analytics replica. 30-day expiry. 3 users.',
|
||||
},
|
||||
{
|
||||
id: 'REV-116',
|
||||
type: 'policy',
|
||||
title: 'Relax alerting threshold for disk usage 90% → 95%',
|
||||
requestedBy: 'J. Patel',
|
||||
requestedAt: '2026-06-17 07:30:00',
|
||||
risk: 'high',
|
||||
detail: 'Global policy change. Could delay detection of disk exhaustion incidents.',
|
||||
},
|
||||
{
|
||||
id: 'REV-115',
|
||||
type: 'alert',
|
||||
title: 'Suppress alerts for db-prod-03 during maintenance window',
|
||||
requestedBy: 'A. Okonkwo',
|
||||
requestedAt: '2026-06-17 06:00:00',
|
||||
risk: 'medium',
|
||||
detail: '2h suppression window 10:00–12:00 UTC. Auto-re-enable after window.',
|
||||
},
|
||||
{
|
||||
id: 'REV-114',
|
||||
type: 'change',
|
||||
title: 'Rollback report-svc to v2.13.4',
|
||||
requestedBy: 'S. Berg',
|
||||
requestedAt: '2026-06-16 17:00:00',
|
||||
risk: 'high',
|
||||
detail: 'Memory leak suspected in v2.14. Rollback to last known stable before profiler analysis.',
|
||||
},
|
||||
]);
|
||||
|
||||
readonly activity = signal<MraActivityItem[]>([
|
||||
{ ts: '2026-06-17 09:02:10', title: 'Failover initiated', detail: 'db-prod-03 → db-prod-04', variant: 'warn' },
|
||||
{ ts: '2026-06-17 08:40:33', title: 'Synthetic check failed', detail: '/v1/ingest returned 502', variant: 'bad' },
|
||||
{ ts: '2026-06-17 07:15:00', title: 'Autoscale triggered', detail: 'events-worker scaled +3', variant: 'ok' },
|
||||
{ ts: '2026-06-17 06:30:15', title: 'Disk threshold breached', detail: 'log-agg-01 at 91%', variant: 'warn' },
|
||||
{ ts: '2026-06-17 01:45:20', title: 'Certificate renewed', detail: 'auth.internal TLS chain verified', variant: 'ok' },
|
||||
{ ts: '2026-06-16 19:00:00', title: 'Queue drained', detail: 'events-w-04 back to 0 depth', variant: 'ok' },
|
||||
{ ts: '2026-06-16 16:20:00', title: 'Heap dump captured', detail: 'report-svc-02 PID 4482', variant: 'neutral' },
|
||||
{ ts: '2026-06-16 11:30:00', title: 'DNS latency resolved', detail: 'eu-west p99 back to 3ms', variant: 'ok' },
|
||||
]);
|
||||
|
||||
readonly logEntries = signal<MraLogEntry[]>([
|
||||
{ ts: '09:02:10', level: 'bad', text: 'FATAL: connection pool exhausted (480/500) on db-prod-03' },
|
||||
{ ts: '09:02:08', level: 'warn', text: 'pool utilisation 96% — threshold breached' },
|
||||
{ ts: '09:01:55', level: 'info', text: 'failover coordinator acquired lock for db-prod-03' },
|
||||
{ ts: '08:59:30', level: 'warn', text: 'latency p99=4.2s on /v1/query (threshold 800ms)' },
|
||||
{ ts: '08:55:12', level: 'info', text: 'health check: 3/8 workers unhealthy on ingest-api' },
|
||||
{ ts: '08:40:33', level: 'bad', text: '502 Bad Gateway — upstream timeout on /v1/ingest' },
|
||||
{ ts: '08:31:00', level: 'info', text: 'initiating graceful drain on api-gw-02' },
|
||||
{ ts: '08:14:22', level: 'warn', text: 'elevated 5xx rate detected by synthetic monitor' },
|
||||
{ ts: '07:55:01', level: 'info', text: 'synthetic check /v1/ingest started from eu-west-1' },
|
||||
{ ts: '07:30:00', level: 'ok', text: 'backup completed for db-prod-04 (2.3TB, 14m)' },
|
||||
{ ts: '07:15:00', level: 'ok', text: 'events-worker autoscaled from 4 → 7 instances' },
|
||||
{ ts: '06:30:15', level: 'warn', text: 'disk usage 91% on log-agg-01 (ETA 4h to full)' },
|
||||
]);
|
||||
|
||||
readonly incidentColumns: MraTableColumn[] = [
|
||||
{ key: 'id', label: 'ID', width: '110px', sortable: true },
|
||||
{ key: 'title', label: 'Title', sortable: true },
|
||||
{ key: 'severity', label: 'Severity', width: '100px', sortable: true },
|
||||
{ key: 'status', label: 'Status', width: '120px', sortable: true },
|
||||
{ key: 'assignee', label: 'Assignee', width: '120px', sortable: true },
|
||||
{ key: 'service', label: 'Service', width: '120px', sortable: true },
|
||||
{ key: 'updatedAt', label: 'Updated', width: '160px', sortable: true },
|
||||
];
|
||||
|
||||
readonly rowActions: MraRowAction[] = [
|
||||
{ label: 'View' },
|
||||
{ label: 'Acknowledge' },
|
||||
{ label: 'Reassign' },
|
||||
{ label: 'Close', danger: true },
|
||||
];
|
||||
|
||||
readonly metrics = signal({
|
||||
openIncidents: 3,
|
||||
investigating: 3,
|
||||
resolved24h: 12,
|
||||
avgResolution: '2h 14m',
|
||||
p99Latency: '4.2s',
|
||||
queueDepth: '8.4k',
|
||||
uptime: '99.94%',
|
||||
backupStatus: 'ok',
|
||||
});
|
||||
}
|
||||
13
projects/demo/src/index.html
Normal file
13
projects/demo/src/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Demo</title>
|
||||
<base href="/" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico" />
|
||||
</head>
|
||||
<body>
|
||||
<mra-root></mra-root>
|
||||
</body>
|
||||
</html>
|
||||
5
projects/demo/src/main.ts
Normal file
5
projects/demo/src/main.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { bootstrapApplication } from '@angular/platform-browser';
|
||||
import { appConfig } from './app/app.config';
|
||||
import { App } from './app/app';
|
||||
|
||||
bootstrapApplication(App, appConfig).catch((err) => console.error(err));
|
||||
1
projects/demo/src/styles.scss
Normal file
1
projects/demo/src/styles.scss
Normal file
@@ -0,0 +1 @@
|
||||
/* You can add global styles to this file, and also import other style files */
|
||||
11
projects/demo/tsconfig.app.json
Normal file
11
projects/demo/tsconfig.app.json
Normal file
@@ -0,0 +1,11 @@
|
||||
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../out-tsc/app",
|
||||
"types": []
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.spec.ts"]
|
||||
}
|
||||
10
projects/demo/tsconfig.spec.json
Normal file
10
projects/demo/tsconfig.spec.json
Normal file
@@ -0,0 +1,10 @@
|
||||
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../out-tsc/spec",
|
||||
"types": ["vitest/globals"]
|
||||
},
|
||||
"include": ["src/**/*.d.ts", "src/**/*.spec.ts"]
|
||||
}
|
||||
64
projects/mrarm-ui/README.md
Normal file
64
projects/mrarm-ui/README.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# MrarmUi
|
||||
|
||||
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 21.2.0.
|
||||
|
||||
## Code scaffolding
|
||||
|
||||
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
|
||||
|
||||
```bash
|
||||
ng generate component component-name
|
||||
```
|
||||
|
||||
For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
|
||||
|
||||
```bash
|
||||
ng generate --help
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
To build the library, run:
|
||||
|
||||
```bash
|
||||
ng build mrarm-ui
|
||||
```
|
||||
|
||||
This command will compile your project, and the build artifacts will be placed in the `dist/` directory.
|
||||
|
||||
### Publishing the Library
|
||||
|
||||
Once the project is built, you can publish your library by following these steps:
|
||||
|
||||
1. Navigate to the `dist` directory:
|
||||
|
||||
```bash
|
||||
cd dist/mrarm-ui
|
||||
```
|
||||
|
||||
2. Run the `npm publish` command to publish your library to the npm registry:
|
||||
```bash
|
||||
npm publish
|
||||
```
|
||||
|
||||
## Running unit tests
|
||||
|
||||
To execute unit tests with the [Karma](https://karma-runner.github.io) test runner, use the following command:
|
||||
|
||||
```bash
|
||||
ng test
|
||||
```
|
||||
|
||||
## Running end-to-end tests
|
||||
|
||||
For end-to-end (e2e) testing, run:
|
||||
|
||||
```bash
|
||||
ng e2e
|
||||
```
|
||||
|
||||
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
|
||||
|
||||
## Additional Resources
|
||||
|
||||
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.
|
||||
8
projects/mrarm-ui/ng-package.json
Normal file
8
projects/mrarm-ui/ng-package.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "../../node_modules/ng-packagr/ng-package.schema.json",
|
||||
"dest": "../../dist/mrarm-ui",
|
||||
"lib": {
|
||||
"entryFile": "src/public-api.ts"
|
||||
},
|
||||
"assets": ["src/styles"]
|
||||
}
|
||||
17
projects/mrarm-ui/package.json
Normal file
17
projects/mrarm-ui/package.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "@mrarm/ui",
|
||||
"version": "0.0.1",
|
||||
"peerDependencies": {
|
||||
"@angular/common": "^21.2.0",
|
||||
"@angular/core": "^21.2.0",
|
||||
"@angular/cdk": "^21.2.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
"sideEffects": false,
|
||||
"exports": {
|
||||
"./styles": "./src/styles/index.css",
|
||||
"./styles/*": "./src/styles/*"
|
||||
}
|
||||
}
|
||||
26
projects/mrarm-ui/src/lib/actions/button-group.stories.ts
Normal file
26
projects/mrarm-ui/src/lib/actions/button-group.stories.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { MraButtonGroup, MraButton } from '@mrarm/ui';
|
||||
|
||||
const meta: Meta<MraButtonGroup> = {
|
||||
title: 'Actions/Button Group',
|
||||
component: MraButtonGroup,
|
||||
tags: ['autodocs'],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
const md = { imports: [MraButtonGroup, MraButton] };
|
||||
|
||||
export const Default: StoryObj<MraButtonGroup> = {
|
||||
render: () => ({
|
||||
template: `<mra-button-group><mra-button>Cancel</mra-button><mra-button variant="primary">Save</mra-button></mra-button-group>`,
|
||||
moduleMetadata: md,
|
||||
}),
|
||||
};
|
||||
|
||||
export const Block: StoryObj<MraButtonGroup> = {
|
||||
render: () => ({
|
||||
template: `<mra-button-group [block]="true"><mra-button>Left</mra-button><mra-button>Right</mra-button></mra-button-group>`,
|
||||
moduleMetadata: md,
|
||||
}),
|
||||
};
|
||||
26
projects/mrarm-ui/src/lib/actions/button-group.ts
Normal file
26
projects/mrarm-ui/src/lib/actions/button-group.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'mra-button-group',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: ` <div class="mra-btn-group" [class.mra-btn-group--block]="block()">
|
||||
<ng-content />
|
||||
</div> `,
|
||||
styles: `
|
||||
:host {
|
||||
display: inline-flex;
|
||||
}
|
||||
.mra-btn-group {
|
||||
display: inline-flex;
|
||||
gap: var(--mra-space-3);
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.mra-btn-group--block {
|
||||
display: flex;
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class MraButtonGroup {
|
||||
readonly block = input<boolean>(false);
|
||||
}
|
||||
74
projects/mrarm-ui/src/lib/actions/button.spec.ts
Normal file
74
projects/mrarm-ui/src/lib/actions/button.spec.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { MraButton } from './button';
|
||||
|
||||
describe('MraButton', () => {
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({ imports: [MraButton] }).compileComponents();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
const fixture = TestBed.createComponent(MraButton);
|
||||
expect(fixture.componentInstance).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render projected content', () => {
|
||||
const fixture = TestBed.createComponent(MraButton);
|
||||
fixture.detectChanges();
|
||||
const el = fixture.nativeElement as HTMLElement;
|
||||
expect(el.querySelector('.mra-btn__label')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should apply variant class', () => {
|
||||
const fixture = TestBed.createComponent(MraButton);
|
||||
fixture.componentRef.setInput('variant', 'primary');
|
||||
fixture.detectChanges();
|
||||
const btn = (fixture.nativeElement as HTMLElement).querySelector('button')!;
|
||||
expect(btn.className).toContain('mra-btn--primary');
|
||||
});
|
||||
|
||||
it('should apply size class', () => {
|
||||
const fixture = TestBed.createComponent(MraButton);
|
||||
fixture.componentRef.setInput('size', 'sm');
|
||||
fixture.detectChanges();
|
||||
const btn = (fixture.nativeElement as HTMLElement).querySelector('button')!;
|
||||
expect(btn.className).toContain('mra-btn--sm');
|
||||
});
|
||||
|
||||
it('should disable when disabled or loading', () => {
|
||||
const fixture = TestBed.createComponent(MraButton);
|
||||
fixture.componentRef.setInput('disabled', true);
|
||||
fixture.detectChanges();
|
||||
let btn = (fixture.nativeElement as HTMLElement).querySelector('button')!;
|
||||
expect(btn.disabled).toBe(true);
|
||||
|
||||
fixture.componentRef.setInput('disabled', false);
|
||||
fixture.componentRef.setInput('loading', true);
|
||||
fixture.detectChanges();
|
||||
btn = (fixture.nativeElement as HTMLElement).querySelector('button')!;
|
||||
expect(btn.disabled).toBe(true);
|
||||
expect(btn.className).toContain('mra-btn--loading');
|
||||
});
|
||||
|
||||
it('should show spinner when loading', () => {
|
||||
const fixture = TestBed.createComponent(MraButton);
|
||||
fixture.componentRef.setInput('loading', true);
|
||||
fixture.detectChanges();
|
||||
const el = fixture.nativeElement as HTMLElement;
|
||||
expect(el.querySelector('.mra-btn__spinner')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should apply block class', () => {
|
||||
const fixture = TestBed.createComponent(MraButton);
|
||||
fixture.componentRef.setInput('block', true);
|
||||
fixture.detectChanges();
|
||||
const btn = (fixture.nativeElement as HTMLElement).querySelector('button')!;
|
||||
expect(btn.className).toContain('mra-btn--block');
|
||||
});
|
||||
|
||||
it('should default type to button', () => {
|
||||
const fixture = TestBed.createComponent(MraButton);
|
||||
fixture.detectChanges();
|
||||
const btn = (fixture.nativeElement as HTMLElement).querySelector('button')!;
|
||||
expect(btn.type).toBe('button');
|
||||
});
|
||||
});
|
||||
38
projects/mrarm-ui/src/lib/actions/button.stories.ts
Normal file
38
projects/mrarm-ui/src/lib/actions/button.stories.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { MraButton } from '@mrarm/ui';
|
||||
|
||||
const meta: Meta<MraButton> = {
|
||||
title: 'Actions/Button',
|
||||
component: MraButton,
|
||||
tags: ['autodocs'],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
export const Default: StoryObj<MraButton> = {
|
||||
render: () => ({ template: `<mra-button>Save changes</mra-button>` }),
|
||||
};
|
||||
|
||||
export const Primary: StoryObj<MraButton> = {
|
||||
render: () => ({ template: `<mra-button variant="primary">Confirm</mra-button>` }),
|
||||
};
|
||||
|
||||
export const Danger: StoryObj<MraButton> = {
|
||||
render: () => ({ template: `<mra-button variant="danger">Delete</mra-button>` }),
|
||||
};
|
||||
|
||||
export const Small: StoryObj<MraButton> = {
|
||||
render: () => ({ template: `<mra-button size="sm">Compact</mra-button>` }),
|
||||
};
|
||||
|
||||
export const Loading: StoryObj<MraButton> = {
|
||||
render: () => ({ template: `<mra-button variant="primary" [loading]="true">Saving…</mra-button>` }),
|
||||
};
|
||||
|
||||
export const Disabled: StoryObj<MraButton> = {
|
||||
render: () => ({ template: `<mra-button [disabled]="true">Unavailable</mra-button>` }),
|
||||
};
|
||||
|
||||
export const Block: StoryObj<MraButton> = {
|
||||
render: () => ({ template: `<mra-button variant="primary" [block]="true">Full width</mra-button>` }),
|
||||
};
|
||||
109
projects/mrarm-ui/src/lib/actions/button.ts
Normal file
109
projects/mrarm-ui/src/lib/actions/button.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
|
||||
|
||||
export type MraButtonVariant = 'default' | 'primary' | 'danger';
|
||||
export type MraButtonSize = 'sm' | 'md';
|
||||
|
||||
@Component({
|
||||
selector: 'mra-button',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<button
|
||||
[type]="type()"
|
||||
[disabled]="disabled() || loading()"
|
||||
[class]="'mra-btn mra-btn--' + variant() + ' mra-btn--' + size()"
|
||||
[class.mra-btn--block]="block()"
|
||||
[class.mra-btn--loading]="loading()"
|
||||
>
|
||||
@if (loading()) {
|
||||
<span class="mra-btn__spinner" aria-hidden="true"></span>
|
||||
}
|
||||
<span class="mra-btn__label"><ng-content /></span>
|
||||
</button>
|
||||
`,
|
||||
styles: `
|
||||
:host {
|
||||
display: inline-flex;
|
||||
}
|
||||
.mra-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--mra-space-2);
|
||||
padding: var(--mra-control-padding-y) var(--mra-control-padding-x);
|
||||
border: 1px solid var(--mra-btn-border);
|
||||
background: var(--mra-btn-bg);
|
||||
color: var(--mra-btn-text);
|
||||
font-family: var(--mra-font);
|
||||
font-size: var(--mra-font-size);
|
||||
line-height: 1.2;
|
||||
cursor: pointer;
|
||||
border-radius: var(--mra-radius);
|
||||
}
|
||||
.mra-btn:hover:not(:disabled) {
|
||||
background: var(--mra-btn-bg-hover);
|
||||
}
|
||||
.mra-btn:active:not(:disabled) {
|
||||
background: var(--mra-btn-bg-active);
|
||||
}
|
||||
.mra-btn:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.mra-btn--sm {
|
||||
padding: var(--mra-space-1) var(--mra-space-3);
|
||||
font-size: var(--mra-font-size-sm);
|
||||
}
|
||||
.mra-btn--primary {
|
||||
background: var(--mra-btn-primary-bg);
|
||||
border-color: var(--mra-btn-primary-border);
|
||||
color: var(--mra-btn-primary-text);
|
||||
font-weight: 700;
|
||||
}
|
||||
.mra-btn--primary:hover:not(:disabled) {
|
||||
background: var(--mra-btn-primary-bg-hover);
|
||||
}
|
||||
.mra-btn--danger {
|
||||
background: var(--mra-btn-danger-bg);
|
||||
border-color: var(--mra-btn-danger-border);
|
||||
color: var(--mra-btn-danger-text);
|
||||
}
|
||||
.mra-btn--danger:hover:not(:disabled) {
|
||||
background: var(--mra-btn-danger-bg-hover);
|
||||
}
|
||||
.mra-btn--block {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
.mra-btn--loading .mra-btn__label {
|
||||
opacity: 0.7;
|
||||
}
|
||||
.mra-btn__spinner {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 2px solid currentColor;
|
||||
border-right-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: mra-spin 0.7s linear infinite;
|
||||
}
|
||||
.mra-btn:focus-visible {
|
||||
box-shadow: var(--mra-focus-ring);
|
||||
}
|
||||
@keyframes mra-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.mra-btn__spinner {
|
||||
animation-duration: 1.5s;
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class MraButton {
|
||||
readonly variant = input<MraButtonVariant>('default');
|
||||
readonly size = input<MraButtonSize>('md');
|
||||
readonly type = input<'button' | 'submit' | 'reset'>('button');
|
||||
readonly disabled = input<boolean>(false);
|
||||
readonly loading = input<boolean>(false);
|
||||
readonly block = input<boolean>(false);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { MraConfirmationAction } from './confirmation-action';
|
||||
|
||||
describe('MraConfirmationAction', () => {
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({ imports: [MraConfirmationAction] }).compileComponents();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
const fixture = TestBed.createComponent(MraConfirmationAction);
|
||||
expect(fixture.componentInstance).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should show trigger button initially', () => {
|
||||
const fixture = TestBed.createComponent(MraConfirmationAction);
|
||||
fixture.detectChanges();
|
||||
const el = fixture.nativeElement as HTMLElement;
|
||||
expect(el.querySelector('mra-button')).toBeTruthy();
|
||||
expect(el.querySelector('.mra-conf')).toBeNull();
|
||||
});
|
||||
|
||||
it('should switch to confirm state on start', () => {
|
||||
const fixture = TestBed.createComponent(MraConfirmationAction);
|
||||
fixture.detectChanges();
|
||||
((fixture.nativeElement as HTMLElement).querySelector('mra-button button') as HTMLElement).click();
|
||||
fixture.detectChanges();
|
||||
expect(fixture.componentInstance.confirming()).toBe(true);
|
||||
expect((fixture.nativeElement as HTMLElement).querySelector('.mra-conf')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should emit confirmed on confirm', () => {
|
||||
const fixture = TestBed.createComponent(MraConfirmationAction);
|
||||
fixture.detectChanges();
|
||||
let confirmed = false;
|
||||
fixture.componentInstance.confirmed.subscribe(() => (confirmed = true));
|
||||
fixture.componentInstance.start();
|
||||
fixture.detectChanges();
|
||||
const dangerBtn = (fixture.nativeElement as HTMLElement).querySelector('.mra-conf [class*="danger"]') as HTMLElement;
|
||||
dangerBtn.click();
|
||||
fixture.detectChanges();
|
||||
expect(confirmed).toBe(true);
|
||||
expect(fixture.componentInstance.confirming()).toBe(false);
|
||||
});
|
||||
|
||||
it('should emit cancelled on cancel', () => {
|
||||
const fixture = TestBed.createComponent(MraConfirmationAction);
|
||||
fixture.detectChanges();
|
||||
let cancelled = false;
|
||||
fixture.componentInstance.cancelled.subscribe(() => (cancelled = true));
|
||||
fixture.componentInstance.start();
|
||||
fixture.detectChanges();
|
||||
const buttons = (fixture.nativeElement as HTMLElement).querySelectorAll('.mra-conf button');
|
||||
(buttons[buttons.length - 1] as HTMLElement).click();
|
||||
fixture.detectChanges();
|
||||
expect(cancelled).toBe(true);
|
||||
expect(fixture.componentInstance.confirming()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { MraConfirmationAction } from '@mrarm/ui';
|
||||
|
||||
const meta: Meta<MraConfirmationAction> = {
|
||||
title: 'Actions/Confirmation Action',
|
||||
component: MraConfirmationAction,
|
||||
tags: ['autodocs'],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
export const Default: StoryObj<MraConfirmationAction> = {
|
||||
render: () => ({ template: `<mra-confirmation-action>Delete row</mra-confirmation-action>` }),
|
||||
};
|
||||
|
||||
export const Danger: StoryObj<MraConfirmationAction> = {
|
||||
render: () => ({ template: `<mra-confirmation-action variant="danger">Purge cache</mra-confirmation-action>` }),
|
||||
};
|
||||
|
||||
export const CustomLabels: StoryObj<MraConfirmationAction> = {
|
||||
render: () => ({
|
||||
template: `<mra-confirmation-action confirmLabel="This cannot be undone." confirmText="Yes, delete" cancelText="Keep it">Remove file</mra-confirmation-action>`,
|
||||
}),
|
||||
};
|
||||
56
projects/mrarm-ui/src/lib/actions/confirmation-action.ts
Normal file
56
projects/mrarm-ui/src/lib/actions/confirmation-action.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { ChangeDetectionStrategy, Component, input, output, signal } from '@angular/core';
|
||||
import { MraButton } from './button';
|
||||
|
||||
@Component({
|
||||
selector: 'mra-confirmation-action',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [MraButton],
|
||||
template: `
|
||||
@if (!confirming()) {
|
||||
<mra-button [variant]="variant()" (click)="start()">
|
||||
<ng-content />
|
||||
</mra-button>
|
||||
} @else {
|
||||
<span class="mra-conf">
|
||||
<span class="mra-conf__prompt">{{ confirmLabel() }}</span>
|
||||
<mra-button variant="danger" size="sm" (click)="confirm()">{{ confirmText() }}</mra-button>
|
||||
<mra-button size="sm" (click)="cancel()">{{ cancelText() }}</mra-button>
|
||||
</span>
|
||||
}
|
||||
`,
|
||||
styles: `
|
||||
:host {
|
||||
display: inline-flex;
|
||||
}
|
||||
.mra-conf {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--mra-space-3);
|
||||
}
|
||||
.mra-conf__prompt {
|
||||
font-size: var(--mra-font-size-sm);
|
||||
color: var(--mra-muted);
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class MraConfirmationAction {
|
||||
readonly variant = input<'default' | 'primary' | 'danger'>('default');
|
||||
readonly confirmLabel = input<string>('Are you sure?');
|
||||
readonly confirmText = input<string>('Delete');
|
||||
readonly cancelText = input<string>('Cancel');
|
||||
readonly confirmed = output<void>();
|
||||
readonly cancelled = output<void>();
|
||||
readonly confirming = signal(false);
|
||||
|
||||
start() {
|
||||
this.confirming.set(true);
|
||||
}
|
||||
confirm() {
|
||||
this.confirming.set(false);
|
||||
this.confirmed.emit();
|
||||
}
|
||||
cancel() {
|
||||
this.confirming.set(false);
|
||||
this.cancelled.emit();
|
||||
}
|
||||
}
|
||||
18
projects/mrarm-ui/src/lib/actions/icon-button.stories.ts
Normal file
18
projects/mrarm-ui/src/lib/actions/icon-button.stories.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { MraIconButton } from '@mrarm/ui';
|
||||
|
||||
const meta: Meta<MraIconButton> = {
|
||||
title: 'Actions/Icon Button',
|
||||
component: MraIconButton,
|
||||
tags: ['autodocs'],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
export const Default: StoryObj<MraIconButton> = {
|
||||
render: () => ({ template: `<mra-icon-button label="Settings">⚙</mra-icon-button>` }),
|
||||
};
|
||||
|
||||
export const Disabled: StoryObj<MraIconButton> = {
|
||||
render: () => ({ template: `<mra-icon-button label="Settings" [disabled]="true">⚙</mra-icon-button>` }),
|
||||
};
|
||||
50
projects/mrarm-ui/src/lib/actions/icon-button.ts
Normal file
50
projects/mrarm-ui/src/lib/actions/icon-button.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'mra-icon-button',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<button
|
||||
[type]="type()"
|
||||
[disabled]="disabled()"
|
||||
[attr.aria-label]="label()"
|
||||
[attr.title]="label()"
|
||||
class="mra-icon-btn"
|
||||
>
|
||||
<ng-content />
|
||||
</button>
|
||||
`,
|
||||
styles: `
|
||||
:host {
|
||||
display: inline-flex;
|
||||
}
|
||||
.mra-icon-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--mra-btn-border);
|
||||
background: var(--mra-btn-bg);
|
||||
color: var(--mra-btn-text);
|
||||
cursor: pointer;
|
||||
border-radius: var(--mra-radius);
|
||||
}
|
||||
.mra-icon-btn:hover:not(:disabled) {
|
||||
background: var(--mra-btn-bg-hover);
|
||||
}
|
||||
.mra-icon-btn:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.mra-icon-btn:focus-visible {
|
||||
box-shadow: var(--mra-focus-ring);
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class MraIconButton {
|
||||
readonly label = input<string>('');
|
||||
readonly type = input<'button' | 'submit' | 'reset'>('button');
|
||||
readonly disabled = input<boolean>(false);
|
||||
}
|
||||
6
projects/mrarm-ui/src/lib/actions/index.ts
Normal file
6
projects/mrarm-ui/src/lib/actions/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export * from './button';
|
||||
export * from './icon-button';
|
||||
export * from './button-group';
|
||||
export * from './link-button';
|
||||
export * from './split-button';
|
||||
export * from './confirmation-action';
|
||||
30
projects/mrarm-ui/src/lib/actions/link-button.stories.ts
Normal file
30
projects/mrarm-ui/src/lib/actions/link-button.stories.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { MraLinkButton } from '@mrarm/ui';
|
||||
|
||||
const meta: Meta<MraLinkButton> = {
|
||||
title: 'Actions/Link Button',
|
||||
component: MraLinkButton,
|
||||
tags: ['autodocs'],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
export const Default: StoryObj<MraLinkButton> = {
|
||||
render: () => ({ template: `<mra-link-button href="#">View docs</mra-link-button>` }),
|
||||
};
|
||||
|
||||
export const Primary: StoryObj<MraLinkButton> = {
|
||||
render: () => ({ template: `<mra-link-button href="#" variant="primary">Open</mra-link-button>` }),
|
||||
};
|
||||
|
||||
export const Danger: StoryObj<MraLinkButton> = {
|
||||
render: () => ({ template: `<mra-link-button href="#" variant="danger">Purge</mra-link-button>` }),
|
||||
};
|
||||
|
||||
export const Disabled: StoryObj<MraLinkButton> = {
|
||||
render: () => ({ template: `<mra-link-button href="#" [disabled]="true">Unavailable</mra-link-button>` }),
|
||||
};
|
||||
|
||||
export const External: StoryObj<MraLinkButton> = {
|
||||
render: () => ({ template: `<mra-link-button href="#" target="_blank">Open in tab</mra-link-button>` }),
|
||||
};
|
||||
64
projects/mrarm-ui/src/lib/actions/link-button.ts
Normal file
64
projects/mrarm-ui/src/lib/actions/link-button.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'mra-link-button',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<a
|
||||
[href]="disabled() ? undefined : href()"
|
||||
[class]="'mra-link-btn mra-link-btn--' + variant()"
|
||||
[attr.aria-disabled]="disabled() ? 'true' : null"
|
||||
[attr.target]="target()"
|
||||
[attr.rel]="target() === '_blank' ? 'noopener noreferrer' : null"
|
||||
>
|
||||
<ng-content />
|
||||
</a>
|
||||
`,
|
||||
styles: `
|
||||
:host {
|
||||
display: inline-flex;
|
||||
}
|
||||
.mra-link-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--mra-space-2);
|
||||
padding: var(--mra-control-padding-y) var(--mra-control-padding-x);
|
||||
border: 1px solid var(--mra-btn-border);
|
||||
background: var(--mra-btn-bg);
|
||||
color: var(--mra-btn-text);
|
||||
text-decoration: none;
|
||||
font-size: var(--mra-font-size);
|
||||
cursor: pointer;
|
||||
border-radius: var(--mra-radius);
|
||||
}
|
||||
.mra-link-btn:hover {
|
||||
background: var(--mra-btn-bg-hover);
|
||||
text-decoration: none;
|
||||
}
|
||||
.mra-link-btn--primary {
|
||||
background: var(--mra-btn-primary-bg);
|
||||
border-color: var(--mra-btn-primary-border);
|
||||
color: var(--mra-btn-primary-text);
|
||||
font-weight: 700;
|
||||
}
|
||||
.mra-link-btn--danger {
|
||||
background: var(--mra-btn-danger-bg);
|
||||
border-color: var(--mra-btn-danger-border);
|
||||
color: var(--mra-btn-danger-text);
|
||||
}
|
||||
.mra-link-btn[aria-disabled='true'] {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
.mra-link-btn:focus-visible {
|
||||
box-shadow: var(--mra-focus-ring);
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class MraLinkButton {
|
||||
readonly href = input<string>('');
|
||||
readonly variant = input<'default' | 'primary' | 'danger'>('default');
|
||||
readonly target = input<string | null>(null);
|
||||
readonly disabled = input<boolean>(false);
|
||||
}
|
||||
37
projects/mrarm-ui/src/lib/actions/split-button.stories.ts
Normal file
37
projects/mrarm-ui/src/lib/actions/split-button.stories.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { MraSplitButton } from '@mrarm/ui';
|
||||
|
||||
const meta: Meta<MraSplitButton> = {
|
||||
title: 'Actions/Split Button',
|
||||
component: MraSplitButton,
|
||||
tags: ['autodocs'],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
const items = [
|
||||
{ label: 'Export CSV', value: 'csv' },
|
||||
{ label: 'Export JSON', value: 'json' },
|
||||
{ label: 'Delete', value: 'del', danger: true },
|
||||
];
|
||||
|
||||
export const Default: StoryObj<MraSplitButton> = {
|
||||
render: () => ({
|
||||
template: `<mra-split-button [items]="items">Export</mra-split-button>`,
|
||||
props: { items },
|
||||
}),
|
||||
};
|
||||
|
||||
export const Primary: StoryObj<MraSplitButton> = {
|
||||
render: () => ({
|
||||
template: `<mra-split-button variant="primary" [items]="items">Save</mra-split-button>`,
|
||||
props: { items },
|
||||
}),
|
||||
};
|
||||
|
||||
export const Disabled: StoryObj<MraSplitButton> = {
|
||||
render: () => ({
|
||||
template: `<mra-split-button [items]="items" [disabled]="true">Export</mra-split-button>`,
|
||||
props: { items },
|
||||
}),
|
||||
};
|
||||
152
projects/mrarm-ui/src/lib/actions/split-button.ts
Normal file
152
projects/mrarm-ui/src/lib/actions/split-button.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
ElementRef,
|
||||
HostListener,
|
||||
booleanAttribute,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { MraButton } from './button';
|
||||
import { MraIconButton } from './icon-button';
|
||||
|
||||
export interface MraSplitMenuItem {
|
||||
label: string;
|
||||
value: unknown;
|
||||
danger?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'mra-split-button',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [MraButton, MraIconButton],
|
||||
template: `
|
||||
<div class="mra-split">
|
||||
<mra-button
|
||||
[variant]="variant()"
|
||||
[disabled]="disabled()"
|
||||
[loading]="loading()"
|
||||
[type]="type()"
|
||||
(click)="primaryClick.emit()"
|
||||
>
|
||||
<ng-content />
|
||||
</mra-button>
|
||||
<mra-icon-button
|
||||
class="mra-split__caret"
|
||||
[label]="menuLabel()"
|
||||
[disabled]="disabled()"
|
||||
(click)="toggle($event)"
|
||||
>
|
||||
<span class="mra-split__arrow" aria-hidden="true">▾</span>
|
||||
</mra-icon-button>
|
||||
@if (open() && items().length) {
|
||||
<ul class="mra-split__menu" role="menu">
|
||||
@for (item of items(); track item.value) {
|
||||
<li
|
||||
role="menuitem"
|
||||
[class.mra-split__item--danger]="item.danger"
|
||||
[class.mra-split__item--disabled]="item.disabled"
|
||||
tabindex="0"
|
||||
(click)="select(item)"
|
||||
(keydown.enter)="select(item)"
|
||||
(keydown.space)="select(item); $event.preventDefault()"
|
||||
>
|
||||
{{ item.label }}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: `
|
||||
:host {
|
||||
display: inline-flex;
|
||||
}
|
||||
.mra-split {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: stretch;
|
||||
}
|
||||
.mra-split mra-button::ng-deep .mra-btn {
|
||||
border-right-width: 0;
|
||||
}
|
||||
.mra-split__caret::ng-deep .mra-icon-btn {
|
||||
padding: 0 6px;
|
||||
}
|
||||
.mra-split__arrow {
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
}
|
||||
.mra-split__menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
min-width: 100%;
|
||||
margin: 0;
|
||||
margin-top: 2px;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
background: var(--mra-surface);
|
||||
border: 1px solid var(--mra-border-2);
|
||||
box-shadow: var(--mra-shadow-hard);
|
||||
z-index: 100;
|
||||
}
|
||||
.mra-split__menu li {
|
||||
padding: var(--mra-space-2) var(--mra-space-4);
|
||||
font-size: var(--mra-font-size-sm);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mra-split__menu li:hover,
|
||||
.mra-split__menu li:focus-visible {
|
||||
background: var(--mra-surface-alt);
|
||||
outline: none;
|
||||
}
|
||||
.mra-split__item--danger {
|
||||
color: var(--mra-bad);
|
||||
}
|
||||
.mra-split__item--disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.mra-split__item--disabled:hover {
|
||||
background: transparent;
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class MraSplitButton {
|
||||
readonly variant = input<'default' | 'primary' | 'danger'>('default');
|
||||
readonly type = input<'button' | 'submit' | 'reset'>('button');
|
||||
readonly disabled = input(false, { transform: booleanAttribute });
|
||||
readonly loading = input(false, { transform: booleanAttribute });
|
||||
readonly items = input<MraSplitMenuItem[]>([]);
|
||||
readonly menuLabel = input<string>('More actions');
|
||||
readonly primaryClick = output<void>();
|
||||
readonly menuSelect = output<MraSplitMenuItem>();
|
||||
readonly open = signal(false);
|
||||
|
||||
constructor(private host: ElementRef<HTMLElement>) {}
|
||||
|
||||
toggle(event: Event) {
|
||||
event.stopPropagation();
|
||||
this.open.set(!this.open());
|
||||
}
|
||||
select(item: MraSplitMenuItem) {
|
||||
if (item.disabled) return;
|
||||
this.open.set(false);
|
||||
this.menuSelect.emit(item);
|
||||
}
|
||||
@HostListener('document:click', ['$event'])
|
||||
onDocClick(event: MouseEvent) {
|
||||
if (!this.open()) return;
|
||||
const target = event.target as Node;
|
||||
if (this.host.nativeElement.contains(target)) return;
|
||||
this.open.set(false);
|
||||
}
|
||||
@HostListener('document:keydown.escape')
|
||||
onEscape() {
|
||||
this.open.set(false);
|
||||
}
|
||||
}
|
||||
22
projects/mrarm-ui/src/lib/data/activity-timeline.stories.ts
Normal file
22
projects/mrarm-ui/src/lib/data/activity-timeline.stories.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { MraActivityTimeline, type MraActivityItem } from '@mrarm/ui';
|
||||
|
||||
const meta: Meta<MraActivityTimeline> = {
|
||||
title: 'Data/Activity Timeline',
|
||||
component: MraActivityTimeline,
|
||||
tags: ['autodocs'],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
const items: MraActivityItem[] = [
|
||||
{ ts: '10:42', title: 'Deployment succeeded', variant: 'ok', detail: 'v2.4.1 rolled out to prod' },
|
||||
{ ts: '10:15', title: 'Config updated', variant: 'accent', detail: 'max_connections set to 200' },
|
||||
{ ts: '09:58', title: 'High latency detected', variant: 'warn', detail: 'p99 at 850ms for 5 min' },
|
||||
{ ts: '09:30', title: 'Health check failed', variant: 'bad', detail: 'worker-3 unresponsive' },
|
||||
{ ts: '09:00', title: 'Service started' },
|
||||
];
|
||||
|
||||
export const Default: StoryObj<MraActivityTimeline> = {
|
||||
render: () => ({ template: `<mra-activity-timeline [items]="items"></mra-activity-timeline>`, props: { items } }),
|
||||
};
|
||||
110
projects/mrarm-ui/src/lib/data/activity-timeline.ts
Normal file
110
projects/mrarm-ui/src/lib/data/activity-timeline.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
|
||||
|
||||
export interface MraActivityItem {
|
||||
ts: string;
|
||||
title: string;
|
||||
detail?: string;
|
||||
variant?: 'neutral' | 'ok' | 'warn' | 'bad' | 'accent';
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'mra-activity-timeline',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<ol class="mra-timeline">
|
||||
@for (item of items(); track $index) {
|
||||
<li class="mra-timeline__item" [class]="'mra-timeline__item--' + (item.variant || 'neutral')">
|
||||
<span class="mra-timeline__dot" aria-hidden="true"></span>
|
||||
<div class="mra-timeline__body">
|
||||
<div class="mra-timeline__head">
|
||||
<span class="mra-timeline__title">{{ item.title }}</span>
|
||||
<span class="mra-timeline__ts mra-mono">{{ item.ts }}</span>
|
||||
</div>
|
||||
@if (item.detail) {
|
||||
<div class="mra-timeline__detail">{{ item.detail }}</div>
|
||||
}
|
||||
</div>
|
||||
</li>
|
||||
} @empty {
|
||||
<li class="mra-timeline__empty">No activity.</li>
|
||||
}
|
||||
</ol>
|
||||
`,
|
||||
styles: `
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
.mra-timeline {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.mra-timeline__item {
|
||||
position: relative;
|
||||
padding: 0 0 var(--mra-space-5) var(--mra-space-6);
|
||||
border-left: 1px solid var(--mra-line);
|
||||
}
|
||||
.mra-timeline__item:last-child {
|
||||
border-left-color: transparent;
|
||||
}
|
||||
.mra-timeline__dot {
|
||||
position: absolute;
|
||||
left: -5px;
|
||||
top: 2px;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
background: var(--mra-border-2);
|
||||
border: 1px solid var(--mra-border);
|
||||
}
|
||||
.mra-timeline__item--ok .mra-timeline__dot {
|
||||
background: var(--mra-ok);
|
||||
border-color: var(--mra-ok-border);
|
||||
}
|
||||
.mra-timeline__item--warn .mra-timeline__dot {
|
||||
background: var(--mra-warn);
|
||||
border-color: var(--mra-warn-border);
|
||||
}
|
||||
.mra-timeline__item--bad .mra-timeline__dot {
|
||||
background: var(--mra-bad);
|
||||
border-color: var(--mra-bad-border);
|
||||
}
|
||||
.mra-timeline__item--accent .mra-timeline__dot {
|
||||
background: var(--mra-accent);
|
||||
border-color: var(--mra-accent-border);
|
||||
}
|
||||
.mra-timeline__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.mra-timeline__head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--mra-space-4);
|
||||
}
|
||||
.mra-timeline__title {
|
||||
font-weight: bold;
|
||||
color: var(--mra-text);
|
||||
font-size: var(--mra-font-size);
|
||||
}
|
||||
.mra-timeline__ts {
|
||||
font-size: var(--mra-font-size-sm);
|
||||
color: var(--mra-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mra-timeline__detail {
|
||||
color: var(--mra-muted);
|
||||
font-size: var(--mra-font-size-sm);
|
||||
}
|
||||
.mra-timeline__empty {
|
||||
list-style: none;
|
||||
padding: var(--mra-space-5);
|
||||
color: var(--mra-muted);
|
||||
font-size: var(--mra-font-size-sm);
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class MraActivityTimeline {
|
||||
readonly items = input<MraActivityItem[]>([]);
|
||||
}
|
||||
22
projects/mrarm-ui/src/lib/data/description-list.stories.ts
Normal file
22
projects/mrarm-ui/src/lib/data/description-list.stories.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { MraDescriptionList, type MraDescriptionItem } from '@mrarm/ui';
|
||||
|
||||
const meta: Meta<MraDescriptionList> = {
|
||||
title: 'Data/Description List',
|
||||
component: MraDescriptionList,
|
||||
tags: ['autodocs'],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
const items: MraDescriptionItem[] = [
|
||||
{ term: 'ID', detail: 'srv-0042', mono: true },
|
||||
{ term: 'Hostname', detail: 'db-01.internal' },
|
||||
{ term: 'Status', detail: 'Running' },
|
||||
{ term: 'Uptime', detail: '14d 3h 22m' },
|
||||
{ term: 'Region', detail: 'eu-west-1' },
|
||||
];
|
||||
|
||||
export const Default: StoryObj<MraDescriptionList> = {
|
||||
render: () => ({ template: `<mra-description-list [items]="items"></mra-description-list>`, props: { items } }),
|
||||
};
|
||||
65
projects/mrarm-ui/src/lib/data/description-list.ts
Normal file
65
projects/mrarm-ui/src/lib/data/description-list.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
|
||||
|
||||
export interface MraDescriptionItem {
|
||||
term: string;
|
||||
detail: string;
|
||||
mono?: boolean;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'mra-description-list',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<dl class="mra-dl">
|
||||
@for (item of items(); track $index) {
|
||||
<div class="mra-dl__row">
|
||||
<dt class="mra-dl__term">{{ item.term }}</dt>
|
||||
<dd class="mra-dl__detail" [class.mra-mono]="item.mono">{{ item.detail }}</dd>
|
||||
</div>
|
||||
}
|
||||
</dl>
|
||||
`,
|
||||
styles: `
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
.mra-dl {
|
||||
margin: 0;
|
||||
border: 1px solid var(--mra-border-2);
|
||||
background: var(--mra-surface);
|
||||
}
|
||||
.mra-dl__row {
|
||||
display: grid;
|
||||
grid-template-columns: 180px 1fr;
|
||||
gap: var(--mra-space-4);
|
||||
padding: var(--mra-space-3) var(--mra-space-4);
|
||||
border-bottom: 1px solid var(--mra-line);
|
||||
}
|
||||
.mra-dl__row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.mra-dl__row:nth-child(even) {
|
||||
background: var(--mra-surface-alt);
|
||||
}
|
||||
.mra-dl__term {
|
||||
font-weight: bold;
|
||||
color: var(--mra-muted);
|
||||
font-size: var(--mra-font-size-sm);
|
||||
margin: 0;
|
||||
}
|
||||
.mra-dl__detail {
|
||||
margin: 0;
|
||||
color: var(--mra-text);
|
||||
word-break: break-word;
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.mra-dl__row {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 2px;
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class MraDescriptionList {
|
||||
readonly items = input<MraDescriptionItem[]>([]);
|
||||
}
|
||||
67
projects/mrarm-ui/src/lib/data/filter-bar.spec.ts
Normal file
67
projects/mrarm-ui/src/lib/data/filter-bar.spec.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { MraFilterBar } from './filter-bar';
|
||||
|
||||
describe('MraFilterBar', () => {
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({ imports: [MraFilterBar] }).compileComponents();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
const fixture = TestBed.createComponent(MraFilterBar);
|
||||
expect(fixture.componentInstance).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render search input', () => {
|
||||
const fixture = TestBed.createComponent(MraFilterBar);
|
||||
fixture.detectChanges();
|
||||
const el = fixture.nativeElement as HTMLElement;
|
||||
expect(el.querySelector('input[type="search"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render placeholder', () => {
|
||||
const fixture = TestBed.createComponent(MraFilterBar);
|
||||
fixture.componentRef.setInput('placeholder', 'Search…');
|
||||
fixture.detectChanges();
|
||||
const input = (fixture.nativeElement as HTMLElement).querySelector('input')!;
|
||||
expect(input.getAttribute('placeholder')).toBe('Search…');
|
||||
});
|
||||
|
||||
it('should emit queryChange on input', () => {
|
||||
const fixture = TestBed.createComponent(MraFilterBar);
|
||||
fixture.detectChanges();
|
||||
let query = '';
|
||||
fixture.componentInstance.queryChange.subscribe((q) => (query = q));
|
||||
const input = (fixture.nativeElement as HTMLElement).querySelector('input')!;
|
||||
input.value = 'test';
|
||||
input.dispatchEvent(new Event('input'));
|
||||
expect(query).toBe('test');
|
||||
});
|
||||
|
||||
it('should emit search on Enter', () => {
|
||||
const fixture = TestBed.createComponent(MraFilterBar);
|
||||
fixture.detectChanges();
|
||||
let searched = '';
|
||||
fixture.componentInstance.search.subscribe((q) => (searched = q));
|
||||
const input = (fixture.nativeElement as HTMLElement).querySelector('input')!;
|
||||
input.value = 'query';
|
||||
input.dispatchEvent(new Event('input'));
|
||||
expect(searched).toBe('query');
|
||||
});
|
||||
|
||||
it('should show count/total', () => {
|
||||
const fixture = TestBed.createComponent(MraFilterBar);
|
||||
fixture.componentRef.setInput('count', 3);
|
||||
fixture.componentRef.setInput('total', 10);
|
||||
fixture.detectChanges();
|
||||
const el = fixture.nativeElement as HTMLElement;
|
||||
expect(el.textContent).toContain('3');
|
||||
expect(el.textContent).toContain('10');
|
||||
});
|
||||
|
||||
it('should not show count when null', () => {
|
||||
const fixture = TestBed.createComponent(MraFilterBar);
|
||||
fixture.detectChanges();
|
||||
const el = fixture.nativeElement as HTMLElement;
|
||||
expect(el.querySelector('.mra-filter__count')).toBeNull();
|
||||
});
|
||||
});
|
||||
18
projects/mrarm-ui/src/lib/data/filter-bar.stories.ts
Normal file
18
projects/mrarm-ui/src/lib/data/filter-bar.stories.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { MraFilterBar } from '@mrarm/ui';
|
||||
|
||||
const meta: Meta<MraFilterBar> = {
|
||||
title: 'Data/Filter Bar',
|
||||
component: MraFilterBar,
|
||||
tags: ['autodocs'],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
export const Default: StoryObj<MraFilterBar> = {
|
||||
render: () => ({ template: `<mra-filter-bar placeholder="Search resources…"></mra-filter-bar>` }),
|
||||
};
|
||||
|
||||
export const WithCount: StoryObj<MraFilterBar> = {
|
||||
render: () => ({ template: `<mra-filter-bar placeholder="Search…" [count]="12" [total]="48"></mra-filter-bar>` }),
|
||||
};
|
||||
101
projects/mrarm-ui/src/lib/data/filter-bar.ts
Normal file
101
projects/mrarm-ui/src/lib/data/filter-bar.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'mra-filter-bar',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="mra-filter-bar">
|
||||
<div class="mra-filter-bar__search">
|
||||
<span class="mra-filter-bar__icon" aria-hidden="true">⌕</span>
|
||||
<input
|
||||
class="mra-filter-bar__input"
|
||||
type="search"
|
||||
[value]="query()"
|
||||
[placeholder]="placeholder()"
|
||||
(input)="onInput($event)"
|
||||
/>
|
||||
</div>
|
||||
@if (count() !== null) {
|
||||
<span class="mra-filter-bar__count">
|
||||
{{ count() }}{{ total() !== null ? ' / ' + total() : '' }}
|
||||
</span>
|
||||
}
|
||||
<div class="mra-filter-bar__filters">
|
||||
<ng-content></ng-content>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: `
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
.mra-filter-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--mra-space-4);
|
||||
flex-wrap: wrap;
|
||||
padding: var(--mra-space-3) var(--mra-space-4);
|
||||
background: var(--mra-panel-3);
|
||||
border: 1px solid var(--mra-border-2);
|
||||
border-bottom: 0;
|
||||
}
|
||||
.mra-filter-bar__search {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 220px;
|
||||
flex: 1 1 220px;
|
||||
max-width: 360px;
|
||||
}
|
||||
.mra-filter-bar__icon {
|
||||
position: absolute;
|
||||
left: var(--mra-space-3);
|
||||
color: var(--mra-muted);
|
||||
font-size: var(--mra-font-size);
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
.mra-filter-bar__input {
|
||||
width: 100%;
|
||||
padding: var(--mra-control-padding-y) var(--mra-control-padding-x)
|
||||
var(--mra-control-padding-y) 26px;
|
||||
border: 1px solid var(--mra-border-2);
|
||||
background: var(--mra-surface);
|
||||
color: var(--mra-text);
|
||||
font-family: var(--mra-font);
|
||||
font-size: var(--mra-font-size);
|
||||
border-radius: var(--mra-radius);
|
||||
}
|
||||
.mra-filter-bar__input:focus {
|
||||
border-color: var(--mra-accent);
|
||||
box-shadow: var(--mra-focus-ring);
|
||||
outline: none;
|
||||
}
|
||||
.mra-filter-bar__count {
|
||||
font-size: var(--mra-font-size-sm);
|
||||
color: var(--mra-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mra-filter-bar__filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--mra-space-3);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class MraFilterBar {
|
||||
readonly query = input<string>('');
|
||||
readonly placeholder = input<string>('Filter…');
|
||||
readonly count = input<number | null>(null);
|
||||
readonly total = input<number | null>(null);
|
||||
|
||||
readonly queryChange = output<string>();
|
||||
readonly search = output<string>();
|
||||
|
||||
onInput(event: Event) {
|
||||
const v = (event.target as HTMLInputElement).value;
|
||||
this.queryChange.emit(v);
|
||||
this.search.emit(v);
|
||||
}
|
||||
}
|
||||
6
projects/mrarm-ui/src/lib/data/index.ts
Normal file
6
projects/mrarm-ui/src/lib/data/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export * from './table';
|
||||
export * from './row-actions';
|
||||
export * from './filter-bar';
|
||||
export * from './description-list';
|
||||
export * from './metric-block';
|
||||
export * from './activity-timeline';
|
||||
66
projects/mrarm-ui/src/lib/data/metric-block.spec.ts
Normal file
66
projects/mrarm-ui/src/lib/data/metric-block.spec.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { MraMetricBlock } from './metric-block';
|
||||
|
||||
describe('MraMetricBlock', () => {
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({ imports: [MraMetricBlock] }).compileComponents();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
const fixture = TestBed.createComponent(MraMetricBlock);
|
||||
fixture.componentRef.setInput('label', 'CPU');
|
||||
fixture.componentRef.setInput('value', 42);
|
||||
expect(fixture.componentInstance).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render label and value', () => {
|
||||
const fixture = TestBed.createComponent(MraMetricBlock);
|
||||
fixture.componentRef.setInput('label', 'CPU');
|
||||
fixture.componentRef.setInput('value', '42%');
|
||||
fixture.detectChanges();
|
||||
const el = fixture.nativeElement as HTMLElement;
|
||||
expect(el.textContent).toContain('CPU');
|
||||
expect(el.textContent).toContain('42%');
|
||||
});
|
||||
|
||||
it('should render unit', () => {
|
||||
const fixture = TestBed.createComponent(MraMetricBlock);
|
||||
fixture.componentRef.setInput('label', 'Memory');
|
||||
fixture.componentRef.setInput('value', 8);
|
||||
fixture.componentRef.setInput('unit', 'GB');
|
||||
fixture.detectChanges();
|
||||
const el = fixture.nativeElement as HTMLElement;
|
||||
expect(el.textContent).toContain('GB');
|
||||
});
|
||||
|
||||
it('should render delta badge', () => {
|
||||
const fixture = TestBed.createComponent(MraMetricBlock);
|
||||
fixture.componentRef.setInput('label', 'Latency');
|
||||
fixture.componentRef.setInput('value', '12ms');
|
||||
fixture.componentRef.setInput('delta', '+5%');
|
||||
fixture.componentRef.setInput('deltaVariant', 'bad');
|
||||
fixture.detectChanges();
|
||||
const el = fixture.nativeElement as HTMLElement;
|
||||
expect(el.querySelector('mra-badge')).toBeTruthy();
|
||||
expect(el.textContent).toContain('+5%');
|
||||
});
|
||||
|
||||
it('should render hint', () => {
|
||||
const fixture = TestBed.createComponent(MraMetricBlock);
|
||||
fixture.componentRef.setInput('label', 'QPS');
|
||||
fixture.componentRef.setInput('value', 1000);
|
||||
fixture.componentRef.setInput('hint', 'last 5 min');
|
||||
fixture.detectChanges();
|
||||
const el = fixture.nativeElement as HTMLElement;
|
||||
expect(el.textContent).toContain('last 5 min');
|
||||
});
|
||||
|
||||
it('should not render delta when null', () => {
|
||||
const fixture = TestBed.createComponent(MraMetricBlock);
|
||||
fixture.componentRef.setInput('label', 'X');
|
||||
fixture.componentRef.setInput('value', 1);
|
||||
fixture.detectChanges();
|
||||
const el = fixture.nativeElement as HTMLElement;
|
||||
expect(el.querySelector('mra-badge')).toBeNull();
|
||||
});
|
||||
});
|
||||
26
projects/mrarm-ui/src/lib/data/metric-block.stories.ts
Normal file
26
projects/mrarm-ui/src/lib/data/metric-block.stories.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { MraMetricBlock } from '@mrarm/ui';
|
||||
|
||||
const meta: Meta<MraMetricBlock> = {
|
||||
title: 'Data/Metric Block',
|
||||
component: MraMetricBlock,
|
||||
tags: ['autodocs'],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
export const Default: StoryObj<MraMetricBlock> = {
|
||||
render: () => ({ template: `<mra-metric-block label="Requests" [value]="'14,832'" unit="/min"></mra-metric-block>` }),
|
||||
};
|
||||
|
||||
export const WithDelta: StoryObj<MraMetricBlock> = {
|
||||
render: () => ({
|
||||
template: `<mra-metric-block label="Error rate" [value]="0.4" unit="%" delta="+0.1%" deltaVariant="warn" hint="Above 0.3% threshold"></mra-metric-block>`,
|
||||
}),
|
||||
};
|
||||
|
||||
export const PositiveDelta: StoryObj<MraMetricBlock> = {
|
||||
render: () => ({
|
||||
template: `<mra-metric-block label="Uptime" [value]="99.98" unit="%" delta="+0.02%" deltaVariant="ok"></mra-metric-block>`,
|
||||
}),
|
||||
};
|
||||
81
projects/mrarm-ui/src/lib/data/metric-block.ts
Normal file
81
projects/mrarm-ui/src/lib/data/metric-block.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
|
||||
import { MraBadge } from '../status/badge';
|
||||
|
||||
export type MraMetricDeltaVariant = 'ok' | 'warn' | 'bad' | 'neutral';
|
||||
|
||||
@Component({
|
||||
selector: 'mra-metric-block',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [MraBadge],
|
||||
template: `
|
||||
<div class="mra-metric">
|
||||
<div class="mra-metric__label">{{ label() }}</div>
|
||||
<div class="mra-metric__value">
|
||||
<span class="mra-metric__num">{{ value() }}</span>
|
||||
@if (unit()) {
|
||||
<span class="mra-metric__unit">{{ unit() }}</span>
|
||||
}
|
||||
</div>
|
||||
@if (delta() !== null) {
|
||||
<div class="mra-metric__delta">
|
||||
<mra-badge [variant]="deltaVariant()">{{ delta() }}</mra-badge>
|
||||
</div>
|
||||
}
|
||||
@if (hint()) {
|
||||
<div class="mra-metric__hint">{{ hint() }}</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: `
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
.mra-metric {
|
||||
background: var(--mra-surface);
|
||||
border: 1px solid var(--mra-border-2);
|
||||
border-left: 3px solid var(--mra-border);
|
||||
padding: var(--mra-space-4) var(--mra-space-5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--mra-space-2);
|
||||
height: 100%;
|
||||
}
|
||||
.mra-metric__label {
|
||||
font-size: var(--mra-font-size-sm);
|
||||
color: var(--mra-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
font-weight: bold;
|
||||
}
|
||||
.mra-metric__value {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--mra-space-2);
|
||||
}
|
||||
.mra-metric__num {
|
||||
font-size: 22px;
|
||||
font-weight: bold;
|
||||
color: var(--mra-text);
|
||||
line-height: 1.1;
|
||||
}
|
||||
.mra-metric__unit {
|
||||
font-size: var(--mra-font-size-sm);
|
||||
color: var(--mra-muted);
|
||||
}
|
||||
.mra-metric__delta {
|
||||
margin-top: 2px;
|
||||
}
|
||||
.mra-metric__hint {
|
||||
font-size: var(--mra-font-size-sm);
|
||||
color: var(--mra-muted);
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class MraMetricBlock {
|
||||
readonly label = input.required<string>();
|
||||
readonly value = input.required<string | number>();
|
||||
readonly unit = input<string>();
|
||||
readonly delta = input<string | null>(null);
|
||||
readonly deltaVariant = input<MraMetricDeltaVariant>('neutral');
|
||||
readonly hint = input<string>();
|
||||
}
|
||||
27
projects/mrarm-ui/src/lib/data/row-actions.stories.ts
Normal file
27
projects/mrarm-ui/src/lib/data/row-actions.stories.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { MraRowActions, type MraRowAction } from '@mrarm/ui';
|
||||
|
||||
const meta: Meta<MraRowActions> = {
|
||||
title: 'Data/Row Actions',
|
||||
component: MraRowActions,
|
||||
tags: ['autodocs'],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
const actions: MraRowAction[] = [
|
||||
{ label: 'View' },
|
||||
{ label: 'Edit' },
|
||||
{ label: 'Delete', danger: true },
|
||||
];
|
||||
|
||||
export const Default: StoryObj<MraRowActions> = {
|
||||
render: () => ({ template: `<mra-row-actions [actions]="actions"></mra-row-actions>`, props: { actions } }),
|
||||
};
|
||||
|
||||
export const WithDivider: StoryObj<MraRowActions> = {
|
||||
render: () => ({
|
||||
template: `<mra-row-actions [actions]="actionsDiv"></mra-row-actions>`,
|
||||
props: { actionsDiv: [{ label: 'View' }, { label: '', divider: true }, { label: 'Delete', danger: true }] },
|
||||
}),
|
||||
};
|
||||
86
projects/mrarm-ui/src/lib/data/row-actions.ts
Normal file
86
projects/mrarm-ui/src/lib/data/row-actions.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
|
||||
|
||||
export interface MraRowAction {
|
||||
label: string;
|
||||
icon?: string;
|
||||
danger?: boolean;
|
||||
disabled?: boolean;
|
||||
divider?: boolean;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'mra-row-actions',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="mra-row-actions">
|
||||
@for (action of actions(); track $index) {
|
||||
@if (action.divider) {
|
||||
<span class="mra-row-actions__divider" aria-hidden="true"></span>
|
||||
} @else {
|
||||
<button
|
||||
type="button"
|
||||
class="mra-row-actions__btn"
|
||||
[class.mra-row-actions__btn--danger]="action.danger"
|
||||
[disabled]="action.disabled"
|
||||
(click)="onClick($event, action)"
|
||||
>
|
||||
{{ action.label }}
|
||||
</button>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: `
|
||||
:host {
|
||||
display: inline-block;
|
||||
}
|
||||
.mra-row-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--mra-space-3);
|
||||
}
|
||||
.mra-row-actions__btn {
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font: inherit;
|
||||
font-size: var(--mra-font-size-sm);
|
||||
color: var(--mra-accent-text);
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
.mra-row-actions__btn:hover:not(:disabled) {
|
||||
color: var(--mra-accent);
|
||||
}
|
||||
.mra-row-actions__btn--danger {
|
||||
color: var(--mra-bad);
|
||||
}
|
||||
.mra-row-actions__btn--danger:hover:not(:disabled) {
|
||||
color: var(--mra-bad-border);
|
||||
}
|
||||
.mra-row-actions__btn:disabled {
|
||||
color: var(--mra-muted);
|
||||
cursor: not-allowed;
|
||||
text-decoration: none;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.mra-row-actions__divider {
|
||||
width: 1px;
|
||||
height: 14px;
|
||||
background: var(--mra-line);
|
||||
display: inline-block;
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class MraRowActions {
|
||||
readonly actions = input<MraRowAction[]>([]);
|
||||
readonly actionClick = output<MraRowAction>();
|
||||
|
||||
onClick(event: MouseEvent, action: MraRowAction) {
|
||||
event.stopPropagation();
|
||||
if (action.disabled) return;
|
||||
this.actionClick.emit(action);
|
||||
}
|
||||
}
|
||||
149
projects/mrarm-ui/src/lib/data/table.spec.ts
Normal file
149
projects/mrarm-ui/src/lib/data/table.spec.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { MraTable, MraTableColumn } from './table';
|
||||
|
||||
describe('MraTable', () => {
|
||||
const columns: MraTableColumn[] = [
|
||||
{ key: 'name', label: 'Name', sortable: true },
|
||||
{ key: 'age', label: 'Age', sortable: true, align: 'end' },
|
||||
];
|
||||
const rows: Record<string, unknown>[] = [
|
||||
{ name: 'Alice', age: 30 },
|
||||
{ name: 'Bob', age: 25 },
|
||||
];
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({ imports: [MraTable] }).compileComponents();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
const fixture = TestBed.createComponent(MraTable);
|
||||
expect(fixture.componentInstance).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render header cells', () => {
|
||||
const fixture = TestBed.createComponent(MraTable);
|
||||
fixture.componentRef.setInput('columns', columns);
|
||||
fixture.componentRef.setInput('rows', rows);
|
||||
fixture.detectChanges();
|
||||
const ths = (fixture.nativeElement as HTMLElement).querySelectorAll('thead th');
|
||||
expect(ths.length).toBe(2);
|
||||
expect(ths[0].textContent).toContain('Name');
|
||||
});
|
||||
|
||||
it('should render body rows', () => {
|
||||
const fixture = TestBed.createComponent(MraTable);
|
||||
fixture.componentRef.setInput('columns', columns);
|
||||
fixture.componentRef.setInput('rows', rows);
|
||||
fixture.detectChanges();
|
||||
const trs = (fixture.nativeElement as HTMLElement).querySelectorAll('tbody tr');
|
||||
expect(trs.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should render empty state when no rows', () => {
|
||||
const fixture = TestBed.createComponent(MraTable);
|
||||
fixture.componentRef.setInput('columns', columns);
|
||||
fixture.detectChanges();
|
||||
const el = fixture.nativeElement as HTMLElement;
|
||||
expect(el.querySelector('.mra-table__empty')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should emit sortChange on header click', () => {
|
||||
const fixture = TestBed.createComponent(MraTable);
|
||||
fixture.componentRef.setInput('columns', columns);
|
||||
fixture.componentRef.setInput('rows', rows);
|
||||
fixture.componentRef.setInput('sortable', true);
|
||||
fixture.detectChanges();
|
||||
let sort: { key: string; dir: string } | null = null;
|
||||
fixture.componentInstance.sortChange.subscribe((s) => (sort = s));
|
||||
const ths = (fixture.nativeElement as HTMLElement).querySelectorAll('thead th');
|
||||
(ths[0] as HTMLElement).click();
|
||||
expect(sort).toEqual({ key: 'name', dir: 'asc' });
|
||||
});
|
||||
|
||||
it('should toggle sort direction', () => {
|
||||
const fixture = TestBed.createComponent(MraTable);
|
||||
fixture.componentRef.setInput('columns', columns);
|
||||
fixture.componentRef.setInput('rows', rows);
|
||||
fixture.componentRef.setInput('sortable', true);
|
||||
fixture.componentRef.setInput('sortKey', 'name');
|
||||
fixture.componentRef.setInput('sortDir', 'asc');
|
||||
fixture.detectChanges();
|
||||
let sort: { key: string; dir: string } | null = null;
|
||||
fixture.componentInstance.sortChange.subscribe((s) => (sort = s));
|
||||
const ths = (fixture.nativeElement as HTMLElement).querySelectorAll('thead th');
|
||||
(ths[0] as HTMLElement).click();
|
||||
expect(sort).toEqual({ key: 'name', dir: 'desc' });
|
||||
});
|
||||
|
||||
it('should not sort when sortable=false', () => {
|
||||
const fixture = TestBed.createComponent(MraTable);
|
||||
fixture.componentRef.setInput('columns', columns);
|
||||
fixture.componentRef.setInput('rows', rows);
|
||||
fixture.detectChanges();
|
||||
let sort: { key: string; dir: string } | null = null;
|
||||
fixture.componentInstance.sortChange.subscribe((s) => (sort = s));
|
||||
const ths = (fixture.nativeElement as HTMLElement).querySelectorAll('thead th');
|
||||
(ths[0] as HTMLElement).click();
|
||||
expect(sort).toBeNull();
|
||||
});
|
||||
|
||||
it('should emit rowClick when clickableRows', () => {
|
||||
const fixture = TestBed.createComponent(MraTable);
|
||||
fixture.componentRef.setInput('columns', columns);
|
||||
fixture.componentRef.setInput('rows', rows);
|
||||
fixture.componentRef.setInput('clickableRows', true);
|
||||
fixture.detectChanges();
|
||||
let clicked: Record<string, unknown> | null = null;
|
||||
fixture.componentInstance.rowClick.subscribe((r) => (clicked = r));
|
||||
const trs = (fixture.nativeElement as HTMLElement).querySelectorAll('tbody tr');
|
||||
(trs[0] as HTMLElement).click();
|
||||
expect(clicked).toEqual(rows[0]);
|
||||
});
|
||||
|
||||
it('should not emit rowClick when not clickableRows', () => {
|
||||
const fixture = TestBed.createComponent(MraTable);
|
||||
fixture.componentRef.setInput('columns', columns);
|
||||
fixture.componentRef.setInput('rows', rows);
|
||||
fixture.detectChanges();
|
||||
let clicked = false;
|
||||
fixture.componentInstance.rowClick.subscribe(() => (clicked = true));
|
||||
const trs = (fixture.nativeElement as HTMLElement).querySelectorAll('tbody tr');
|
||||
(trs[0] as HTMLElement).click();
|
||||
expect(clicked).toBe(false);
|
||||
});
|
||||
|
||||
it('should use format function for cell text', () => {
|
||||
const cols: MraTableColumn[] = [
|
||||
{ key: 'name', label: 'Name', format: (v) => `[${v}]` },
|
||||
];
|
||||
const fixture = TestBed.createComponent(MraTable);
|
||||
fixture.componentRef.setInput('columns', cols);
|
||||
fixture.componentRef.setInput('rows', [{ name: 'Alice' }]);
|
||||
fixture.detectChanges();
|
||||
const td = (fixture.nativeElement as HTMLElement).querySelector('tbody td')!;
|
||||
expect(td.textContent).toContain('[Alice]');
|
||||
});
|
||||
|
||||
it('should set aria-sort', () => {
|
||||
const fixture = TestBed.createComponent(MraTable);
|
||||
fixture.componentRef.setInput('columns', columns);
|
||||
fixture.componentRef.setInput('rows', rows);
|
||||
fixture.componentRef.setInput('sortable', true);
|
||||
fixture.componentRef.setInput('sortKey', 'name');
|
||||
fixture.componentRef.setInput('sortDir', 'asc');
|
||||
fixture.detectChanges();
|
||||
const ths = (fixture.nativeElement as HTMLElement).querySelectorAll('thead th');
|
||||
expect(ths[0].getAttribute('aria-sort')).toBe('ascending');
|
||||
expect(ths[1].getAttribute('aria-sort')).toBeNull();
|
||||
});
|
||||
|
||||
it('should apply dense class', () => {
|
||||
const fixture = TestBed.createComponent(MraTable);
|
||||
fixture.componentRef.setInput('columns', columns);
|
||||
fixture.componentRef.setInput('rows', rows);
|
||||
fixture.componentRef.setInput('dense', true);
|
||||
fixture.detectChanges();
|
||||
const el = fixture.nativeElement as HTMLElement;
|
||||
expect(el.querySelector('.mra-table--dense')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
55
projects/mrarm-ui/src/lib/data/table.stories.ts
Normal file
55
projects/mrarm-ui/src/lib/data/table.stories.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { MraTable, type MraTableColumn, type MraRowAction } from '@mrarm/ui';
|
||||
|
||||
const meta: Meta<MraTable> = {
|
||||
title: 'Data/Table',
|
||||
component: MraTable,
|
||||
tags: ['autodocs'],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
const columns: MraTableColumn[] = [
|
||||
{ key: 'name', label: 'Name', sortable: true },
|
||||
{ key: 'env', label: 'Env' },
|
||||
{ key: 'status', label: 'Status', align: 'center' },
|
||||
{ key: 'actions', label: 'Actions', type: 'actions', align: 'end' },
|
||||
];
|
||||
|
||||
const rows = [
|
||||
{ name: 'web-api', env: 'prod', status: 'active' },
|
||||
{ name: 'worker', env: 'prod', status: 'active' },
|
||||
{ name: 'scheduler', env: 'stg', status: 'idle' },
|
||||
{ name: 'migrator', env: 'dev', status: 'failed' },
|
||||
];
|
||||
|
||||
const rowActions: MraRowAction[] = [
|
||||
{ label: 'View' },
|
||||
{ label: 'Restart' },
|
||||
{ label: 'Delete', danger: true },
|
||||
];
|
||||
|
||||
export const Default: StoryObj<MraTable> = {
|
||||
render: () => ({
|
||||
template: `<mra-table [columns]="columns" [rows]="rows" [rowActions]="rowActions"></mra-table>`,
|
||||
props: { columns, rows, rowActions },
|
||||
}),
|
||||
};
|
||||
|
||||
export const Sortable: StoryObj<MraTable> = {
|
||||
render: () => ({
|
||||
template: `<mra-table [columns]="columns" [rows]="rows" [sortable]="true" sortKey="name" sortDir="asc"></mra-table>`,
|
||||
props: { columns, rows },
|
||||
}),
|
||||
};
|
||||
|
||||
export const Dense: StoryObj<MraTable> = {
|
||||
render: () => ({
|
||||
template: `<mra-table [columns]="columns" [rows]="rows" [dense]="true"></mra-table>`,
|
||||
props: { columns, rows },
|
||||
}),
|
||||
};
|
||||
|
||||
export const Empty: StoryObj<MraTable> = {
|
||||
render: () => ({ template: `<mra-table [columns]="columns" [rows]="[]"></mra-table>`, props: { columns } }),
|
||||
};
|
||||
211
projects/mrarm-ui/src/lib/data/table.ts
Normal file
211
projects/mrarm-ui/src/lib/data/table.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
input,
|
||||
output,
|
||||
} from '@angular/core';
|
||||
import { MraRowActions, MraRowAction } from './row-actions';
|
||||
|
||||
export interface MraTableColumn {
|
||||
key: string;
|
||||
label: string;
|
||||
align?: 'start' | 'center' | 'end';
|
||||
width?: string;
|
||||
/** Cell formatter. Returns display string. */
|
||||
format?: (value: unknown, row: Record<string, unknown>) => string;
|
||||
sortable?: boolean;
|
||||
type?: 'text' | 'actions';
|
||||
}
|
||||
|
||||
export type MraSortDir = 'asc' | 'desc';
|
||||
|
||||
export interface MraSortState {
|
||||
key: string;
|
||||
dir: MraSortDir;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'mra-table',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [MraRowActions],
|
||||
template: `
|
||||
<div class="mra-table__scroll">
|
||||
<table class="mra-table" [class.mra-table--dense]="dense()">
|
||||
@if (caption()) {
|
||||
<caption class="mra-table__caption">{{ caption() }}</caption>
|
||||
}
|
||||
<thead>
|
||||
<tr>
|
||||
@for (col of columns(); track col.key) {
|
||||
<th
|
||||
[style.width]="col.width"
|
||||
[style.text-align]="col.align || 'start'"
|
||||
[class.mra-table__th--sortable]="col.sortable && sortable()"
|
||||
[class.mra-table__th--active]="sortable() && sortKey() === col.key"
|
||||
[attr.aria-sort]="sortAria(col.key)"
|
||||
(click)="onHeaderClick(col)"
|
||||
>
|
||||
<span class="mra-table__th-label">{{ col.label }}</span>
|
||||
@if (sortable() && col.sortable && sortKey() === col.key) {
|
||||
<span class="mra-table__sort" aria-hidden="true">{{ sortDir() === 'asc' ? '▲' : '▼' }}</span>
|
||||
}
|
||||
</th>
|
||||
}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of rows(); track $index) {
|
||||
<tr
|
||||
[class.mra-table__row--clickable]="clickableRows()"
|
||||
(click)="onRowClick(row)"
|
||||
>
|
||||
@for (col of columns(); track col.key) {
|
||||
<td [style.text-align]="col.align || 'start'" [class.mra-table__cell--actions]="col.type === 'actions'">
|
||||
@if (col.type === 'actions' && rowActions()) {
|
||||
<mra-row-actions [actions]="rowActions()!" (actionClick)="onRowAction($event, row)" />
|
||||
} @else {
|
||||
{{ cellText(col, row) }}
|
||||
}
|
||||
</td>
|
||||
}
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr class="mra-table__empty-row">
|
||||
<td [attr.colspan]="columns().length" class="mra-table__empty">No rows.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`,
|
||||
styles: `
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
.mra-table__scroll {
|
||||
overflow-x: auto;
|
||||
border: 1px solid var(--mra-border-2);
|
||||
background: var(--mra-surface);
|
||||
}
|
||||
.mra-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--mra-font-size);
|
||||
}
|
||||
.mra-table__caption {
|
||||
caption-side: top;
|
||||
text-align: start;
|
||||
padding: var(--mra-space-3) var(--mra-space-4);
|
||||
color: var(--mra-muted);
|
||||
font-size: var(--mra-font-size-sm);
|
||||
border-bottom: 1px solid var(--mra-line);
|
||||
}
|
||||
thead th {
|
||||
background: var(--mra-table-header-bg);
|
||||
color: var(--mra-text);
|
||||
text-align: start;
|
||||
font-weight: bold;
|
||||
padding: var(--mra-space-3) var(--mra-space-4);
|
||||
border-bottom: 2px solid var(--mra-border);
|
||||
white-space: nowrap;
|
||||
font-size: var(--mra-font-size-sm);
|
||||
}
|
||||
.mra-table--dense thead th {
|
||||
padding: var(--mra-space-2) var(--mra-space-3);
|
||||
}
|
||||
.mra-table__th--sortable {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.mra-table__th--sortable:hover {
|
||||
background: var(--mra-panel-3);
|
||||
}
|
||||
.mra-table__th--active {
|
||||
color: var(--mra-accent-text);
|
||||
}
|
||||
.mra-table__th-label {
|
||||
display: inline;
|
||||
}
|
||||
.mra-table__sort {
|
||||
margin-left: var(--mra-space-2);
|
||||
font-size: 10px;
|
||||
color: var(--mra-accent);
|
||||
}
|
||||
tbody td {
|
||||
padding: var(--mra-space-3) var(--mra-space-4);
|
||||
border-bottom: 1px solid var(--mra-line);
|
||||
vertical-align: middle;
|
||||
color: var(--mra-text);
|
||||
}
|
||||
.mra-table--dense tbody td {
|
||||
padding: var(--mra-space-2) var(--mra-space-3);
|
||||
}
|
||||
tbody tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
tbody tr:nth-child(even) td {
|
||||
background: var(--mra-surface-alt);
|
||||
}
|
||||
.mra-table__row--clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
.mra-table__row--clickable:hover td {
|
||||
background: var(--mra-accent-weak);
|
||||
}
|
||||
.mra-table__cell--actions {
|
||||
white-space: nowrap;
|
||||
text-align: end !important;
|
||||
}
|
||||
.mra-table__empty {
|
||||
padding: var(--mra-space-6);
|
||||
text-align: center;
|
||||
color: var(--mra-muted);
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class MraTable {
|
||||
readonly columns = input<MraTableColumn[]>([]);
|
||||
readonly rows = input<readonly Record<string, unknown>[]>([]);
|
||||
readonly caption = input<string>();
|
||||
readonly dense = input<boolean>(false);
|
||||
readonly sortable = input<boolean>(false);
|
||||
readonly sortKey = input<string>('');
|
||||
readonly sortDir = input<MraSortDir>('asc');
|
||||
readonly rowActions = input<MraRowAction[]>();
|
||||
readonly clickableRows = input<boolean>(false);
|
||||
|
||||
readonly sortChange = output<MraSortState>();
|
||||
readonly rowClick = output<Record<string, unknown>>();
|
||||
readonly rowAction = output<{ action: MraRowAction; row: Record<string, unknown> }>();
|
||||
|
||||
onHeaderClick(col: MraTableColumn) {
|
||||
if (!this.sortable() || !col.sortable) return;
|
||||
const key = col.key;
|
||||
let dir: MraSortDir = 'asc';
|
||||
if (this.sortKey() === key) {
|
||||
dir = this.sortDir() === 'asc' ? 'desc' : 'asc';
|
||||
}
|
||||
this.sortChange.emit({ key, dir });
|
||||
}
|
||||
|
||||
onRowClick(row: Record<string, unknown>) {
|
||||
if (!this.clickableRows()) return;
|
||||
this.rowClick.emit(row);
|
||||
}
|
||||
|
||||
onRowAction(action: MraRowAction, row: Record<string, unknown>) {
|
||||
this.rowAction.emit({ action, row });
|
||||
}
|
||||
|
||||
cellText(col: MraTableColumn, row: Record<string, unknown>): string {
|
||||
const value = row[col.key];
|
||||
if (col.format) return col.format(value, row);
|
||||
if (value === null || value === undefined) return '';
|
||||
return String(value);
|
||||
}
|
||||
|
||||
sortAria(key: string): 'ascending' | 'descending' | 'none' | null {
|
||||
if (!this.sortable() || this.sortKey() !== key) return null;
|
||||
return this.sortDir() === 'asc' ? 'ascending' : 'descending';
|
||||
}
|
||||
}
|
||||
68
projects/mrarm-ui/src/lib/forms/checkbox.spec.ts
Normal file
68
projects/mrarm-ui/src/lib/forms/checkbox.spec.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MraCheckbox } from './checkbox';
|
||||
|
||||
describe('MraCheckbox', () => {
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [MraCheckbox, FormsModule],
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
const fixture = TestBed.createComponent(MraCheckbox);
|
||||
expect(fixture.componentInstance).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should write boolean value via CVA', () => {
|
||||
const fixture = TestBed.createComponent(MraCheckbox);
|
||||
const c = fixture.componentInstance;
|
||||
c.writeValue(true);
|
||||
expect(c.checked()).toBe(true);
|
||||
c.writeValue(false);
|
||||
expect(c.checked()).toBe(false);
|
||||
});
|
||||
|
||||
it('should coerce truthy/falsy', () => {
|
||||
const fixture = TestBed.createComponent(MraCheckbox);
|
||||
const c = fixture.componentInstance;
|
||||
c.writeValue(1);
|
||||
expect(c.checked()).toBe(true);
|
||||
c.writeValue(0);
|
||||
expect(c.checked()).toBe(false);
|
||||
});
|
||||
|
||||
it('should propagate toggle', () => {
|
||||
const fixture = TestBed.createComponent(MraCheckbox);
|
||||
const c = fixture.componentInstance;
|
||||
let val = false;
|
||||
c.registerOnChange((v) => (val = v));
|
||||
c.onToggle({ target: { checked: true } } as unknown as Event);
|
||||
expect(c.checked()).toBe(true);
|
||||
expect(val).toBe(true);
|
||||
});
|
||||
|
||||
it('should fire onTouch on blur', () => {
|
||||
const fixture = TestBed.createComponent(MraCheckbox);
|
||||
const c = fixture.componentInstance;
|
||||
let touched = false;
|
||||
c.registerOnTouched(() => (touched = true));
|
||||
c.onTouch();
|
||||
expect(touched).toBe(true);
|
||||
});
|
||||
|
||||
it('should set disabled state', () => {
|
||||
const fixture = TestBed.createComponent(MraCheckbox);
|
||||
const c = fixture.componentInstance;
|
||||
c.setDisabledState(true);
|
||||
expect(c.disabled()).toBe(true);
|
||||
});
|
||||
|
||||
it('should render label when provided', () => {
|
||||
const fixture = TestBed.createComponent(MraCheckbox);
|
||||
fixture.componentRef.setInput('label', 'Accept terms');
|
||||
fixture.detectChanges();
|
||||
const el = fixture.nativeElement as HTMLElement;
|
||||
expect(el.querySelector('.mra-check__label')?.textContent).toContain('Accept terms');
|
||||
});
|
||||
});
|
||||
21
projects/mrarm-ui/src/lib/forms/checkbox.stories.ts
Normal file
21
projects/mrarm-ui/src/lib/forms/checkbox.stories.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MraCheckbox } from '@mrarm/ui';
|
||||
|
||||
const meta: Meta<MraCheckbox> = {
|
||||
title: 'Forms/Checkbox',
|
||||
component: MraCheckbox,
|
||||
tags: ['autodocs'],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
const md = { imports: [FormsModule, MraCheckbox] };
|
||||
|
||||
export const Default: StoryObj<MraCheckbox> = {
|
||||
render: () => ({ template: `<mra-checkbox label="Enable notifications"></mra-checkbox>`, moduleMetadata: md }),
|
||||
};
|
||||
|
||||
export const Checked: StoryObj<MraCheckbox> = {
|
||||
render: () => ({ template: `<mra-checkbox label="Enable notifications" [ngModel]="true"></mra-checkbox>`, moduleMetadata: md }),
|
||||
};
|
||||
109
projects/mrarm-ui/src/lib/forms/checkbox.ts
Normal file
109
projects/mrarm-ui/src/lib/forms/checkbox.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
forwardRef,
|
||||
input,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
|
||||
|
||||
@Component({
|
||||
selector: 'mra-checkbox',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
providers: [
|
||||
{
|
||||
provide: NG_VALUE_ACCESSOR,
|
||||
useExisting: forwardRef(() => MraCheckbox),
|
||||
multi: true,
|
||||
},
|
||||
],
|
||||
template: `
|
||||
<label class="mra-check" [class.mra-check--disabled]="disabled()">
|
||||
<input
|
||||
type="checkbox"
|
||||
[id]="id()"
|
||||
[checked]="checked()"
|
||||
[disabled]="disabled()"
|
||||
(change)="onToggle($event)"
|
||||
(blur)="onTouch()"
|
||||
/>
|
||||
<span class="mra-check__box" aria-hidden="true"></span>
|
||||
@if (label()) {
|
||||
<span class="mra-check__label">{{ label() }}</span>
|
||||
}
|
||||
<ng-content />
|
||||
</label>
|
||||
`,
|
||||
styles: `
|
||||
:host {
|
||||
display: inline-flex;
|
||||
}
|
||||
.mra-check {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--mra-space-2);
|
||||
cursor: pointer;
|
||||
font-size: var(--mra-font-size);
|
||||
}
|
||||
.mra-check--disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.mra-check input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
.mra-check__box {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 1px solid var(--mra-border);
|
||||
background: var(--mra-surface);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.mra-check input:checked + .mra-check__box {
|
||||
background: var(--mra-accent);
|
||||
border-color: var(--mra-accent);
|
||||
}
|
||||
.mra-check input:checked + .mra-check__box::after {
|
||||
content: '';
|
||||
width: 7px;
|
||||
height: 4px;
|
||||
border-left: 2px solid var(--mra-surface);
|
||||
border-bottom: 2px solid var(--mra-surface);
|
||||
transform: rotate(-45deg) translate(1px, -1px);
|
||||
}
|
||||
.mra-check input:focus-visible + .mra-check__box {
|
||||
box-shadow: var(--mra-focus-ring);
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class MraCheckbox implements ControlValueAccessor {
|
||||
readonly id = input<string>('');
|
||||
readonly label = input<string>('');
|
||||
readonly checked = signal(false);
|
||||
readonly disabled = signal(false);
|
||||
private onChange: (v: boolean) => void = () => {};
|
||||
onTouch = () => {};
|
||||
|
||||
onToggle(event: Event) {
|
||||
const v = (event.target as HTMLInputElement).checked;
|
||||
this.checked.set(v);
|
||||
this.onChange(v);
|
||||
}
|
||||
writeValue(obj: unknown): void {
|
||||
this.checked.set(!!obj);
|
||||
}
|
||||
registerOnChange(fn: (v: boolean) => void): void {
|
||||
this.onChange = fn;
|
||||
}
|
||||
registerOnTouched(fn: () => void): void {
|
||||
this.onTouch = fn;
|
||||
}
|
||||
setDisabledState(isDisabled: boolean): void {
|
||||
this.disabled.set(isDisabled);
|
||||
}
|
||||
}
|
||||
62
projects/mrarm-ui/src/lib/forms/field.spec.ts
Normal file
62
projects/mrarm-ui/src/lib/forms/field.spec.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { MraField } from './field';
|
||||
|
||||
describe('MraField', () => {
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({ imports: [MraField] }).compileComponents();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
const fixture = TestBed.createComponent(MraField);
|
||||
expect(fixture.componentInstance).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render label', () => {
|
||||
const fixture = TestBed.createComponent(MraField);
|
||||
fixture.componentRef.setInput('label', 'Name');
|
||||
fixture.detectChanges();
|
||||
const el = fixture.nativeElement as HTMLElement;
|
||||
expect(el.querySelector('.mra-field__label')?.textContent).toContain('Name');
|
||||
});
|
||||
|
||||
it('should set for attribute on label', () => {
|
||||
const fixture = TestBed.createComponent(MraField);
|
||||
fixture.componentRef.setInput('label', 'Name');
|
||||
fixture.componentRef.setInput('forId', 'name-field');
|
||||
fixture.detectChanges();
|
||||
const label = (fixture.nativeElement as HTMLElement).querySelector('label')!;
|
||||
expect(label.getAttribute('for')).toBe('name-field');
|
||||
});
|
||||
|
||||
it('should render hint', () => {
|
||||
const fixture = TestBed.createComponent(MraField);
|
||||
fixture.componentRef.setInput('hint', 'Enter your full name');
|
||||
fixture.detectChanges();
|
||||
const el = fixture.nativeElement as HTMLElement;
|
||||
expect(el.querySelector('.mra-field__hint')?.textContent).toContain('Enter your full name');
|
||||
});
|
||||
|
||||
it('should apply required class and asterisk', () => {
|
||||
const fixture = TestBed.createComponent(MraField);
|
||||
fixture.componentRef.setInput('label', 'Name');
|
||||
fixture.componentRef.setInput('required', true);
|
||||
fixture.detectChanges();
|
||||
const el = fixture.nativeElement as HTMLElement;
|
||||
expect(el.querySelector('.mra-field--required')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should apply invalid class', () => {
|
||||
const fixture = TestBed.createComponent(MraField);
|
||||
fixture.componentRef.setInput('invalid', true);
|
||||
fixture.detectChanges();
|
||||
const el = fixture.nativeElement as HTMLElement;
|
||||
expect(el.querySelector('.mra-field--invalid')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should not render label when empty', () => {
|
||||
const fixture = TestBed.createComponent(MraField);
|
||||
fixture.detectChanges();
|
||||
const el = fixture.nativeElement as HTMLElement;
|
||||
expect(el.querySelector('.mra-field__label')).toBeNull();
|
||||
});
|
||||
});
|
||||
33
projects/mrarm-ui/src/lib/forms/field.stories.ts
Normal file
33
projects/mrarm-ui/src/lib/forms/field.stories.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { MraField, MraInput, MraValidationMessage } from '@mrarm/ui';
|
||||
|
||||
const meta: Meta<MraField> = {
|
||||
title: 'Forms/Field',
|
||||
component: MraField,
|
||||
tags: ['autodocs'],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
const md = { imports: [MraField, MraInput, MraValidationMessage] };
|
||||
|
||||
export const Default: StoryObj<MraField> = {
|
||||
render: () => ({
|
||||
template: `<mra-field label="Hostname" forId="host" hint="Fully qualified domain name"><mra-input id="host" placeholder="db-01.internal"></mra-input></mra-field>`,
|
||||
moduleMetadata: md,
|
||||
}),
|
||||
};
|
||||
|
||||
export const Required: StoryObj<MraField> = {
|
||||
render: () => ({
|
||||
template: `<mra-field label="API key" forId="key" [required]="true"><mra-input id="key" placeholder="sk-…"></mra-input></mra-field>`,
|
||||
moduleMetadata: md,
|
||||
}),
|
||||
};
|
||||
|
||||
export const Invalid: StoryObj<MraField> = {
|
||||
render: () => ({
|
||||
template: `<mra-field label="Email" forId="email" [invalid]="true"><mra-input id="email" [invalid]="true" placeholder="name@example.com"></mra-input><mra-validation-message [show]="true">Enter a valid email address.</mra-validation-message></mra-field>`,
|
||||
moduleMetadata: md,
|
||||
}),
|
||||
};
|
||||
51
projects/mrarm-ui/src/lib/forms/field.ts
Normal file
51
projects/mrarm-ui/src/lib/forms/field.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { ChangeDetectionStrategy, Component, booleanAttribute, input } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'mra-field',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="mra-field" [class.mra-field--required]="required()" [class.mra-field--invalid]="invalid()">
|
||||
@if (label()) {
|
||||
<label class="mra-field__label" [attr.for]="forId()">{{ label() }}</label>
|
||||
}
|
||||
<div class="mra-field__control"><ng-content /></div>
|
||||
@if (hint()) {
|
||||
<div class="mra-field__hint">{{ hint() }}</div>
|
||||
}
|
||||
<ng-content select="mra-validation-message" />
|
||||
</div>
|
||||
`,
|
||||
styles: `
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
.mra-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--mra-space-1);
|
||||
}
|
||||
.mra-field__label {
|
||||
font-size: var(--mra-font-size-sm);
|
||||
color: var(--mra-muted);
|
||||
font-weight: 700;
|
||||
}
|
||||
.mra-field--required .mra-field__label::after {
|
||||
content: ' *';
|
||||
color: var(--mra-bad);
|
||||
}
|
||||
.mra-field__hint {
|
||||
font-size: var(--mra-font-size-sm);
|
||||
color: var(--mra-muted);
|
||||
}
|
||||
.mra-field--invalid .mra-field__label {
|
||||
color: var(--mra-bad);
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class MraField {
|
||||
readonly label = input<string>('');
|
||||
readonly forId = input<string>('');
|
||||
readonly hint = input<string>('');
|
||||
readonly required = input(false, { transform: booleanAttribute });
|
||||
readonly invalid = input(false, { transform: booleanAttribute });
|
||||
}
|
||||
19
projects/mrarm-ui/src/lib/forms/form-actions.stories.ts
Normal file
19
projects/mrarm-ui/src/lib/forms/form-actions.stories.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { MraFormActions, MraButton } from '@mrarm/ui';
|
||||
|
||||
const meta: Meta<MraFormActions> = {
|
||||
title: 'Forms/Form Actions',
|
||||
component: MraFormActions,
|
||||
tags: ['autodocs'],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
const md = { imports: [MraFormActions, MraButton] };
|
||||
|
||||
export const Default: StoryObj<MraFormActions> = {
|
||||
render: () => ({
|
||||
template: `<mra-form-actions><mra-button>Cancel</mra-button><mra-button variant="primary">Save</mra-button></mra-form-actions>`,
|
||||
moduleMetadata: md,
|
||||
}),
|
||||
};
|
||||
29
projects/mrarm-ui/src/lib/forms/form-actions.ts
Normal file
29
projects/mrarm-ui/src/lib/forms/form-actions.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'mra-form-actions',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: ` <div class="mra-form-actions" [class.mra-form-actions--block]="block()">
|
||||
<ng-content />
|
||||
</div> `,
|
||||
styles: `
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
.mra-form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--mra-space-3);
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
padding-top: var(--mra-space-3);
|
||||
border-top: 1px solid var(--mra-line);
|
||||
}
|
||||
.mra-form-actions--block {
|
||||
justify-content: stretch;
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class MraFormActions {
|
||||
readonly block = input<boolean>(false);
|
||||
}
|
||||
12
projects/mrarm-ui/src/lib/forms/index.ts
Normal file
12
projects/mrarm-ui/src/lib/forms/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export * from './field';
|
||||
export * from './validation-message';
|
||||
export * from './form-actions';
|
||||
export * from './input';
|
||||
export * from './textarea';
|
||||
export * from './select';
|
||||
export * from './checkbox';
|
||||
export * from './switch';
|
||||
export * from './radio';
|
||||
export * from './segmented-control';
|
||||
export * from './slider';
|
||||
export * from './number-stepper';
|
||||
72
projects/mrarm-ui/src/lib/forms/input.spec.ts
Normal file
72
projects/mrarm-ui/src/lib/forms/input.spec.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MraInput } from './input';
|
||||
|
||||
describe('MraInput', () => {
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [MraInput, FormsModule],
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
const fixture = TestBed.createComponent(MraInput);
|
||||
expect(fixture.componentInstance).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should write value via CVA', () => {
|
||||
const fixture = TestBed.createComponent(MraInput);
|
||||
const c = fixture.componentInstance;
|
||||
c.writeValue('hello');
|
||||
expect(c.value()).toBe('hello');
|
||||
});
|
||||
|
||||
it('should write null as empty', () => {
|
||||
const fixture = TestBed.createComponent(MraInput);
|
||||
const c = fixture.componentInstance;
|
||||
c.writeValue(null);
|
||||
expect(c.value()).toBe('');
|
||||
});
|
||||
|
||||
it('should propagate input change', () => {
|
||||
const fixture = TestBed.createComponent(MraInput);
|
||||
const c = fixture.componentInstance;
|
||||
let val: string | null = null;
|
||||
c.registerOnChange((v) => (val = v));
|
||||
c.onInput({ target: { value: 'typed' } } as unknown as Event);
|
||||
expect(c.value()).toBe('typed');
|
||||
expect(val).toBe('typed');
|
||||
});
|
||||
|
||||
it('should fire onTouch on blur', () => {
|
||||
const fixture = TestBed.createComponent(MraInput);
|
||||
const c = fixture.componentInstance;
|
||||
let touched = false;
|
||||
c.registerOnTouched(() => (touched = true));
|
||||
c.onTouch();
|
||||
expect(touched).toBe(true);
|
||||
});
|
||||
|
||||
it('should set disabled state', () => {
|
||||
const fixture = TestBed.createComponent(MraInput);
|
||||
const c = fixture.componentInstance;
|
||||
c.setDisabledState(true);
|
||||
expect(c.disabled()).toBe(true);
|
||||
});
|
||||
|
||||
it('should render native input with correct type', () => {
|
||||
const fixture = TestBed.createComponent(MraInput);
|
||||
fixture.componentRef.setInput('type', 'email');
|
||||
fixture.detectChanges();
|
||||
const input = (fixture.nativeElement as HTMLElement).querySelector('input')!;
|
||||
expect(input.type).toBe('email');
|
||||
});
|
||||
|
||||
it('should apply invalid class', () => {
|
||||
const fixture = TestBed.createComponent(MraInput);
|
||||
fixture.componentRef.setInput('invalid', true);
|
||||
fixture.detectChanges();
|
||||
const input = (fixture.nativeElement as HTMLElement).querySelector('input')!;
|
||||
expect(input.className).toContain('mra-input--invalid');
|
||||
});
|
||||
});
|
||||
33
projects/mrarm-ui/src/lib/forms/input.stories.ts
Normal file
33
projects/mrarm-ui/src/lib/forms/input.stories.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MraInput } from '@mrarm/ui';
|
||||
|
||||
const meta: Meta<MraInput> = {
|
||||
title: 'Forms/Input',
|
||||
component: MraInput,
|
||||
tags: ['autodocs'],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
const md = { imports: [FormsModule, MraInput] };
|
||||
|
||||
export const Default: StoryObj<MraInput> = {
|
||||
render: () => ({ template: `<mra-input placeholder="Enter hostname"></mra-input>`, moduleMetadata: md }),
|
||||
};
|
||||
|
||||
export const WithValue: StoryObj<MraInput> = {
|
||||
render: () => ({ template: `<mra-input placeholder="Hostname" [ngModel]="'db-01.internal'"></mra-input>`, moduleMetadata: md }),
|
||||
};
|
||||
|
||||
export const Readonly: StoryObj<MraInput> = {
|
||||
render: () => ({ template: `<mra-input placeholder="Readonly" [readonly]="true" [ngModel]="'locked-value'"></mra-input>`, moduleMetadata: md }),
|
||||
};
|
||||
|
||||
export const Invalid: StoryObj<MraInput> = {
|
||||
render: () => ({ template: `<mra-input placeholder="Required" [invalid]="true"></mra-input>`, moduleMetadata: md }),
|
||||
};
|
||||
|
||||
export const Password: StoryObj<MraInput> = {
|
||||
render: () => ({ template: `<mra-input type="password" placeholder="Password" [ngModel]="'secret'"></mra-input>`, moduleMetadata: md }),
|
||||
};
|
||||
98
projects/mrarm-ui/src/lib/forms/input.ts
Normal file
98
projects/mrarm-ui/src/lib/forms/input.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
forwardRef,
|
||||
input,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
|
||||
|
||||
@Component({
|
||||
selector: 'mra-input',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
providers: [
|
||||
{
|
||||
provide: NG_VALUE_ACCESSOR,
|
||||
useExisting: forwardRef(() => MraInput),
|
||||
multi: true,
|
||||
},
|
||||
],
|
||||
template: `
|
||||
<input
|
||||
class="mra-input"
|
||||
[class.mra-input--invalid]="invalid()"
|
||||
[type]="type()"
|
||||
[id]="id()"
|
||||
[placeholder]="placeholder()"
|
||||
[disabled]="disabled()"
|
||||
[readonly]="readonly()"
|
||||
[value]="value()"
|
||||
[attr.maxlength]="maxlength()"
|
||||
[attr.autocomplete]="autocomplete()"
|
||||
(input)="onInput($event)"
|
||||
(blur)="onTouch()"
|
||||
/>
|
||||
`,
|
||||
styles: `
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
.mra-input {
|
||||
width: 100%;
|
||||
padding: var(--mra-control-padding-y) var(--mra-control-padding-x);
|
||||
border: 1px solid var(--mra-border-2);
|
||||
background: var(--mra-surface);
|
||||
color: var(--mra-text);
|
||||
font-family: var(--mra-font);
|
||||
font-size: var(--mra-font-size);
|
||||
border-radius: var(--mra-radius);
|
||||
}
|
||||
.mra-input::placeholder {
|
||||
color: var(--mra-muted);
|
||||
}
|
||||
.mra-input:focus {
|
||||
border-color: var(--mra-accent);
|
||||
box-shadow: var(--mra-focus-ring);
|
||||
outline: none;
|
||||
}
|
||||
.mra-input:disabled {
|
||||
background: var(--mra-surface-alt);
|
||||
cursor: not-allowed;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.mra-input--invalid {
|
||||
border-color: var(--mra-bad);
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class MraInput implements ControlValueAccessor {
|
||||
readonly type = input<string>('text');
|
||||
readonly id = input<string>('');
|
||||
readonly placeholder = input<string>('');
|
||||
readonly readonly = input<boolean>(false);
|
||||
readonly maxlength = input<number | null>(null);
|
||||
readonly autocomplete = input<string>('');
|
||||
readonly value = signal('');
|
||||
readonly disabled = signal(false);
|
||||
readonly invalid = input<boolean>(false);
|
||||
private onChange: (v: string | null) => void = () => {};
|
||||
onTouch = () => {};
|
||||
|
||||
onInput(event: Event) {
|
||||
const v = (event.target as HTMLInputElement).value;
|
||||
this.value.set(v);
|
||||
this.onChange(v);
|
||||
}
|
||||
writeValue(obj: unknown): void {
|
||||
this.value.set(obj == null ? '' : String(obj));
|
||||
}
|
||||
registerOnChange(fn: (v: string | null) => void): void {
|
||||
this.onChange = fn;
|
||||
}
|
||||
registerOnTouched(fn: () => void): void {
|
||||
this.onTouch = fn;
|
||||
}
|
||||
setDisabledState(isDisabled: boolean): void {
|
||||
this.disabled.set(isDisabled);
|
||||
}
|
||||
}
|
||||
21
projects/mrarm-ui/src/lib/forms/number-stepper.stories.ts
Normal file
21
projects/mrarm-ui/src/lib/forms/number-stepper.stories.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MraNumberStepper } from '@mrarm/ui';
|
||||
|
||||
const meta: Meta<MraNumberStepper> = {
|
||||
title: 'Forms/Number Stepper',
|
||||
component: MraNumberStepper,
|
||||
tags: ['autodocs'],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
const md = { imports: [FormsModule, MraNumberStepper] };
|
||||
|
||||
export const Default: StoryObj<MraNumberStepper> = {
|
||||
render: () => ({ template: `<mra-number-stepper [ngModel]="3"></mra-number-stepper>`, moduleMetadata: md }),
|
||||
};
|
||||
|
||||
export const Bounded: StoryObj<MraNumberStepper> = {
|
||||
render: () => ({ template: `<mra-number-stepper [ngModel]="5" [min]="1" [max]="10" [step]="1"></mra-number-stepper>`, moduleMetadata: md }),
|
||||
};
|
||||
143
projects/mrarm-ui/src/lib/forms/number-stepper.ts
Normal file
143
projects/mrarm-ui/src/lib/forms/number-stepper.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
forwardRef,
|
||||
input,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
|
||||
|
||||
@Component({
|
||||
selector: 'mra-number-stepper',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
providers: [
|
||||
{
|
||||
provide: NG_VALUE_ACCESSOR,
|
||||
useExisting: forwardRef(() => MraNumberStepper),
|
||||
multi: true,
|
||||
},
|
||||
],
|
||||
template: `
|
||||
<div class="mra-stepper">
|
||||
<button
|
||||
type="button"
|
||||
class="mra-stepper__btn"
|
||||
[disabled]="disabled() || atMin()"
|
||||
(click)="stepBy(-1)"
|
||||
aria-label="Decrease"
|
||||
>−</button>
|
||||
<input
|
||||
type="number"
|
||||
class="mra-stepper__input"
|
||||
[id]="id()"
|
||||
[min]="min()"
|
||||
[max]="max()"
|
||||
[step]="step()"
|
||||
[disabled]="disabled()"
|
||||
[value]="value()"
|
||||
(input)="onInput($event)"
|
||||
(blur)="onTouch()"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="mra-stepper__btn"
|
||||
[disabled]="disabled() || atMax()"
|
||||
(click)="stepBy(1)"
|
||||
aria-label="Increase"
|
||||
>+</button>
|
||||
</div>
|
||||
`,
|
||||
styles: `
|
||||
:host {
|
||||
display: inline-flex;
|
||||
}
|
||||
.mra-stepper {
|
||||
display: inline-flex;
|
||||
border: 1px solid var(--mra-border-2);
|
||||
}
|
||||
.mra-stepper__btn {
|
||||
width: 26px;
|
||||
padding: 0;
|
||||
background: var(--mra-btn-bg);
|
||||
color: var(--mra-text);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
border: 0;
|
||||
border-right: 1px solid var(--mra-border-2);
|
||||
}
|
||||
.mra-stepper__btn:last-child {
|
||||
border-right: 0;
|
||||
border-left: 1px solid var(--mra-border-2);
|
||||
}
|
||||
.mra-stepper__btn:hover:not(:disabled) {
|
||||
background: var(--mra-btn-bg-hover);
|
||||
}
|
||||
.mra-stepper__btn:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
.mra-stepper__input {
|
||||
width: 56px;
|
||||
padding: var(--mra-control-padding-y) var(--mra-space-2);
|
||||
border: 0;
|
||||
background: var(--mra-surface);
|
||||
color: var(--mra-text);
|
||||
font-family: var(--mra-font-mono);
|
||||
font-size: var(--mra-font-size-sm);
|
||||
text-align: center;
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
.mra-stepper__input::-webkit-outer-spin-button,
|
||||
.mra-stepper__input::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
.mra-stepper__input:focus {
|
||||
outline: none;
|
||||
box-shadow: var(--mra-focus-ring);
|
||||
}
|
||||
.mra-stepper__btn:focus-visible {
|
||||
box-shadow: var(--mra-focus-ring);
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class MraNumberStepper implements ControlValueAccessor {
|
||||
readonly id = input<string>('');
|
||||
readonly min = input<number>(0);
|
||||
readonly max = input<number>(9999);
|
||||
readonly step = input<number>(1);
|
||||
readonly value = signal(0);
|
||||
readonly disabled = signal(false);
|
||||
private onChange: (v: number) => void = () => {};
|
||||
onTouch = () => {};
|
||||
|
||||
atMin() {
|
||||
return this.value() <= this.min();
|
||||
}
|
||||
atMax() {
|
||||
return this.value() >= this.max();
|
||||
}
|
||||
stepBy(dir: number) {
|
||||
const v = Math.max(this.min(), Math.min(this.max(), this.value() + dir * this.step()));
|
||||
this.value.set(v);
|
||||
this.onChange(v);
|
||||
}
|
||||
onInput(event: Event) {
|
||||
const raw = (event.target as HTMLInputElement).value;
|
||||
const v = raw === '' ? this.min() : Number(raw);
|
||||
this.value.set(v);
|
||||
this.onChange(v);
|
||||
}
|
||||
writeValue(obj: unknown): void {
|
||||
this.value.set(obj == null ? this.min() : Number(obj));
|
||||
}
|
||||
registerOnChange(fn: (v: number) => void): void {
|
||||
this.onChange = fn;
|
||||
}
|
||||
registerOnTouched(fn: () => void): void {
|
||||
this.onTouch = fn;
|
||||
}
|
||||
setDisabledState(isDisabled: boolean): void {
|
||||
this.disabled.set(isDisabled);
|
||||
}
|
||||
}
|
||||
31
projects/mrarm-ui/src/lib/forms/radio.stories.ts
Normal file
31
projects/mrarm-ui/src/lib/forms/radio.stories.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MraRadioGroup, type MraRadioOption } from '@mrarm/ui';
|
||||
|
||||
const meta: Meta<MraRadioGroup> = {
|
||||
title: 'Forms/Radio Group',
|
||||
component: MraRadioGroup,
|
||||
tags: ['autodocs'],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
const options: MraRadioOption[] = [
|
||||
{ value: 'low', label: 'Low priority' },
|
||||
{ value: 'med', label: 'Medium priority' },
|
||||
{ value: 'high', label: 'High priority' },
|
||||
];
|
||||
|
||||
const md = { imports: [FormsModule, MraRadioGroup] };
|
||||
|
||||
export const Default: StoryObj<MraRadioGroup> = {
|
||||
render: () => ({ template: `<mra-radio-group name="prio" [options]="options"></mra-radio-group>`, props: { options }, moduleMetadata: md }),
|
||||
};
|
||||
|
||||
export const Inline: StoryObj<MraRadioGroup> = {
|
||||
render: () => ({ template: `<mra-radio-group name="prio2" [options]="options" [inline]="true"></mra-radio-group>`, props: { options }, moduleMetadata: md }),
|
||||
};
|
||||
|
||||
export const Selected: StoryObj<MraRadioGroup> = {
|
||||
render: () => ({ template: `<mra-radio-group name="prio3" [options]="options" [ngModel]="'med'"></mra-radio-group>`, props: { options }, moduleMetadata: md }),
|
||||
};
|
||||
126
projects/mrarm-ui/src/lib/forms/radio.ts
Normal file
126
projects/mrarm-ui/src/lib/forms/radio.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
forwardRef,
|
||||
input,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
|
||||
|
||||
export interface MraRadioOption {
|
||||
value: unknown;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'mra-radio-group',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
providers: [
|
||||
{
|
||||
provide: NG_VALUE_ACCESSOR,
|
||||
useExisting: forwardRef(() => MraRadioGroup),
|
||||
multi: true,
|
||||
},
|
||||
],
|
||||
template: `
|
||||
<div class="mra-radio" [class.mra-radio--inline]="inline()" role="radiogroup">
|
||||
@for (opt of options(); track opt.value) {
|
||||
<label class="mra-radio__item" [class.mra-radio__item--disabled]="opt.disabled || disabled()">
|
||||
<input
|
||||
type="radio"
|
||||
[name]="name()"
|
||||
[value]="opt.value"
|
||||
[checked]="opt.value === value()"
|
||||
[disabled]="opt.disabled || disabled()"
|
||||
(change)="select(opt.value)"
|
||||
(blur)="onTouch()"
|
||||
/>
|
||||
<span class="mra-radio__dot" aria-hidden="true"></span>
|
||||
<span class="mra-radio__label">{{ opt.label }}</span>
|
||||
</label>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: `
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
.mra-radio {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--mra-space-2);
|
||||
}
|
||||
.mra-radio--inline {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--mra-space-4);
|
||||
}
|
||||
.mra-radio__item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--mra-space-2);
|
||||
cursor: pointer;
|
||||
font-size: var(--mra-font-size);
|
||||
}
|
||||
.mra-radio__item--disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.mra-radio__item input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
.mra-radio__dot {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 1px solid var(--mra-border);
|
||||
background: var(--mra-surface);
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.mra-radio__item input:checked + .mra-radio__dot {
|
||||
border-color: var(--mra-accent);
|
||||
}
|
||||
.mra-radio__item input:checked + .mra-radio__dot::after {
|
||||
content: '';
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: var(--mra-accent);
|
||||
border-radius: 50%;
|
||||
}
|
||||
.mra-radio__item input:focus-visible + .mra-radio__dot {
|
||||
box-shadow: var(--mra-focus-ring);
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class MraRadioGroup implements ControlValueAccessor {
|
||||
readonly name = input<string>('');
|
||||
readonly options = input<MraRadioOption[]>([]);
|
||||
readonly inline = input<boolean>(false);
|
||||
readonly value = signal<unknown>(null);
|
||||
readonly disabled = signal(false);
|
||||
private onChange: (v: unknown) => void = () => {};
|
||||
onTouch = () => {};
|
||||
|
||||
select(v: unknown) {
|
||||
this.value.set(v);
|
||||
this.onChange(v);
|
||||
}
|
||||
writeValue(obj: unknown): void {
|
||||
this.value.set(obj);
|
||||
}
|
||||
registerOnChange(fn: (v: unknown) => void): void {
|
||||
this.onChange = fn;
|
||||
}
|
||||
registerOnTouched(fn: () => void): void {
|
||||
this.onTouch = fn;
|
||||
}
|
||||
setDisabledState(isDisabled: boolean): void {
|
||||
this.disabled.set(isDisabled);
|
||||
}
|
||||
}
|
||||
27
projects/mrarm-ui/src/lib/forms/segmented-control.stories.ts
Normal file
27
projects/mrarm-ui/src/lib/forms/segmented-control.stories.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MraSegmentedControl, type MraSegmentedOption } from '@mrarm/ui';
|
||||
|
||||
const meta: Meta<MraSegmentedControl> = {
|
||||
title: 'Forms/Segmented Control',
|
||||
component: MraSegmentedControl,
|
||||
tags: ['autodocs'],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
const options: MraSegmentedOption[] = [
|
||||
{ value: 'list', label: 'List' },
|
||||
{ value: 'grid', label: 'Grid' },
|
||||
{ value: 'table', label: 'Table' },
|
||||
];
|
||||
|
||||
const md = { imports: [FormsModule, MraSegmentedControl] };
|
||||
|
||||
export const Default: StoryObj<MraSegmentedControl> = {
|
||||
render: () => ({ template: `<mra-segmented-control [options]="options" [ngModel]="'list'"></mra-segmented-control>`, props: { options }, moduleMetadata: md }),
|
||||
};
|
||||
|
||||
export const Block: StoryObj<MraSegmentedControl> = {
|
||||
render: () => ({ template: `<mra-segmented-control [options]="options" [block]="true" [ngModel]="'grid'"></mra-segmented-control>`, props: { options }, moduleMetadata: md }),
|
||||
};
|
||||
115
projects/mrarm-ui/src/lib/forms/segmented-control.ts
Normal file
115
projects/mrarm-ui/src/lib/forms/segmented-control.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
forwardRef,
|
||||
input,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
|
||||
|
||||
export interface MraSegmentedOption {
|
||||
value: unknown;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'mra-segmented-control',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
providers: [
|
||||
{
|
||||
provide: NG_VALUE_ACCESSOR,
|
||||
useExisting: forwardRef(() => MraSegmentedControl),
|
||||
multi: true,
|
||||
},
|
||||
],
|
||||
template: `
|
||||
<div class="mra-seg" role="radiogroup" [class.mra-seg--block]="block()">
|
||||
@for (opt of options(); track opt.value) {
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
[attr.aria-checked]="opt.value === value()"
|
||||
class="mra-seg__item"
|
||||
[class.mra-seg__item--active]="opt.value === value()"
|
||||
[disabled]="opt.disabled || disabled()"
|
||||
(click)="select(opt.value)"
|
||||
(blur)="onTouch()"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: `
|
||||
:host {
|
||||
display: inline-flex;
|
||||
}
|
||||
.mra-seg {
|
||||
display: inline-flex;
|
||||
border: 1px solid var(--mra-border-2);
|
||||
}
|
||||
.mra-seg--block {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
}
|
||||
.mra-seg__item {
|
||||
padding: var(--mra-space-2) var(--mra-space-4);
|
||||
background: var(--mra-btn-bg);
|
||||
color: var(--mra-text);
|
||||
font-size: var(--mra-font-size-sm);
|
||||
border: 0;
|
||||
border-right: 1px solid var(--mra-border-2);
|
||||
cursor: pointer;
|
||||
}
|
||||
.mra-seg__item:last-child {
|
||||
border-right: 0;
|
||||
}
|
||||
.mra-seg__item:hover:not(:disabled) {
|
||||
background: var(--mra-btn-bg-hover);
|
||||
}
|
||||
.mra-seg__item--active {
|
||||
background: var(--mra-accent);
|
||||
color: var(--mra-surface);
|
||||
font-weight: 700;
|
||||
}
|
||||
.mra-seg__item:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.mra-seg__item:focus-visible {
|
||||
box-shadow: var(--mra-focus-ring);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.mra-seg--block .mra-seg__item {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class MraSegmentedControl implements ControlValueAccessor {
|
||||
readonly options = input<MraSegmentedOption[]>([]);
|
||||
readonly block = input<boolean>(false);
|
||||
readonly value = signal<unknown>(null);
|
||||
readonly disabled = signal(false);
|
||||
private onChange: (v: unknown) => void = () => {};
|
||||
onTouch = () => {};
|
||||
|
||||
select(v: unknown) {
|
||||
this.value.set(v);
|
||||
this.onChange(v);
|
||||
}
|
||||
writeValue(obj: unknown): void {
|
||||
this.value.set(obj);
|
||||
}
|
||||
registerOnChange(fn: (v: unknown) => void): void {
|
||||
this.onChange = fn;
|
||||
}
|
||||
registerOnTouched(fn: () => void): void {
|
||||
this.onTouch = fn;
|
||||
}
|
||||
setDisabledState(isDisabled: boolean): void {
|
||||
this.disabled.set(isDisabled);
|
||||
}
|
||||
}
|
||||
25
projects/mrarm-ui/src/lib/forms/select.stories.ts
Normal file
25
projects/mrarm-ui/src/lib/forms/select.stories.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { MraSelect, type MraSelectOption } from '@mrarm/ui';
|
||||
|
||||
const meta: Meta<MraSelect> = {
|
||||
title: 'Forms/Select',
|
||||
component: MraSelect,
|
||||
tags: ['autodocs'],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
const options: MraSelectOption[] = [
|
||||
{ value: 'dev', label: 'Development' },
|
||||
{ value: 'stg', label: 'Staging' },
|
||||
{ value: 'prod', label: 'Production' },
|
||||
{ value: 'arch', label: 'Archived', disabled: true },
|
||||
];
|
||||
|
||||
export const Default: StoryObj<MraSelect> = {
|
||||
render: () => ({ template: `<mra-select placeholder="Choose env" [options]="options"></mra-select>`, props: { options } }),
|
||||
};
|
||||
|
||||
export const Invalid: StoryObj<MraSelect> = {
|
||||
render: () => ({ template: `<mra-select placeholder="Choose env" [options]="options" [invalid]="true"></mra-select>`, props: { options } }),
|
||||
};
|
||||
109
projects/mrarm-ui/src/lib/forms/select.ts
Normal file
109
projects/mrarm-ui/src/lib/forms/select.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
forwardRef,
|
||||
input,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
|
||||
|
||||
export interface MraSelectOption {
|
||||
value: unknown;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'mra-select',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
providers: [
|
||||
{
|
||||
provide: NG_VALUE_ACCESSOR,
|
||||
useExisting: forwardRef(() => MraSelect),
|
||||
multi: true,
|
||||
},
|
||||
],
|
||||
template: `
|
||||
<select
|
||||
class="mra-select"
|
||||
[class.mra-select--invalid]="invalid()"
|
||||
[id]="id()"
|
||||
[disabled]="disabled()"
|
||||
(change)="onChange($event)"
|
||||
(blur)="onTouch()"
|
||||
>
|
||||
@if (placeholder()) {
|
||||
<option value="" [disabled]="true" [selected]="value() === ''">{{ placeholder() }}</option>
|
||||
}
|
||||
@for (opt of options(); track opt.value) {
|
||||
<option [value]="opt.value" [disabled]="opt.disabled" [selected]="opt.value === value()">
|
||||
{{ opt.label }}
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
`,
|
||||
styles: `
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
.mra-select {
|
||||
width: 100%;
|
||||
padding: var(--mra-control-padding-y) var(--mra-control-padding-x);
|
||||
border: 1px solid var(--mra-border-2);
|
||||
background: var(--mra-surface);
|
||||
color: var(--mra-text);
|
||||
font-family: var(--mra-font);
|
||||
font-size: var(--mra-font-size);
|
||||
border-radius: var(--mra-radius);
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
background-image: linear-gradient(45deg, transparent 50%, var(--mra-border) 50%),
|
||||
linear-gradient(135deg, var(--mra-border) 50%, transparent 50%);
|
||||
background-position: calc(100% - 14px) 50%, calc(100% - 9px) 50%;
|
||||
background-size: 5px 5px, 5px 5px;
|
||||
background-repeat: no-repeat;
|
||||
padding-right: 26px;
|
||||
}
|
||||
.mra-select:focus {
|
||||
border-color: var(--mra-accent);
|
||||
box-shadow: var(--mra-focus-ring);
|
||||
outline: none;
|
||||
}
|
||||
.mra-select:disabled {
|
||||
background: var(--mra-surface-alt);
|
||||
cursor: not-allowed;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.mra-select--invalid {
|
||||
border-color: var(--mra-bad);
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class MraSelect implements ControlValueAccessor {
|
||||
readonly id = input<string>('');
|
||||
readonly placeholder = input<string>('');
|
||||
readonly options = input<MraSelectOption[]>([]);
|
||||
readonly value = signal<unknown>('');
|
||||
readonly disabled = signal(false);
|
||||
readonly invalid = input<boolean>(false);
|
||||
private onChangeFn: (v: unknown) => void = () => {};
|
||||
onTouch = () => {};
|
||||
|
||||
onChange(event: Event) {
|
||||
const v = (event.target as HTMLSelectElement).value;
|
||||
this.value.set(v);
|
||||
this.onChangeFn(v);
|
||||
}
|
||||
writeValue(obj: unknown): void {
|
||||
this.value.set(obj == null ? '' : obj);
|
||||
}
|
||||
registerOnChange(fn: (v: unknown) => void): void {
|
||||
this.onChangeFn = fn;
|
||||
}
|
||||
registerOnTouched(fn: () => void): void {
|
||||
this.onTouch = fn;
|
||||
}
|
||||
setDisabledState(isDisabled: boolean): void {
|
||||
this.disabled.set(isDisabled);
|
||||
}
|
||||
}
|
||||
25
projects/mrarm-ui/src/lib/forms/slider.stories.ts
Normal file
25
projects/mrarm-ui/src/lib/forms/slider.stories.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MraSlider } from '@mrarm/ui';
|
||||
|
||||
const meta: Meta<MraSlider> = {
|
||||
title: 'Forms/Slider',
|
||||
component: MraSlider,
|
||||
tags: ['autodocs'],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
const md = { imports: [FormsModule, MraSlider] };
|
||||
|
||||
export const Default: StoryObj<MraSlider> = {
|
||||
render: () => ({ template: `<mra-slider [ngModel]="40"></mra-slider>`, moduleMetadata: md }),
|
||||
};
|
||||
|
||||
export const WithUnit: StoryObj<MraSlider> = {
|
||||
render: () => ({ template: `<mra-slider [ngModel]="75" unit="%"></mra-slider>`, moduleMetadata: md }),
|
||||
};
|
||||
|
||||
export const Stepped: StoryObj<MraSlider> = {
|
||||
render: () => ({ template: `<mra-slider [ngModel]="20" [min]="0" [max]="100" [step]="10" unit="ms"></mra-slider>`, moduleMetadata: md }),
|
||||
};
|
||||
90
projects/mrarm-ui/src/lib/forms/slider.ts
Normal file
90
projects/mrarm-ui/src/lib/forms/slider.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
forwardRef,
|
||||
input,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
|
||||
|
||||
@Component({
|
||||
selector: 'mra-slider',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
providers: [
|
||||
{
|
||||
provide: NG_VALUE_ACCESSOR,
|
||||
useExisting: forwardRef(() => MraSlider),
|
||||
multi: true,
|
||||
},
|
||||
],
|
||||
template: `
|
||||
<div class="mra-slider">
|
||||
<input
|
||||
type="range"
|
||||
class="mra-slider__input"
|
||||
[id]="id()"
|
||||
[min]="min()"
|
||||
[max]="max()"
|
||||
[step]="step()"
|
||||
[disabled]="disabled()"
|
||||
[value]="value()"
|
||||
(input)="onInput($event)"
|
||||
(blur)="onTouch()"
|
||||
/>
|
||||
<span class="mra-slider__value mra-mono">{{ value() }}{{ unit() }}</span>
|
||||
</div>
|
||||
`,
|
||||
styles: `
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
.mra-slider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--mra-space-3);
|
||||
}
|
||||
.mra-slider__input {
|
||||
flex: 1;
|
||||
accent-color: var(--mra-accent);
|
||||
height: 14px;
|
||||
}
|
||||
.mra-slider__input:focus-visible {
|
||||
outline: 2px solid var(--mra-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.mra-slider__value {
|
||||
min-width: 40px;
|
||||
text-align: right;
|
||||
color: var(--mra-muted);
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class MraSlider implements ControlValueAccessor {
|
||||
readonly id = input<string>('');
|
||||
readonly min = input<number>(0);
|
||||
readonly max = input<number>(100);
|
||||
readonly step = input<number>(1);
|
||||
readonly unit = input<string>('');
|
||||
readonly value = signal(0);
|
||||
readonly disabled = signal(false);
|
||||
private onChange: (v: number) => void = () => {};
|
||||
onTouch = () => {};
|
||||
|
||||
onInput(event: Event) {
|
||||
const v = Number((event.target as HTMLInputElement).value);
|
||||
this.value.set(v);
|
||||
this.onChange(v);
|
||||
}
|
||||
writeValue(obj: unknown): void {
|
||||
this.value.set(obj == null ? this.min() : Number(obj));
|
||||
}
|
||||
registerOnChange(fn: (v: number) => void): void {
|
||||
this.onChange = fn;
|
||||
}
|
||||
registerOnTouched(fn: () => void): void {
|
||||
this.onTouch = fn;
|
||||
}
|
||||
setDisabledState(isDisabled: boolean): void {
|
||||
this.disabled.set(isDisabled);
|
||||
}
|
||||
}
|
||||
21
projects/mrarm-ui/src/lib/forms/switch.stories.ts
Normal file
21
projects/mrarm-ui/src/lib/forms/switch.stories.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MraSwitch } from '@mrarm/ui';
|
||||
|
||||
const meta: Meta<MraSwitch> = {
|
||||
title: 'Forms/Switch',
|
||||
component: MraSwitch,
|
||||
tags: ['autodocs'],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
const md = { imports: [FormsModule, MraSwitch] };
|
||||
|
||||
export const Default: StoryObj<MraSwitch> = {
|
||||
render: () => ({ template: `<mra-switch label="Dark mode"></mra-switch>`, moduleMetadata: md }),
|
||||
};
|
||||
|
||||
export const On: StoryObj<MraSwitch> = {
|
||||
render: () => ({ template: `<mra-switch label="Dark mode" [ngModel]="true"></mra-switch>`, moduleMetadata: md }),
|
||||
};
|
||||
117
projects/mrarm-ui/src/lib/forms/switch.ts
Normal file
117
projects/mrarm-ui/src/lib/forms/switch.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
forwardRef,
|
||||
input,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
|
||||
|
||||
@Component({
|
||||
selector: 'mra-switch',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
providers: [
|
||||
{
|
||||
provide: NG_VALUE_ACCESSOR,
|
||||
useExisting: forwardRef(() => MraSwitch),
|
||||
multi: true,
|
||||
},
|
||||
],
|
||||
template: `
|
||||
<label class="mra-switch" [class.mra-switch--disabled]="disabled()">
|
||||
<input
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
[id]="id()"
|
||||
[checked]="on()"
|
||||
[disabled]="disabled()"
|
||||
(change)="onToggle($event)"
|
||||
(blur)="onTouch()"
|
||||
/>
|
||||
<span class="mra-switch__track" aria-hidden="true"><span class="mra-switch__thumb"></span></span>
|
||||
@if (label()) {
|
||||
<span class="mra-switch__label">{{ label() }}</span>
|
||||
}
|
||||
<ng-content />
|
||||
</label>
|
||||
`,
|
||||
styles: `
|
||||
:host {
|
||||
display: inline-flex;
|
||||
}
|
||||
.mra-switch {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--mra-space-3);
|
||||
cursor: pointer;
|
||||
font-size: var(--mra-font-size);
|
||||
}
|
||||
.mra-switch--disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.mra-switch input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
.mra-switch__track {
|
||||
width: 30px;
|
||||
height: 16px;
|
||||
border: 1px solid var(--mra-border);
|
||||
background: var(--mra-surface-alt);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 1px;
|
||||
}
|
||||
.mra-switch__thumb {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: var(--mra-border);
|
||||
transition: transform var(--mra-duration) var(--mra-easing);
|
||||
}
|
||||
.mra-switch input:checked + .mra-switch__track {
|
||||
background: var(--mra-accent);
|
||||
border-color: var(--mra-accent);
|
||||
}
|
||||
.mra-switch input:checked + .mra-switch__track .mra-switch__thumb {
|
||||
transform: translateX(14px);
|
||||
background: var(--mra-surface);
|
||||
}
|
||||
.mra-switch input:focus-visible + .mra-switch__track {
|
||||
box-shadow: var(--mra-focus-ring);
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.mra-switch__thumb {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class MraSwitch implements ControlValueAccessor {
|
||||
readonly id = input<string>('');
|
||||
readonly label = input<string>('');
|
||||
readonly on = signal(false);
|
||||
readonly disabled = signal(false);
|
||||
private onChange: (v: boolean) => void = () => {};
|
||||
onTouch = () => {};
|
||||
|
||||
onToggle(event: Event) {
|
||||
const v = (event.target as HTMLInputElement).checked;
|
||||
this.on.set(v);
|
||||
this.onChange(v);
|
||||
}
|
||||
writeValue(obj: unknown): void {
|
||||
this.on.set(!!obj);
|
||||
}
|
||||
registerOnChange(fn: (v: boolean) => void): void {
|
||||
this.onChange = fn;
|
||||
}
|
||||
registerOnTouched(fn: () => void): void {
|
||||
this.onTouch = fn;
|
||||
}
|
||||
setDisabledState(isDisabled: boolean): void {
|
||||
this.disabled.set(isDisabled);
|
||||
}
|
||||
}
|
||||
25
projects/mrarm-ui/src/lib/forms/textarea.stories.ts
Normal file
25
projects/mrarm-ui/src/lib/forms/textarea.stories.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MraTextarea } from '@mrarm/ui';
|
||||
|
||||
const meta: Meta<MraTextarea> = {
|
||||
title: 'Forms/Textarea',
|
||||
component: MraTextarea,
|
||||
tags: ['autodocs'],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
const md = { imports: [FormsModule, MraTextarea] };
|
||||
|
||||
export const Default: StoryObj<MraTextarea> = {
|
||||
render: () => ({ template: `<mra-textarea placeholder="Add notes…"></mra-textarea>`, moduleMetadata: md }),
|
||||
};
|
||||
|
||||
export const WithValue: StoryObj<MraTextarea> = {
|
||||
render: () => ({ template: `<mra-textarea [ngModel]="'First line.\\nSecond line.'" [rows]="5"></mra-textarea>`, moduleMetadata: md }),
|
||||
};
|
||||
|
||||
export const Invalid: StoryObj<MraTextarea> = {
|
||||
render: () => ({ template: `<mra-textarea placeholder="Required" [invalid]="true"></mra-textarea>`, moduleMetadata: md }),
|
||||
};
|
||||
97
projects/mrarm-ui/src/lib/forms/textarea.ts
Normal file
97
projects/mrarm-ui/src/lib/forms/textarea.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
forwardRef,
|
||||
input,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
|
||||
|
||||
@Component({
|
||||
selector: 'mra-textarea',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
providers: [
|
||||
{
|
||||
provide: NG_VALUE_ACCESSOR,
|
||||
useExisting: forwardRef(() => MraTextarea),
|
||||
multi: true,
|
||||
},
|
||||
],
|
||||
template: `
|
||||
<textarea
|
||||
class="mra-textarea"
|
||||
[class.mra-textarea--invalid]="invalid()"
|
||||
[id]="id()"
|
||||
[placeholder]="placeholder()"
|
||||
[disabled]="disabled()"
|
||||
[readonly]="readonly()"
|
||||
[rows]="rows()"
|
||||
[attr.maxlength]="maxlength()"
|
||||
(input)="onInput($event)"
|
||||
(blur)="onTouch()"
|
||||
>{{ value() }}</textarea>
|
||||
`,
|
||||
styles: `
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
.mra-textarea {
|
||||
width: 100%;
|
||||
padding: var(--mra-control-padding-y) var(--mra-control-padding-x);
|
||||
border: 1px solid var(--mra-border-2);
|
||||
background: var(--mra-surface);
|
||||
color: var(--mra-text);
|
||||
font-family: var(--mra-font);
|
||||
font-size: var(--mra-font-size);
|
||||
border-radius: var(--mra-radius);
|
||||
resize: vertical;
|
||||
min-height: 60px;
|
||||
}
|
||||
.mra-textarea::placeholder {
|
||||
color: var(--mra-muted);
|
||||
}
|
||||
.mra-textarea:focus {
|
||||
border-color: var(--mra-accent);
|
||||
box-shadow: var(--mra-focus-ring);
|
||||
outline: none;
|
||||
}
|
||||
.mra-textarea:disabled {
|
||||
background: var(--mra-surface-alt);
|
||||
cursor: not-allowed;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.mra-textarea--invalid {
|
||||
border-color: var(--mra-bad);
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class MraTextarea implements ControlValueAccessor {
|
||||
readonly id = input<string>('');
|
||||
readonly placeholder = input<string>('');
|
||||
readonly readonly = input<boolean>(false);
|
||||
readonly rows = input<number>(3);
|
||||
readonly maxlength = input<number | null>(null);
|
||||
readonly value = signal('');
|
||||
readonly disabled = signal(false);
|
||||
readonly invalid = input<boolean>(false);
|
||||
private onChange: (v: string | null) => void = () => {};
|
||||
onTouch = () => {};
|
||||
|
||||
onInput(event: Event) {
|
||||
const v = (event.target as HTMLTextAreaElement).value;
|
||||
this.value.set(v);
|
||||
this.onChange(v);
|
||||
}
|
||||
writeValue(obj: unknown): void {
|
||||
this.value.set(obj == null ? '' : String(obj));
|
||||
}
|
||||
registerOnChange(fn: (v: string | null) => void): void {
|
||||
this.onChange = fn;
|
||||
}
|
||||
registerOnTouched(fn: () => void): void {
|
||||
this.onTouch = fn;
|
||||
}
|
||||
setDisabledState(isDisabled: boolean): void {
|
||||
this.disabled.set(isDisabled);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { MraValidationMessage } from '@mrarm/ui';
|
||||
|
||||
const meta: Meta<MraValidationMessage> = {
|
||||
title: 'Forms/Validation Message',
|
||||
component: MraValidationMessage,
|
||||
tags: ['autodocs'],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
export const Shown: StoryObj<MraValidationMessage> = {
|
||||
render: () => ({ template: `<mra-validation-message [show]="true">This field is required.</mra-validation-message>` }),
|
||||
};
|
||||
|
||||
export const Hidden: StoryObj<MraValidationMessage> = {
|
||||
render: () => ({ template: `<mra-validation-message [show]="false">This field is required.</mra-validation-message>` }),
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user