feat: Base set of UI components

This commit is contained in:
Matthew McPeak 2026-06-08 15:05:42 -04:00
parent a4ec81967f
commit ddfc01f917
Signed by: McPeakML
GPG key ID: 3D64A2E70F58D07C
20 changed files with 1776 additions and 17 deletions

7
.gitignore vendored
View file

@ -46,3 +46,10 @@ packages/*/storybook-static
# Storybook # Storybook
.storybook/node_modules .storybook/node_modules
node_modules/.cache node_modules/.cache
# Local browser-verification tooling (Playwright + extracted libs + screenshots)
.tmp/
.bun-cache/
.pw-browsers/
.pw-libs/
.shots/

View file

@ -22,6 +22,7 @@
"jsdom": "^29.1.1", "jsdom": "^29.1.1",
"npm-run-all2": "^8.0.4", "npm-run-all2": "^8.0.4",
"oxlint": "~1.60.0", "oxlint": "~1.60.0",
"playwright": "^1.60.0",
"prettier": "3.8.3", "prettier": "3.8.3",
"primevue": "^4.5.5", "primevue": "^4.5.5",
"typescript": "~6.0.3", "typescript": "~6.0.3",
@ -797,7 +798,7 @@
"foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
@ -1051,6 +1052,10 @@
"pkg-types": ["pkg-types@2.3.1", "", { "dependencies": { "confbox": "^0.2.4", "exsolve": "^1.0.8", "pathe": "^2.0.3" } }, "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg=="], "pkg-types": ["pkg-types@2.3.1", "", { "dependencies": { "confbox": "^0.2.4", "exsolve": "^1.0.8", "pathe": "^2.0.3" } }, "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg=="],
"playwright": ["playwright@1.60.0", "", { "dependencies": { "playwright-core": "1.60.0" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA=="],
"playwright-core": ["playwright-core@1.60.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA=="],
"possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="],
"postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="], "postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="],
@ -1413,6 +1418,8 @@
"strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], "strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
"vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"vue/@vue/compiler-dom": ["@vue/compiler-dom@3.5.35", "", { "dependencies": { "@vue/compiler-core": "3.5.35", "@vue/shared": "3.5.35" } }, "sha512-k+bprkXxuqhVajgTx5mUHuir7TwQzUKOWR40ng1ncAqQRPnrLngGGgqVEEhOnTMlc8btHYVKmrP8s5Qyg0hvYA=="], "vue/@vue/compiler-dom": ["@vue/compiler-dom@3.5.35", "", { "dependencies": { "@vue/compiler-core": "3.5.35", "@vue/shared": "3.5.35" } }, "sha512-k+bprkXxuqhVajgTx5mUHuir7TwQzUKOWR40ng1ncAqQRPnrLngGGgqVEEhOnTMlc8btHYVKmrP8s5Qyg0hvYA=="],
"vue/@vue/server-renderer": ["@vue/server-renderer@3.5.35", "", { "dependencies": { "@vue/compiler-ssr": "3.5.35", "@vue/shared": "3.5.35" }, "peerDependencies": { "vue": "3.5.35" } }, "sha512-NkebSOYdB97wi8OQcO3HqzZSlymJi/aWsN/7h74OSVhRTm6qGs3Jp3e0rCXynmWwSlKeRrnlIug+ilYoHBmQDA=="], "vue/@vue/server-renderer": ["@vue/server-renderer@3.5.35", "", { "dependencies": { "@vue/compiler-ssr": "3.5.35", "@vue/shared": "3.5.35" }, "peerDependencies": { "vue": "3.5.35" } }, "sha512-NkebSOYdB97wi8OQcO3HqzZSlymJi/aWsN/7h74OSVhRTm6qGs3Jp3e0rCXynmWwSlKeRrnlIug+ilYoHBmQDA=="],

View file

@ -6,9 +6,8 @@
"@types/jsdom": "^28.0.3", "@types/jsdom": "^28.0.3",
"@types/node": "^25.9.1", "@types/node": "^25.9.1",
"@vitejs/plugin-vue": "^6.0.7", "@vitejs/plugin-vue": "^6.0.7",
"primevue": "^4.5.5",
"@vitest/eslint-plugin": "^1.6.19",
"@vitest/coverage-v8": "^4.1.8", "@vitest/coverage-v8": "^4.1.8",
"@vitest/eslint-plugin": "^1.6.19",
"@vue/eslint-config-typescript": "^14.7.0", "@vue/eslint-config-typescript": "^14.7.0",
"@vue/test-utils": "^2.4.10", "@vue/test-utils": "^2.4.10",
"@vue/tsconfig": "^0.9.1", "@vue/tsconfig": "^0.9.1",
@ -20,7 +19,9 @@
"jsdom": "^29.1.1", "jsdom": "^29.1.1",
"npm-run-all2": "^8.0.4", "npm-run-all2": "^8.0.4",
"oxlint": "~1.60.0", "oxlint": "~1.60.0",
"playwright": "^1.60.0",
"prettier": "3.8.3", "prettier": "3.8.3",
"primevue": "^4.5.5",
"typescript": "~6.0.3", "typescript": "~6.0.3",
"vite": "^8.0.16", "vite": "^8.0.16",
"vite-plugin-vue-devtools": "^8.1.2", "vite-plugin-vue-devtools": "^8.1.2",

View file

@ -63,7 +63,6 @@
color: var(--danger-fg); color: var(--danger-fg);
} }
.nych-loading-icon .wreath path { .nych-loading-icon .wreath path {
fill: var(--primary-fg); fill: var(--primary-fg);
} }
@ -93,3 +92,636 @@ button[data-p~='loading'] > .nych-loading-icon {
height: 40px !important; height: 40px !important;
font-size: 20pt !important; font-size: 20pt !important;
} }
/* ============================================================
Text inputs InputText, Textarea
============================================================ */
.nych-input,
.nych-textarea {
font-family: var(--font-sans);
width: 100%;
padding: 8px 11px;
color: var(--text-body);
background-color: var(--surface-1);
border: 1px solid var(--border);
border-radius: 5px;
transition:
border-color 0.2s ease,
box-shadow 0.2s ease;
}
.nych-input::placeholder,
.nych-textarea::placeholder {
color: var(--text-dim);
}
.nych-input:hover:not(:disabled),
.nych-textarea:hover:not(:disabled) {
border-color: var(--border-hi);
}
.nych-input:focus,
.nych-textarea:focus {
outline: none;
border-color: var(--primary);
box-shadow: var(--focus-ring);
}
.nych-input[data-p~='invalid'],
.nych-textarea[data-p~='invalid'] {
border-color: var(--danger);
}
.nych-input[data-p~='small'] {
padding: 5px 8px;
font-size: 0.875rem;
}
.nych-input[data-p~='large'] {
padding: 11px 13px;
font-size: 1.125rem;
}
.nych-textarea {
resize: vertical;
min-height: 84px;
line-height: 1.5;
}
/* ============================================================
Checkbox
============================================================ */
.nych-checkbox,
.nych-radio {
position: relative;
display: inline-flex;
width: 20px;
height: 20px;
vertical-align: middle;
}
.nych-checkbox-input,
.nych-radio-input {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
margin: 0;
opacity: 0;
cursor: pointer;
z-index: 1;
}
.nych-checkbox-box,
.nych-radio-box {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
background-color: var(--surface-1);
border: 1px solid var(--border);
transition:
background-color 0.2s ease,
border-color 0.2s ease;
}
.nych-checkbox-box {
border-radius: 4px;
}
.nych-radio-box {
border-radius: 50%;
}
.nych-checkbox:hover .nych-checkbox-box,
.nych-radio:hover .nych-radio-box {
border-color: var(--border-hi);
}
.nych-checkbox[data-p-checked='true'] .nych-checkbox-box {
background-color: var(--primary);
border-color: var(--primary);
}
.nych-checkbox-icon {
width: 13px;
height: 13px;
color: var(--primary-fg);
fill: var(--primary-fg);
stroke: var(--primary-fg);
}
.nych-checkbox[data-p-disabled='true'] .nych-checkbox-box,
.nych-radio[data-p-disabled='true'] .nych-radio-box {
opacity: 0.5;
}
/* Group containers (CheckboxGroup / RadioButtonGroup) — layout only. */
.nych-checkbox-group,
.nych-radio-group {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.nych-checkbox-input:focus-visible + .nych-checkbox-box,
.nych-radio-input:focus-visible + .nych-radio-box {
box-shadow: var(--focus-ring);
}
/* Keep the visually-hidden native control hidden even when disabled the
global [class^='nych-']:disabled opacity would otherwise reveal it. */
.nych-checkbox .nych-checkbox-input,
.nych-radio .nych-radio-input,
.nych-toggleswitch .nych-toggleswitch-input {
opacity: 0;
}
/* ============================================================
Radio button
============================================================ */
.nych-radio-icon {
width: 9px;
height: 9px;
border-radius: 50%;
background-color: transparent;
transition: background-color 0.2s ease;
}
.nych-radio[data-p-checked='true'] .nych-radio-box {
border-color: var(--primary);
}
.nych-radio[data-p-checked='true'] .nych-radio-icon {
background-color: var(--primary);
}
/* ============================================================
Toggle switch
============================================================ */
.nych-toggleswitch {
position: relative;
display: inline-block;
width: 44px;
height: 24px;
vertical-align: middle;
}
.nych-toggleswitch-input {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
margin: 0;
opacity: 0;
cursor: pointer;
z-index: 1;
}
.nych-toggleswitch-slider {
position: absolute;
inset: 0;
background-color: var(--track);
border: 1px solid var(--border);
border-radius: 999px;
transition:
background-color 0.2s ease,
border-color 0.2s ease;
}
.nych-toggleswitch-handle {
position: absolute;
top: 50%;
left: 3px;
width: 16px;
height: 16px;
transform: translateY(-50%);
background-color: var(--text-muted);
border-radius: 50%;
transition:
left 0.2s ease,
background-color 0.2s ease;
}
.nych-toggleswitch[data-p-checked='true'] .nych-toggleswitch-slider {
background-color: var(--primary);
border-color: var(--primary);
}
.nych-toggleswitch[data-p-checked='true'] .nych-toggleswitch-handle {
left: 25px;
background-color: var(--primary-fg);
}
.nych-toggleswitch-input:focus-visible + .nych-toggleswitch-slider {
box-shadow: var(--focus-ring);
}
.nych-toggleswitch[data-p-disabled='true'] .nych-toggleswitch-slider,
.nych-toggleswitch[data-p-disabled='true'] .nych-toggleswitch-handle {
opacity: 0.5;
}
/* ============================================================
Select
============================================================ */
.nych-select {
display: inline-flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-width: 13rem;
font-family: var(--font-sans);
padding: 8px 11px;
color: var(--text-body);
background-color: var(--surface-1);
border: 1px solid var(--border);
border-radius: 5px;
cursor: pointer;
transition:
border-color 0.2s ease,
box-shadow 0.2s ease;
}
.nych-select:hover {
border-color: var(--border-hi);
}
.nych-select[data-p~='focus'] {
border-color: var(--primary);
box-shadow: var(--focus-ring);
}
.nych-select[data-p~='disabled'] {
cursor: not-allowed;
opacity: 0.5;
}
.nych-select-label {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.nych-select-label[data-p~='placeholder'] {
color: var(--text-dim);
}
.nych-select-dropdown {
display: flex;
align-items: center;
color: var(--text-muted);
}
.nych-select-dropdownIcon {
width: 14px;
height: 14px;
}
.nych-select-overlay {
font-family: var(--font-sans);
margin-top: 4px;
background-color: var(--surface-2);
border: 1px solid var(--border);
border-radius: 6px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
overflow: hidden;
}
.nych-select-list {
list-style: none;
margin: 0;
padding: 4px;
max-height: 14rem;
overflow-y: auto;
}
.nych-select-option {
padding: 8px 11px;
border-radius: 4px;
color: var(--text-body);
cursor: pointer;
transition: background-color 0.15s ease;
}
.nych-select-option[data-p-focused='true'],
.nych-select-option:hover {
background-color: var(--surface-3);
}
.nych-select-option[data-p-selected='true'] {
background-color: var(--primary);
color: var(--primary-fg);
}
.nych-select-emptyMessage {
padding: 8px 11px;
color: var(--text-dim);
}
/* ============================================================
Card
============================================================ */
.nych-card {
display: flex;
flex-direction: column;
background-color: var(--surface-1);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.18);
overflow: hidden;
color: var(--text-body);
}
.nych-card-header :where(img) {
display: block;
width: 100%;
}
.nych-card-body {
display: flex;
flex-direction: column;
gap: 12px;
padding: 20px;
}
.nych-card-caption {
display: flex;
flex-direction: column;
gap: 4px;
}
.nych-card-title {
font-family: var(--font-serif);
font-size: 1.4rem;
letter-spacing: 0.02em;
color: var(--text-high);
}
.nych-card-subtitle {
font-family: var(--font-sans);
font-size: 0.95rem;
color: var(--text-muted);
}
.nych-card-content {
font-family: var(--font-sans);
line-height: 1.6;
}
.nych-card-footer {
display: flex;
gap: 8px;
padding-top: 4px;
}
/* ============================================================
Message
============================================================ */
.nych-message {
color: var(--text-body);
background-color: var(--surface-1);
border: 1px solid var(--border);
border-left: 3px solid var(--border);
border-radius: 6px;
}
.nych-message-content {
display: flex;
align-items: center;
gap: 10px;
padding: 11px 14px;
}
.nych-message-icon {
display: flex;
flex-shrink: 0;
width: 18px;
height: 18px;
}
.nych-message-text {
font-family: var(--font-sans);
line-height: 1.4;
}
.nych-message-closeButton {
display: flex;
align-items: center;
justify-content: center;
margin-left: auto;
width: 22px;
height: 22px;
padding: 0;
color: inherit;
background: transparent;
border: none;
border-radius: 4px;
cursor: pointer;
opacity: 0.7;
transition:
opacity 0.2s ease,
background-color 0.2s ease;
}
.nych-message-closeButton:hover {
opacity: 1;
background-color: rgba(128, 128, 128, 0.18);
}
.nych-message-closeIcon {
width: 12px;
height: 12px;
}
/* Severity tints are mixed from the semantic colour into the surface so they
read consistently in both the dark (Hades) and light (Apollo) themes,
rather than relying on the very-dark *-subtle tokens that muddied Hades. */
.nych-message[data-p~='info'] {
background-color: color-mix(in srgb, var(--info) 15%, var(--surface-1));
border-color: color-mix(in srgb, var(--info) 30%, var(--border));
border-left-color: var(--info);
}
.nych-message[data-p~='info'] .nych-message-icon {
color: var(--info);
}
.nych-message[data-p~='success'] {
background-color: color-mix(in srgb, var(--success) 15%, var(--surface-1));
border-color: color-mix(in srgb, var(--success) 30%, var(--border));
border-left-color: var(--success);
}
.nych-message[data-p~='success'] .nych-message-icon {
color: var(--success);
}
.nych-message[data-p~='warn'] {
background-color: color-mix(in srgb, var(--warn) 16%, var(--surface-1));
border-color: color-mix(in srgb, var(--warn) 32%, var(--border));
border-left-color: var(--warn);
}
.nych-message[data-p~='warn'] .nych-message-icon {
color: var(--warn);
}
.nych-message[data-p~='error'] {
background-color: color-mix(in srgb, var(--danger) 20%, var(--surface-1));
border-color: color-mix(in srgb, var(--danger) 38%, var(--border));
border-left-color: var(--danger);
}
.nych-message[data-p~='error'] .nych-message-icon {
color: var(--danger);
}
.nych-message[data-p~='secondary'] {
background-color: var(--surface-2);
border-left-color: var(--neutral);
}
/* ============================================================
Tag
============================================================ */
.nych-tag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
font-size: 0.8rem;
font-weight: 600;
letter-spacing: 0.03em;
line-height: 1;
border-radius: 4px;
background-color: var(--primary);
color: var(--primary-fg);
}
.nych-tag[data-p~='rounded'] {
border-radius: 999px;
}
.nych-tag-icon {
width: 12px;
height: 12px;
}
.nych-tag[data-p~='secondary'] {
background-color: var(--neutral);
color: var(--neutral-fg);
}
.nych-tag[data-p~='info'] {
background-color: var(--info);
color: var(--info-fg);
}
.nych-tag[data-p~='success'] {
background-color: var(--success);
color: var(--success-fg);
}
.nych-tag[data-p~='warn'] {
background-color: var(--warn);
color: var(--warn-fg);
}
.nych-tag[data-p~='danger'] {
background-color: var(--danger);
color: var(--danger-fg);
}
.nych-tag[data-p~='contrast'] {
background-color: var(--text-high);
color: var(--surface-0);
}
/* ============================================================
Dialog
============================================================ */
.nych-dialog-mask {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
background-color: rgba(0, 0, 0, 0.55);
}
.nych-dialog {
display: flex;
flex-direction: column;
max-width: 92vw;
max-height: 90vh;
color: var(--text-body);
background-color: var(--surface-1);
border: 1px solid var(--border);
border-radius: 10px;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5);
overflow: hidden;
}
.nych-dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 16px 20px;
border-bottom: 1px solid var(--border-lo);
}
.nych-dialog-title {
font-family: var(--font-serif);
font-size: 1.3rem;
letter-spacing: 0.02em;
color: var(--text-high);
}
.nych-dialog-headerActions {
display: flex;
align-items: center;
gap: 4px;
}
.nych-dialog-headerActions button {
display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
padding: 0;
color: var(--text-muted);
background: transparent;
border: none;
border-radius: 6px;
cursor: pointer;
transition:
background-color 0.2s ease,
color 0.2s ease;
}
.nych-dialog-headerActions button:hover {
color: var(--text-high);
background-color: var(--surface-3);
}
.nych-dialog-headerActions button svg {
width: 14px;
height: 14px;
}
.nych-dialog-content {
font-family: var(--font-sans);
padding: 20px;
line-height: 1.6;
overflow-y: auto;
}
.nych-dialog-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 16px 20px;
border-top: 1px solid var(--border-lo);
}

View file

@ -63,14 +63,22 @@ export class ThemeEngine {
const { component, classes, styles, injectionKeys, propMutator, unstyled, slots } = const { component, classes, styles, injectionKeys, propMutator, unstyled, slots } =
this.lib.components[key] this.lib.components[key]
const componentObj = { ...(component as Record<string, unknown>) } // Build a MINIMAL pass-through wrapper rather than cloning the wrapped
// component. Cloning dragged along the component's `props`, `emits` and
componentObj.props = { // `extends` chain — which made Vue treat listeners like `onUpdate:modelValue`
...((componentObj.props as Record<string, unknown>) || {}), // as the wrapper's own declared events and withhold them from `ctx.attrs`,
injectedClasses: { default: classes }, // silently breaking two-way `v-model`. By declaring only the engine's own
injectedStyles: { default: styles }, // props here, every other prop, listener and attribute falls through
stylePlug: { type: String }, // `ctx.attrs` and is forwarded verbatim to the real component below.
unstyled: { default: unstyled }, const componentObj: Record<string, unknown> = {
name: `Nych${String(key)}`,
inheritAttrs: false,
props: {
injectedClasses: { default: classes },
injectedStyles: { default: styles },
stylePlug: { type: String },
unstyled: { default: unstyled },
},
} }
const applyThemeProps = (props: Record<string, unknown>) => { const applyThemeProps = (props: Record<string, unknown>) => {

View file

@ -6,4 +6,20 @@ export {
type Key, type Key,
type Props, type Props,
} from './engine' } from './engine'
export { createNychthemeron, Button, LoadingIcon } from './lib' export {
createNychthemeron,
Button,
LoadingIcon,
InputText,
Textarea,
Checkbox,
CheckboxGroup,
RadioButton,
RadioButtonGroup,
ToggleSwitch,
Select,
Card,
Dialog,
Message,
Tag,
} from './lib'

View file

@ -1,8 +1,56 @@
import type { App } from 'vue' import type { App } from 'vue'
import { Button as PrimeButton } from 'primevue' import {
import { ThemeLibrary, type Themeable, ThemeEngine } from '../engine' Button as PrimeButton,
InputText as PrimeInputText,
Textarea as PrimeTextarea,
Checkbox as PrimeCheckbox,
CheckboxGroup as PrimeCheckboxGroup,
RadioButton as PrimeRadioButton,
RadioButtonGroup as PrimeRadioButtonGroup,
ToggleSwitch as PrimeToggleSwitch,
Select as PrimeSelect,
Card as PrimeCard,
Dialog as PrimeDialog,
Message as PrimeMessage,
Tag as PrimeTag,
} from 'primevue'
import { ThemeLibrary, type Themeable, ThemeEngine, type LibComponent, type Key } from '../engine'
import { NychLoadingIcon } from '../components' import { NychLoadingIcon } from '../components'
/**
* Build a Themeable that themes a PrimeVue component through its passthrough (`pt`) API.
*
* The engine injects the base class (`nych-<base>`) onto `pt.root`. This factory layers a
* `nych-<base>-<section>` class onto every additional section so multi-part components
* (Checkbox box, Select overlay, Card body, ) can be fully styled in unstyled mode, where
* PrimeVue emits no classes of its own.
*/
const ptThemeable = <T extends LibComponent>(
component: T,
base: string,
sections: string[] = [],
): Themeable<T> => ({
component,
unstyled: true as Themeable<T>['unstyled'],
classes: [`nych-${base}`],
injectionKeys: { class: 'pt' as Key<T> },
propMutator: ((props: Record<string, unknown>) => {
const pt = (props.pt as Record<string, { class?: string }>) ?? {}
for (const section of sections) {
const existing = pt[section]?.class
pt[section] = {
...(pt[section] ?? {}),
class: [existing, `nych-${base}-${section}`].filter(Boolean).join(' '),
}
}
props.pt = pt
return props
}) as Themeable<T>['propMutator'],
})
const buttonThemeable: Themeable<typeof PrimeButton> = { const buttonThemeable: Themeable<typeof PrimeButton> = {
component: PrimeButton, component: PrimeButton,
unstyled: true, unstyled: true,
@ -42,6 +90,51 @@ const nychthemeron: ThemeLibrary = {
components: { components: {
Button: buttonThemeable, Button: buttonThemeable,
LoadingIcon: loadingIconThemeable, LoadingIcon: loadingIconThemeable,
InputText: ptThemeable(PrimeInputText, 'input'),
Textarea: ptThemeable(PrimeTextarea, 'textarea'),
Checkbox: ptThemeable(PrimeCheckbox, 'checkbox', ['box', 'input', 'icon']),
CheckboxGroup: ptThemeable(PrimeCheckboxGroup, 'checkbox-group'),
RadioButton: ptThemeable(PrimeRadioButton, 'radio', ['box', 'input', 'icon']),
RadioButtonGroup: ptThemeable(PrimeRadioButtonGroup, 'radio-group'),
ToggleSwitch: ptThemeable(PrimeToggleSwitch, 'toggleswitch', ['input', 'slider', 'handle']),
Select: ptThemeable(PrimeSelect, 'select', [
'label',
'dropdown',
'dropdownIcon',
'overlay',
'header',
'listContainer',
'list',
'option',
'optionLabel',
'emptyMessage',
]),
Card: ptThemeable(PrimeCard, 'card', [
'header',
'body',
'caption',
'title',
'subtitle',
'content',
'footer',
]),
Dialog: ptThemeable(PrimeDialog, 'dialog', [
'mask',
'header',
'title',
'headerActions',
'content',
'footer',
]),
Message: ptThemeable(PrimeMessage, 'message', [
'contentWrapper',
'content',
'icon',
'text',
'closeButton',
'closeIcon',
]),
Tag: ptThemeable(PrimeTag, 'tag', ['label', 'icon']),
}, },
} }
@ -49,16 +142,69 @@ const _engine = new ThemeEngine(nychthemeron)
export const Button: typeof PrimeButton = _engine.getComponent<typeof PrimeButton>('Button') export const Button: typeof PrimeButton = _engine.getComponent<typeof PrimeButton>('Button')
export const LoadingIcon = _engine.getComponent<typeof NychLoadingIcon>('LoadingIcon') export const LoadingIcon = _engine.getComponent<typeof NychLoadingIcon>('LoadingIcon')
export const InputText: typeof PrimeInputText =
_engine.getComponent<typeof PrimeInputText>('InputText')
export const Textarea: typeof PrimeTextarea = _engine.getComponent<typeof PrimeTextarea>('Textarea')
export const Checkbox: typeof PrimeCheckbox = _engine.getComponent<typeof PrimeCheckbox>('Checkbox')
export const CheckboxGroup: typeof PrimeCheckboxGroup =
_engine.getComponent<typeof PrimeCheckboxGroup>('CheckboxGroup')
export const RadioButton: typeof PrimeRadioButton =
_engine.getComponent<typeof PrimeRadioButton>('RadioButton')
export const RadioButtonGroup: typeof PrimeRadioButtonGroup =
_engine.getComponent<typeof PrimeRadioButtonGroup>('RadioButtonGroup')
export const ToggleSwitch: typeof PrimeToggleSwitch =
_engine.getComponent<typeof PrimeToggleSwitch>('ToggleSwitch')
export const Select: typeof PrimeSelect = _engine.getComponent<typeof PrimeSelect>('Select')
export const Card: typeof PrimeCard = _engine.getComponent<typeof PrimeCard>('Card')
export const Dialog: typeof PrimeDialog = _engine.getComponent<typeof PrimeDialog>('Dialog')
export const Message: typeof PrimeMessage = _engine.getComponent<typeof PrimeMessage>('Message')
export const Tag: typeof PrimeTag = _engine.getComponent<typeof PrimeTag>('Tag')
export const createNychthemeron = (): { export const createNychthemeron = (): {
Button: typeof PrimeButton Button: typeof PrimeButton
LoadingIcon: typeof NychLoadingIcon LoadingIcon: typeof NychLoadingIcon
InputText: typeof PrimeInputText
Textarea: typeof PrimeTextarea
Checkbox: typeof PrimeCheckbox
CheckboxGroup: typeof PrimeCheckboxGroup
RadioButton: typeof PrimeRadioButton
RadioButtonGroup: typeof PrimeRadioButtonGroup
ToggleSwitch: typeof PrimeToggleSwitch
Select: typeof PrimeSelect
Card: typeof PrimeCard
Dialog: typeof PrimeDialog
Message: typeof PrimeMessage
Tag: typeof PrimeTag
install: (app: App) => void install: (app: App) => void
} => ({ } => ({
Button, Button,
LoadingIcon, LoadingIcon,
InputText,
Textarea,
Checkbox,
CheckboxGroup,
RadioButton,
RadioButtonGroup,
ToggleSwitch,
Select,
Card,
Dialog,
Message,
Tag,
install(app: App) { install(app: App) {
app.component('NychButton', Button) app.component('NychButton', Button)
app.component('NychLoadingIcon', LoadingIcon) app.component('NychLoadingIcon', LoadingIcon)
app.component('NychInputText', InputText)
app.component('NychTextarea', Textarea)
app.component('NychCheckbox', Checkbox)
app.component('NychCheckboxGroup', CheckboxGroup)
app.component('NychRadioButton', RadioButton)
app.component('NychRadioButtonGroup', RadioButtonGroup)
app.component('NychToggleSwitch', ToggleSwitch)
app.component('NychSelect', Select)
app.component('NychCard', Card)
app.component('NychDialog', Dialog)
app.component('NychMessage', Message)
app.component('NychTag', Tag)
}, },
}) })

View file

@ -1,6 +1,7 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import Button from 'primevue/button' import Button from 'primevue/button'
import { h } from 'vue' import { h, ref } from 'vue'
import { ThemeEngine, ThemeLibrary, type Themeable } from '../src/engine' import { ThemeEngine, ThemeLibrary, type Themeable } from '../src/engine'
@ -564,6 +565,40 @@ describe('ThemeEngine', () => {
expect(vnode).toBeDefined() expect(vnode).toBeDefined()
}) })
it('forwards update:modelValue so v-model works on wrapped components', async () => {
// Regression: a cloned wrapper inherited the component's emits/extends, so
// Vue withheld onUpdate:modelValue from ctx.attrs and the engine never
// forwarded it — breaking two-way v-model on every wrapped component.
const Emitter = {
name: 'Emitter',
props: { modelValue: { type: String, default: '' } },
emits: ['update:modelValue'],
//eslint-disable-next-line
setup(_props: Record<string, unknown>, { emit }: { emit: (e: string, v: string) => void }) {
return () => h('button', { onClick: () => emit('update:modelValue', 'changed') }, 'x')
},
}
const theme: ThemeLibrary = { components: { Emitter: { component: Emitter } } }
const engine = new ThemeEngine(theme)
const Wrapped = engine.getComponent('Emitter')
const Parent = {
components: { Wrapped },
setup() {
const val = ref('initial')
return { val }
},
template: `<div><Wrapped v-model="val" /><span class="out">{{ val }}</span></div>`,
}
const wrapper = mount(Parent)
expect(wrapper.find('.out').text()).toBe('initial')
await wrapper.find('button').trigger('click')
expect(wrapper.find('.out').text()).toBe('changed')
})
it('merges theme slots with original component slots', () => { it('merges theme slots with original component slots', () => {
const ThemeSlot = { const ThemeSlot = {
name: 'ThemeSlot', name: 'ThemeSlot',

View file

@ -1,6 +1,22 @@
import { describe, it, expect, vi } from 'vitest' import { describe, it, expect, vi } from 'vitest'
import type { App } from 'vue' import type { App } from 'vue'
import { createNychthemeron, Button, LoadingIcon } from '../src/index' import {
createNychthemeron,
Button,
LoadingIcon,
InputText,
Textarea,
Checkbox,
RadioButton,
CheckboxGroup,
RadioButtonGroup,
ToggleSwitch,
Select,
Card,
Dialog,
Message,
Tag,
} from '../src/index'
//eslint-disable-next-line //eslint-disable-next-line
const callSetup = (component: any, props: Record<string, unknown>) => { const callSetup = (component: any, props: Record<string, unknown>) => {
@ -92,3 +108,50 @@ describe('LoadingIcon', () => {
expect(LoadingIcon).toBeDefined() expect(LoadingIcon).toBeDefined()
}) })
}) })
describe('themed pt components', () => {
const cases = [
{ name: 'InputText', component: InputText, base: 'nych-input' },
{ name: 'Textarea', component: Textarea, base: 'nych-textarea' },
{ name: 'Checkbox', component: Checkbox, base: 'nych-checkbox' },
{ name: 'CheckboxGroup', component: CheckboxGroup, base: 'nych-checkbox-group' },
{ name: 'RadioButton', component: RadioButton, base: 'nych-radio' },
{ name: 'RadioButtonGroup', component: RadioButtonGroup, base: 'nych-radio-group' },
{ name: 'ToggleSwitch', component: ToggleSwitch, base: 'nych-toggleswitch' },
{ name: 'Select', component: Select, base: 'nych-select' },
{ name: 'Card', component: Card, base: 'nych-card' },
{ name: 'Dialog', component: Dialog, base: 'nych-dialog' },
{ name: 'Message', component: Message, base: 'nych-message' },
{ name: 'Tag', component: Tag, base: 'nych-tag' },
]
it.each(cases)('$name is a valid Vue component', ({ component }) => {
expect(component).toBeDefined()
expect(typeof (component as Record<string, unknown>).setup).toBe('function')
})
it.each(cases)('$name injects its base class onto pt.root', ({ component, base }) => {
const result = callSetup(component, { pt: {} })
expect(result.pt?.root?.class).toContain(base)
})
it.each(cases)('$name does not leak engine internal props', ({ component }) => {
const result = callSetup(component, { pt: {} })
expect(result).not.toHaveProperty('injectedClasses')
expect(result).not.toHaveProperty('injectedStyles')
expect(result).not.toHaveProperty('stylePlug')
})
it('Checkbox themes its box, input and icon sections', () => {
const result = callSetup(Checkbox, { pt: {} })
expect(result.pt?.box?.class).toContain('nych-checkbox-box')
expect(result.pt?.input?.class).toContain('nych-checkbox-input')
expect(result.pt?.icon?.class).toContain('nych-checkbox-icon')
})
it('Select themes its overlay and option sections', () => {
const result = callSetup(Select, { pt: {} })
expect(result.pt?.overlay?.class).toContain('nych-select-overlay')
expect(result.pt?.option?.class).toContain('nych-select-option')
})
})

View file

@ -1,6 +1,12 @@
import type { Preview } from '@storybook/vue3-vite' import type { Preview } from '@storybook/vue3-vite'
import { setup } from '@storybook/vue3-vite'
import PrimeVue from 'primevue/config'
import '@nychthemeron/library/theme' import '@nychthemeron/library/theme'
setup((app) => {
app.use(PrimeVue, { unstyled: true })
})
const preview: Preview = { const preview: Preview = {
parameters: { parameters: {
layout: 'centered', layout: 'centered',

View file

@ -0,0 +1,73 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { Card as NychCard, Button as NychButton, Tag as NychTag } from '@nychthemeron/library'
const meta = {
title: 'Components/Card',
component: NychCard,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
} satisfies Meta<typeof NychCard>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: () => ({
components: { NychCard },
template: `
<NychCard style="width: 22rem;">
<template #title>Elysium</template>
<template #subtitle>The blessed fields</template>
<template #content>
A resting place reserved for the heroic and the virtuous, where the
air is mild and the laurel never wilts.
</template>
</NychCard>
`,
}),
}
export const WithFooter: Story = {
render: () => ({
components: { NychCard, NychButton },
template: `
<NychCard style="width: 22rem;">
<template #title>Passage of the Styx</template>
<template #subtitle>One obol required</template>
<template #content>
Charon ferries the souls of the dead across the river that divides
the world of the living from the world of the dead.
</template>
<template #footer>
<NychButton label="Pay the toll" />
<NychButton label="Turn back" severity="secondary" />
</template>
</NychCard>
`,
}),
}
export const WithHeaderAndTag: Story = {
render: () => ({
components: { NychCard, NychTag },
template: `
<NychCard style="width: 22rem;">
<template #header>
<div style="height: 120px; background: linear-gradient(135deg, var(--surface-2), var(--surface-3));"></div>
</template>
<template #title>
<span style="display: flex; align-items: center; gap: 0.5rem;">
Oracle of Delphi <NychTag value="Sacred" severity="info" />
</span>
</template>
<template #content>
Seekers travelled from across the Aegean to hear the Pythia speak the
will of Apollo in riddling verse.
</template>
</NychCard>
`,
}),
}

View file

@ -0,0 +1,111 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import { Checkbox as NychCheckbox, CheckboxGroup as NychCheckboxGroup } from '@nychthemeron/library'
const meta = {
title: 'Components/Checkbox',
component: NychCheckbox,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
argTypes: {
disabled: {
control: { type: 'boolean' },
description: 'Disable the checkbox',
},
},
} satisfies Meta<typeof NychCheckbox>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: () => ({
components: { NychCheckbox },
setup() {
const checked = ref(false)
return { checked }
},
template: `<NychCheckbox v-model="checked" binary />`,
}),
}
export const Checked: Story = {
render: () => ({
components: { NychCheckbox },
setup() {
const checked = ref(true)
return { checked }
},
template: `<NychCheckbox v-model="checked" binary />`,
}),
}
export const Disabled: Story = {
render: () => ({
components: { NychCheckbox },
setup() {
const checked = ref(true)
return { checked }
},
template: `
<div style="display: flex; gap: 1rem;">
<NychCheckbox :modelValue="false" binary disabled />
<NychCheckbox :modelValue="true" binary disabled />
</div>
`,
}),
}
export const WithLabels: Story = {
render: () => ({
components: { NychCheckbox },
setup() {
const offerings = ref(['honey'])
return { offerings }
},
template: `
<div style="display: flex; flex-direction: column; gap: 0.75rem; font-family: var(--font-sans); color: var(--text-body);">
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
<NychCheckbox v-model="offerings" value="honey" /> Honey
</label>
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
<NychCheckbox v-model="offerings" value="wine" /> Wine
</label>
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
<NychCheckbox v-model="offerings" value="laurel" /> Laurel
</label>
<p style="color: var(--text-muted); margin-top: 0.25rem;">Offerings: {{ offerings.join(', ') || 'none' }}</p>
</div>
`,
}),
}
// CheckboxGroup holds the array v-model and shares a name across children.
export const Group: Story = {
render: () => ({
components: { NychCheckbox, NychCheckboxGroup },
setup() {
const offerings = ref(['honey'])
return { offerings }
},
template: `
<div style="font-family: var(--font-sans); color: var(--text-body);">
<NychCheckboxGroup v-model="offerings" name="offerings">
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
<NychCheckbox value="honey" /> Honey
</label>
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
<NychCheckbox value="wine" /> Wine
</label>
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
<NychCheckbox value="laurel" /> Laurel
</label>
</NychCheckboxGroup>
<p style="color: var(--text-muted); margin-top: 0.75rem;">Offerings: {{ offerings.join(', ') || 'none' }}</p>
</div>
`,
}),
}

View file

@ -0,0 +1,79 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import { Dialog as NychDialog, Button as NychButton } from '@nychthemeron/library'
const meta = {
title: 'Components/Dialog',
component: NychDialog,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
} satisfies Meta<typeof NychDialog>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: () => ({
components: { NychDialog, NychButton },
setup() {
const visible = ref(false)
return { visible }
},
template: `
<div>
<NychButton label="Summon the Oracle" @click="visible = true" />
<NychDialog v-model:visible="visible" modal header="Oracle of Delphi" style="width: 28rem;">
<p style="margin: 0;">
Seekers travelled from across the Aegean to hear the Pythia speak the
will of Apollo in riddling verse. Approach, and ask your question.
</p>
<template #footer>
<NychButton label="Withdraw" severity="secondary" @click="visible = false" />
<NychButton label="Ask" @click="visible = false" />
</template>
</NychDialog>
</div>
`,
}),
}
// Rendered open by default so the dialog surface is visible in docs/snapshots.
export const Open: Story = {
render: () => ({
components: { NychDialog, NychButton },
setup() {
const visible = ref(true)
return { visible }
},
template: `
<NychDialog v-model:visible="visible" modal header="Passage of the Styx" style="width: 28rem;">
<p style="margin: 0;">
Charon ferries the souls of the dead across the river that divides the
world of the living from the world of the dead. One obol is required.
</p>
<template #footer>
<NychButton label="Turn back" severity="secondary" @click="visible = false" />
<NychButton label="Pay the toll" @click="visible = false" />
</template>
</NychDialog>
`,
}),
}
export const WithoutFooter: Story = {
render: () => ({
components: { NychDialog },
setup() {
const visible = ref(true)
return { visible }
},
template: `
<NychDialog v-model:visible="visible" modal header="An Omen" style="width: 26rem;">
<p style="margin: 0;">A crow has settled upon the western gate. Interpret it as you will.</p>
</NychDialog>
`,
}),
}

View file

@ -0,0 +1,93 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import { InputText as NychInputText } from '@nychthemeron/library'
const meta = {
title: 'Components/InputText',
component: NychInputText,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
argTypes: {
size: {
control: { type: 'select' },
options: ['small', undefined, 'large'],
description: 'Input size',
},
placeholder: {
control: { type: 'text' },
description: 'Placeholder text',
},
disabled: {
control: { type: 'boolean' },
description: 'Disable the input',
},
invalid: {
control: { type: 'boolean' },
description: 'Mark the input as invalid',
},
},
} satisfies Meta<typeof NychInputText>
export default meta
type Story = StoryObj<typeof meta>
const modelTemplate = (attrs: string) => ({
components: { NychInputText },
setup() {
const value = ref('')
return { value }
},
template: `<NychInputText v-model="value" ${attrs} />`,
})
export const Default: Story = {
render: () => modelTemplate('placeholder="Speak, traveller…"'),
}
export const Filled: Story = {
render: () => ({
components: { NychInputText },
setup() {
const value = ref('Charon')
return { value }
},
template: `<NychInputText v-model="value" />`,
}),
}
export const Invalid: Story = {
render: () => modelTemplate('placeholder="Required" invalid'),
}
export const Disabled: Story = {
render: () => ({
components: { NychInputText },
setup() {
const value = ref('Sealed')
return { value }
},
template: `<NychInputText v-model="value" disabled />`,
}),
}
export const Sizes: Story = {
render: () => ({
components: { NychInputText },
setup() {
const a = ref('')
const b = ref('')
const c = ref('')
return { a, b, c }
},
template: `
<div style="display: flex; flex-direction: column; gap: 1rem; width: 16rem;">
<NychInputText v-model="a" size="small" placeholder="Small" />
<NychInputText v-model="b" placeholder="Normal" />
<NychInputText v-model="c" size="large" placeholder="Large" />
</div>
`,
}),
}

View file

@ -0,0 +1,86 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { Message as NychMessage } from '@nychthemeron/library'
const meta = {
title: 'Components/Message',
component: NychMessage,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
argTypes: {
severity: {
control: { type: 'select' },
options: ['info', 'success', 'warn', 'error', 'secondary'],
description: 'Message severity',
},
closable: {
control: { type: 'boolean' },
description: 'Show a close button',
},
},
} satisfies Meta<typeof NychMessage>
export default meta
type Story = StoryObj<typeof meta>
export const Info: Story = {
args: { severity: 'info' },
render: (args) => ({
components: { NychMessage },
setup: () => ({ args }),
template: `<NychMessage v-bind="args" style="width: 24rem;">The Oracle has spoken.</NychMessage>`,
}),
}
export const Success: Story = {
args: { severity: 'success' },
render: (args) => ({
components: { NychMessage },
setup: () => ({ args }),
template: `<NychMessage v-bind="args" style="width: 24rem;">The offering was accepted.</NychMessage>`,
}),
}
export const Warn: Story = {
args: { severity: 'warn' },
render: (args) => ({
components: { NychMessage },
setup: () => ({ args }),
template: `<NychMessage v-bind="args" style="width: 24rem;">The ferryman grows impatient.</NychMessage>`,
}),
}
export const Error: Story = {
args: { severity: 'error' },
render: (args) => ({
components: { NychMessage },
setup: () => ({ args }),
template: `<NychMessage v-bind="args" style="width: 24rem;">The gods have refused your plea.</NychMessage>`,
}),
}
export const Closable: Story = {
args: { severity: 'info', closable: true },
render: (args) => ({
components: { NychMessage },
setup: () => ({ args }),
template: `<NychMessage v-bind="args" style="width: 24rem;">This omen may be dismissed.</NychMessage>`,
}),
}
export const AllSeverities: Story = {
render: () => ({
components: { NychMessage },
template: `
<div style="display: flex; flex-direction: column; gap: 0.75rem; width: 24rem;">
<NychMessage severity="info">The Oracle has spoken.</NychMessage>
<NychMessage severity="success">The offering was accepted.</NychMessage>
<NychMessage severity="warn">The ferryman grows impatient.</NychMessage>
<NychMessage severity="error">The gods have refused your plea.</NychMessage>
<NychMessage severity="secondary">A neutral notice from the keeper.</NychMessage>
</div>
`,
}),
}

View file

@ -0,0 +1,109 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import {
RadioButton as NychRadioButton,
RadioButtonGroup as NychRadioButtonGroup,
} from '@nychthemeron/library'
const meta = {
title: 'Components/RadioButton',
component: NychRadioButton,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
argTypes: {
disabled: {
control: { type: 'boolean' },
description: 'Disable the radio button',
},
},
} satisfies Meta<typeof NychRadioButton>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: () => ({
components: { NychRadioButton },
setup() {
const realm = ref('underworld')
return { realm }
},
template: `
<div style="display: flex; gap: 1rem;">
<NychRadioButton v-model="realm" value="underworld" />
<NychRadioButton v-model="realm" value="sky" />
</div>
`,
}),
}
export const Disabled: Story = {
render: () => ({
components: { NychRadioButton },
setup() {
const realm = ref('sky')
return { realm }
},
template: `
<div style="display: flex; gap: 1rem;">
<NychRadioButton :modelValue="'underworld'" value="sky" disabled />
<NychRadioButton :modelValue="'sky'" value="sky" disabled />
</div>
`,
}),
}
export const WithLabels: Story = {
render: () => ({
components: { NychRadioButton },
setup() {
const god = ref('apollo')
return { god }
},
template: `
<div style="display: flex; flex-direction: column; gap: 0.75rem; font-family: var(--font-sans); color: var(--text-body);">
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
<NychRadioButton v-model="god" value="apollo" /> Apollo
</label>
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
<NychRadioButton v-model="god" value="hades" /> Hades
</label>
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
<NychRadioButton v-model="god" value="hermes" /> Hermes
</label>
<p style="color: var(--text-muted); margin-top: 0.25rem;">Chosen: {{ god }}</p>
</div>
`,
}),
}
// RadioButtonGroup binds the v-model once on the group and shares a name across
// children — the children only carry a `value`.
export const Group: Story = {
render: () => ({
components: { NychRadioButton, NychRadioButtonGroup },
setup() {
const god = ref('apollo')
return { god }
},
template: `
<div style="font-family: var(--font-sans); color: var(--text-body);">
<NychRadioButtonGroup v-model="god" name="god">
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
<NychRadioButton value="apollo" /> Apollo
</label>
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
<NychRadioButton value="hades" /> Hades
</label>
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
<NychRadioButton value="hermes" /> Hermes
</label>
</NychRadioButtonGroup>
<p style="color: var(--text-muted); margin-top: 0.75rem;">Chosen: {{ god }}</p>
</div>
`,
}),
}

View file

@ -0,0 +1,77 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import { Select as NychSelect } from '@nychthemeron/library'
const meta = {
title: 'Components/Select',
component: NychSelect,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
argTypes: {
placeholder: {
control: { type: 'text' },
description: 'Placeholder text',
},
disabled: {
control: { type: 'boolean' },
description: 'Disable the select',
},
},
} satisfies Meta<typeof NychSelect>
export default meta
type Story = StoryObj<typeof meta>
const RIVERS = ['Styx', 'Acheron', 'Cocytus', 'Phlegethon', 'Lethe']
export const Default: Story = {
render: () => ({
components: { NychSelect },
setup() {
const river = ref(null)
return { river, RIVERS }
},
template: `<NychSelect v-model="river" :options="RIVERS" placeholder="Choose a river" />`,
}),
}
export const Selected: Story = {
render: () => ({
components: { NychSelect },
setup() {
const river = ref('Lethe')
return { river, RIVERS }
},
template: `<NychSelect v-model="river" :options="RIVERS" placeholder="Choose a river" />`,
}),
}
export const Disabled: Story = {
render: () => ({
components: { NychSelect },
setup() {
const river = ref('Styx')
return { river, RIVERS }
},
template: `<NychSelect v-model="river" :options="RIVERS" disabled />`,
}),
}
export const ObjectOptions: Story = {
render: () => ({
components: { NychSelect },
setup() {
const god = ref(null)
const gods = [
{ name: 'Apollo', domain: 'apollo' },
{ name: 'Hades', domain: 'hades' },
{ name: 'Hermes', domain: 'hermes' },
]
return { god, gods }
},
template: `<NychSelect v-model="god" :options="gods" optionLabel="name" optionValue="domain" placeholder="Choose a god" />`,
}),
}

View file

@ -0,0 +1,55 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { Tag as NychTag } from '@nychthemeron/library'
const meta = {
title: 'Components/Tag',
component: NychTag,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
argTypes: {
severity: {
control: { type: 'select' },
options: ['secondary', 'success', 'info', 'warn', 'danger', 'contrast'],
description: 'Tag color severity',
},
value: {
control: { type: 'text' },
description: 'Tag label',
},
rounded: {
control: { type: 'boolean' },
description: 'Pill-shaped tag',
},
},
} satisfies Meta<typeof NychTag>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: { value: 'Mortal' },
}
export const Rounded: Story = {
args: { value: 'Immortal', severity: 'info', rounded: true },
}
export const Severities: Story = {
render: () => ({
components: { NychTag },
template: `
<div style="display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center;">
<NychTag value="Primary" />
<NychTag value="Secondary" severity="secondary" />
<NychTag value="Info" severity="info" />
<NychTag value="Success" severity="success" />
<NychTag value="Warn" severity="warn" />
<NychTag value="Danger" severity="danger" />
<NychTag value="Contrast" severity="contrast" />
</div>
`,
}),
}

View file

@ -0,0 +1,80 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import { Textarea as NychTextarea } from '@nychthemeron/library'
const meta = {
title: 'Components/Textarea',
component: NychTextarea,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
argTypes: {
placeholder: {
control: { type: 'text' },
description: 'Placeholder text',
},
rows: {
control: { type: 'number' },
description: 'Visible rows',
},
disabled: {
control: { type: 'boolean' },
description: 'Disable the textarea',
},
invalid: {
control: { type: 'boolean' },
description: 'Mark the textarea as invalid',
},
},
} satisfies Meta<typeof NychTextarea>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: () => ({
components: { NychTextarea },
setup() {
const value = ref('')
return { value }
},
template: `<NychTextarea v-model="value" rows="4" placeholder="Inscribe your oath…" style="width: 22rem;" />`,
}),
}
export const Filled: Story = {
render: () => ({
components: { NychTextarea },
setup() {
const value = ref(
'Whoever crosses the Styx leaves the world of light behind, and is bound to the laws of the deep.',
)
return { value }
},
template: `<NychTextarea v-model="value" rows="4" style="width: 22rem;" />`,
}),
}
export const Invalid: Story = {
render: () => ({
components: { NychTextarea },
setup() {
const value = ref('')
return { value }
},
template: `<NychTextarea v-model="value" rows="4" invalid placeholder="Required" style="width: 22rem;" />`,
}),
}
export const Disabled: Story = {
render: () => ({
components: { NychTextarea },
setup() {
const value = ref('This scroll is sealed.')
return { value }
},
template: `<NychTextarea v-model="value" rows="4" disabled style="width: 22rem;" />`,
}),
}

View file

@ -0,0 +1,75 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import { ToggleSwitch as NychToggleSwitch } from '@nychthemeron/library'
const meta = {
title: 'Components/ToggleSwitch',
component: NychToggleSwitch,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
argTypes: {
disabled: {
control: { type: 'boolean' },
description: 'Disable the switch',
},
},
} satisfies Meta<typeof NychToggleSwitch>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: () => ({
components: { NychToggleSwitch },
setup() {
const on = ref(false)
return { on }
},
template: `<NychToggleSwitch v-model="on" />`,
}),
}
export const On: Story = {
render: () => ({
components: { NychToggleSwitch },
setup() {
const on = ref(true)
return { on }
},
template: `<NychToggleSwitch v-model="on" />`,
}),
}
export const Disabled: Story = {
render: () => ({
components: { NychToggleSwitch },
setup() {
return {}
},
template: `
<div style="display: flex; gap: 1rem;">
<NychToggleSwitch :modelValue="false" disabled />
<NychToggleSwitch :modelValue="true" disabled />
</div>
`,
}),
}
export const WithLabel: Story = {
render: () => ({
components: { NychToggleSwitch },
setup() {
const nocturnal = ref(true)
return { nocturnal }
},
template: `
<label style="display: flex; align-items: center; gap: 0.75rem; font-family: var(--font-sans); color: var(--text-body); cursor: pointer;">
<NychToggleSwitch v-model="nocturnal" />
{{ nocturnal ? 'Hades (night)' : 'Apollo (day)' }}
</label>
`,
}),
}