This commit is contained in:
Jordan
2026-06-17 04:54:11 +00:00
commit 1517667ba2
176 changed files with 28025 additions and 0 deletions

17
.editorconfig Normal file
View 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
View 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
View File

@@ -0,0 +1,12 @@
{
"printWidth": 100,
"singleQuote": true,
"overrides": [
{
"files": "*.html",
"options": {
"parser": "angular"
}
}
]
}

11
.storybook/main.ts Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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 Storybooks 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
View 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
View 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
View 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
View 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] SB_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

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

File diff suppressed because it is too large Load Diff

38
package.json Normal file
View 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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View 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)],
};

View 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>

View 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' },
];

View 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);
}

View 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');
});
});

View 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' },
];
}

View 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' },
];
}

View 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);
}

View 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');
}
}

View 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;
}
}

View 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>[]);
}

View 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"
}`;
}

View 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}`);
}
}

View 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);
}

View 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);
}
}

View 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:0012: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',
});
}

View 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>

View 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));

View File

@@ -0,0 +1 @@
/* You can add global styles to this file, and also import other style files */

View 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"]
}

View 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"]
}

View 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.

View 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"]
}

View 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/*"
}
}

View 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,
}),
};

View 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);
}

View 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');
});
});

View 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>` }),
};

View 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);
}

View File

@@ -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);
});
});

View File

@@ -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>`,
}),
};

View 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();
}
}

View 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">&#9881;</mra-icon-button>` }),
};
export const Disabled: StoryObj<MraIconButton> = {
render: () => ({ template: `<mra-icon-button label="Settings" [disabled]="true">&#9881;</mra-icon-button>` }),
};

View 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);
}

View 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';

View 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>` }),
};

View 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);
}

View 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 },
}),
};

View 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">&#9662;</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);
}
}

View 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 } }),
};

View 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[]>([]);
}

View 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 } }),
};

View 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[]>([]);
}

View 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();
});
});

View 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>` }),
};

View 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);
}
}

View 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';

View 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();
});
});

View 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>`,
}),
};

View 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>();
}

View 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 }] },
}),
};

View 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);
}
}

View 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();
});
});

View 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 } }),
};

View 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';
}
}

View 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');
});
});

View 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 }),
};

View 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);
}
}

View 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();
});
});

View 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,
}),
};

View 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 });
}

View 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,
}),
};

View 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);
}

View 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';

View 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');
});
});

View 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 }),
};

View 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);
}
}

View 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 }),
};

View 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"
>&#8722;</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);
}
}

View 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 }),
};

View 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);
}
}

View 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 }),
};

View 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);
}
}

View 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 } }),
};

View 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);
}
}

View 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 }),
};

View 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);
}
}

View 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 }),
};

View 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);
}
}

View 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 }),
};

View 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);
}
}

View File

@@ -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