diff --git a/.gitignore b/.gitignore index 0f73fed..8348aa1 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,10 @@ packages/*/storybook-static # Storybook .storybook/node_modules node_modules/.cache + +# Local browser-verification tooling (Playwright + extracted libs + screenshots) +.tmp/ +.bun-cache/ +.pw-browsers/ +.pw-libs/ +.shots/ diff --git a/bun.lock b/bun.lock index 2d9cda2..fa8da1b 100644 --- a/bun.lock +++ b/bun.lock @@ -22,6 +22,7 @@ "jsdom": "^29.1.1", "npm-run-all2": "^8.0.4", "oxlint": "~1.60.0", + "playwright": "^1.60.0", "prettier": "3.8.3", "primevue": "^4.5.5", "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=="], - "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=="], @@ -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=="], + "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=="], "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=="], + "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/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=="], diff --git a/package.json b/package.json index d1be525..c395f0f 100644 --- a/package.json +++ b/package.json @@ -6,9 +6,8 @@ "@types/jsdom": "^28.0.3", "@types/node": "^25.9.1", "@vitejs/plugin-vue": "^6.0.7", - "primevue": "^4.5.5", - "@vitest/eslint-plugin": "^1.6.19", "@vitest/coverage-v8": "^4.1.8", + "@vitest/eslint-plugin": "^1.6.19", "@vue/eslint-config-typescript": "^14.7.0", "@vue/test-utils": "^2.4.10", "@vue/tsconfig": "^0.9.1", @@ -20,7 +19,9 @@ "jsdom": "^29.1.1", "npm-run-all2": "^8.0.4", "oxlint": "~1.60.0", + "playwright": "^1.60.0", "prettier": "3.8.3", + "primevue": "^4.5.5", "typescript": "~6.0.3", "vite": "^8.0.16", "vite-plugin-vue-devtools": "^8.1.2", diff --git a/packages/library/src/assets/theme/theme.css b/packages/library/src/assets/theme/theme.css index a8d422f..10adb38 100644 --- a/packages/library/src/assets/theme/theme.css +++ b/packages/library/src/assets/theme/theme.css @@ -63,7 +63,6 @@ color: var(--danger-fg); } - .nych-loading-icon .wreath path { fill: var(--primary-fg); } @@ -93,3 +92,636 @@ button[data-p~='loading'] > .nych-loading-icon { height: 40px !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); +} diff --git a/packages/library/src/engine/index.ts b/packages/library/src/engine/index.ts index 0fba865..a516e6f 100644 --- a/packages/library/src/engine/index.ts +++ b/packages/library/src/engine/index.ts @@ -63,14 +63,22 @@ export class ThemeEngine { const { component, classes, styles, injectionKeys, propMutator, unstyled, slots } = this.lib.components[key] - const componentObj = { ...(component as Record) } - - componentObj.props = { - ...((componentObj.props as Record) || {}), - injectedClasses: { default: classes }, - injectedStyles: { default: styles }, - stylePlug: { type: String }, - unstyled: { default: unstyled }, + // Build a MINIMAL pass-through wrapper rather than cloning the wrapped + // component. Cloning dragged along the component's `props`, `emits` and + // `extends` chain — which made Vue treat listeners like `onUpdate:modelValue` + // as the wrapper's own declared events and withhold them from `ctx.attrs`, + // silently breaking two-way `v-model`. By declaring only the engine's own + // props here, every other prop, listener and attribute falls through + // `ctx.attrs` and is forwarded verbatim to the real component below. + const componentObj: Record = { + name: `Nych${String(key)}`, + inheritAttrs: false, + props: { + injectedClasses: { default: classes }, + injectedStyles: { default: styles }, + stylePlug: { type: String }, + unstyled: { default: unstyled }, + }, } const applyThemeProps = (props: Record) => { diff --git a/packages/library/src/index.ts b/packages/library/src/index.ts index f96f942..37802dd 100644 --- a/packages/library/src/index.ts +++ b/packages/library/src/index.ts @@ -6,4 +6,20 @@ export { type Key, type Props, } 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' diff --git a/packages/library/src/lib/index.ts b/packages/library/src/lib/index.ts index 5ee060c..4bc2e52 100644 --- a/packages/library/src/lib/index.ts +++ b/packages/library/src/lib/index.ts @@ -1,8 +1,56 @@ import type { App } from 'vue' -import { Button as PrimeButton } from 'primevue' -import { ThemeLibrary, type Themeable, ThemeEngine } from '../engine' +import { + 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' +/** + * Build a Themeable that themes a PrimeVue component through its passthrough (`pt`) API. + * + * The engine injects the base class (`nych-`) onto `pt.root`. This factory layers a + * `nych--
` 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 = ( + component: T, + base: string, + sections: string[] = [], +): Themeable => ({ + component, + unstyled: true as Themeable['unstyled'], + classes: [`nych-${base}`], + injectionKeys: { class: 'pt' as Key }, + propMutator: ((props: Record) => { + const pt = (props.pt as Record) ?? {} + + 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['propMutator'], +}) + const buttonThemeable: Themeable = { component: PrimeButton, unstyled: true, @@ -42,6 +90,51 @@ const nychthemeron: ThemeLibrary = { components: { Button: buttonThemeable, 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('Button') export const LoadingIcon = _engine.getComponent('LoadingIcon') +export const InputText: typeof PrimeInputText = + _engine.getComponent('InputText') +export const Textarea: typeof PrimeTextarea = _engine.getComponent('Textarea') +export const Checkbox: typeof PrimeCheckbox = _engine.getComponent('Checkbox') +export const CheckboxGroup: typeof PrimeCheckboxGroup = + _engine.getComponent('CheckboxGroup') +export const RadioButton: typeof PrimeRadioButton = + _engine.getComponent('RadioButton') +export const RadioButtonGroup: typeof PrimeRadioButtonGroup = + _engine.getComponent('RadioButtonGroup') +export const ToggleSwitch: typeof PrimeToggleSwitch = + _engine.getComponent('ToggleSwitch') +export const Select: typeof PrimeSelect = _engine.getComponent('Select') +export const Card: typeof PrimeCard = _engine.getComponent('Card') +export const Dialog: typeof PrimeDialog = _engine.getComponent('Dialog') +export const Message: typeof PrimeMessage = _engine.getComponent('Message') +export const Tag: typeof PrimeTag = _engine.getComponent('Tag') export const createNychthemeron = (): { Button: typeof PrimeButton 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 } => ({ Button, LoadingIcon, + InputText, + Textarea, + Checkbox, + CheckboxGroup, + RadioButton, + RadioButtonGroup, + ToggleSwitch, + Select, + Card, + Dialog, + Message, + Tag, install(app: App) { app.component('NychButton', Button) 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) }, }) diff --git a/packages/library/tests/engine.spec.ts b/packages/library/tests/engine.spec.ts index fd3054e..e2795b1 100644 --- a/packages/library/tests/engine.spec.ts +++ b/packages/library/tests/engine.spec.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from 'vitest' +import { mount } from '@vue/test-utils' import Button from 'primevue/button' -import { h } from 'vue' +import { h, ref } from 'vue' import { ThemeEngine, ThemeLibrary, type Themeable } from '../src/engine' @@ -564,6 +565,40 @@ describe('ThemeEngine', () => { 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, { 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: `
{{ val }}
`, + } + + 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', () => { const ThemeSlot = { name: 'ThemeSlot', diff --git a/packages/library/tests/lib.spec.ts b/packages/library/tests/lib.spec.ts index f837b5d..aef3b44 100644 --- a/packages/library/tests/lib.spec.ts +++ b/packages/library/tests/lib.spec.ts @@ -1,6 +1,22 @@ import { describe, it, expect, vi } from 'vitest' 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 const callSetup = (component: any, props: Record) => { @@ -92,3 +108,50 @@ describe('LoadingIcon', () => { 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).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') + }) +}) diff --git a/packages/playground/.storybook/preview.ts b/packages/playground/.storybook/preview.ts index da1aae2..f5d2053 100644 --- a/packages/playground/.storybook/preview.ts +++ b/packages/playground/.storybook/preview.ts @@ -1,6 +1,12 @@ import type { Preview } from '@storybook/vue3-vite' +import { setup } from '@storybook/vue3-vite' +import PrimeVue from 'primevue/config' import '@nychthemeron/library/theme' +setup((app) => { + app.use(PrimeVue, { unstyled: true }) +}) + const preview: Preview = { parameters: { layout: 'centered', diff --git a/packages/playground/stories/Card.stories.ts b/packages/playground/stories/Card.stories.ts new file mode 100644 index 0000000..f066d34 --- /dev/null +++ b/packages/playground/stories/Card.stories.ts @@ -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 + +export default meta +type Story = StoryObj + +export const Default: Story = { + render: () => ({ + components: { NychCard }, + template: ` + + + + + + `, + }), +} + +export const WithFooter: Story = { + render: () => ({ + components: { NychCard, NychButton }, + template: ` + + + + + + + `, + }), +} + +export const WithHeaderAndTag: Story = { + render: () => ({ + components: { NychCard, NychTag }, + template: ` + + + + + + `, + }), +} diff --git a/packages/playground/stories/Checkbox.stories.ts b/packages/playground/stories/Checkbox.stories.ts new file mode 100644 index 0000000..6c9c57c --- /dev/null +++ b/packages/playground/stories/Checkbox.stories.ts @@ -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 + +export default meta +type Story = StoryObj + +export const Default: Story = { + render: () => ({ + components: { NychCheckbox }, + setup() { + const checked = ref(false) + return { checked } + }, + template: ``, + }), +} + +export const Checked: Story = { + render: () => ({ + components: { NychCheckbox }, + setup() { + const checked = ref(true) + return { checked } + }, + template: ``, + }), +} + +export const Disabled: Story = { + render: () => ({ + components: { NychCheckbox }, + setup() { + const checked = ref(true) + return { checked } + }, + template: ` +
+ + +
+ `, + }), +} + +export const WithLabels: Story = { + render: () => ({ + components: { NychCheckbox }, + setup() { + const offerings = ref(['honey']) + return { offerings } + }, + template: ` +
+ + + +

Offerings: {{ offerings.join(', ') || 'none' }}

+
+ `, + }), +} + +// 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: ` +
+ + + + + +

Offerings: {{ offerings.join(', ') || 'none' }}

+
+ `, + }), +} diff --git a/packages/playground/stories/Dialog.stories.ts b/packages/playground/stories/Dialog.stories.ts new file mode 100644 index 0000000..a089f61 --- /dev/null +++ b/packages/playground/stories/Dialog.stories.ts @@ -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 + +export default meta +type Story = StoryObj + +export const Default: Story = { + render: () => ({ + components: { NychDialog, NychButton }, + setup() { + const visible = ref(false) + return { visible } + }, + template: ` +
+ + +

+ Seekers travelled from across the Aegean to hear the Pythia speak the + will of Apollo in riddling verse. Approach, and ask your question. +

+ +
+
+ `, + }), +} + +// 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: ` + +

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

+ +
+ `, + }), +} + +export const WithoutFooter: Story = { + render: () => ({ + components: { NychDialog }, + setup() { + const visible = ref(true) + return { visible } + }, + template: ` + +

A crow has settled upon the western gate. Interpret it as you will.

+
+ `, + }), +} diff --git a/packages/playground/stories/InputText.stories.ts b/packages/playground/stories/InputText.stories.ts new file mode 100644 index 0000000..139d007 --- /dev/null +++ b/packages/playground/stories/InputText.stories.ts @@ -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 + +export default meta +type Story = StoryObj + +const modelTemplate = (attrs: string) => ({ + components: { NychInputText }, + setup() { + const value = ref('') + return { value } + }, + template: ``, +}) + +export const Default: Story = { + render: () => modelTemplate('placeholder="Speak, traveller…"'), +} + +export const Filled: Story = { + render: () => ({ + components: { NychInputText }, + setup() { + const value = ref('Charon') + return { value } + }, + template: ``, + }), +} + +export const Invalid: Story = { + render: () => modelTemplate('placeholder="Required" invalid'), +} + +export const Disabled: Story = { + render: () => ({ + components: { NychInputText }, + setup() { + const value = ref('Sealed') + return { value } + }, + template: ``, + }), +} + +export const Sizes: Story = { + render: () => ({ + components: { NychInputText }, + setup() { + const a = ref('') + const b = ref('') + const c = ref('') + return { a, b, c } + }, + template: ` +
+ + + +
+ `, + }), +} diff --git a/packages/playground/stories/Message.stories.ts b/packages/playground/stories/Message.stories.ts new file mode 100644 index 0000000..0e3a745 --- /dev/null +++ b/packages/playground/stories/Message.stories.ts @@ -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 + +export default meta +type Story = StoryObj + +export const Info: Story = { + args: { severity: 'info' }, + render: (args) => ({ + components: { NychMessage }, + setup: () => ({ args }), + template: `The Oracle has spoken.`, + }), +} + +export const Success: Story = { + args: { severity: 'success' }, + render: (args) => ({ + components: { NychMessage }, + setup: () => ({ args }), + template: `The offering was accepted.`, + }), +} + +export const Warn: Story = { + args: { severity: 'warn' }, + render: (args) => ({ + components: { NychMessage }, + setup: () => ({ args }), + template: `The ferryman grows impatient.`, + }), +} + +export const Error: Story = { + args: { severity: 'error' }, + render: (args) => ({ + components: { NychMessage }, + setup: () => ({ args }), + template: `The gods have refused your plea.`, + }), +} + +export const Closable: Story = { + args: { severity: 'info', closable: true }, + render: (args) => ({ + components: { NychMessage }, + setup: () => ({ args }), + template: `This omen may be dismissed.`, + }), +} + +export const AllSeverities: Story = { + render: () => ({ + components: { NychMessage }, + template: ` +
+ The Oracle has spoken. + The offering was accepted. + The ferryman grows impatient. + The gods have refused your plea. + A neutral notice from the keeper. +
+ `, + }), +} diff --git a/packages/playground/stories/RadioButton.stories.ts b/packages/playground/stories/RadioButton.stories.ts new file mode 100644 index 0000000..3e73ad4 --- /dev/null +++ b/packages/playground/stories/RadioButton.stories.ts @@ -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 + +export default meta +type Story = StoryObj + +export const Default: Story = { + render: () => ({ + components: { NychRadioButton }, + setup() { + const realm = ref('underworld') + return { realm } + }, + template: ` +
+ + +
+ `, + }), +} + +export const Disabled: Story = { + render: () => ({ + components: { NychRadioButton }, + setup() { + const realm = ref('sky') + return { realm } + }, + template: ` +
+ + +
+ `, + }), +} + +export const WithLabels: Story = { + render: () => ({ + components: { NychRadioButton }, + setup() { + const god = ref('apollo') + return { god } + }, + template: ` +
+ + + +

Chosen: {{ god }}

+
+ `, + }), +} + +// 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: ` +
+ + + + + +

Chosen: {{ god }}

+
+ `, + }), +} diff --git a/packages/playground/stories/Select.stories.ts b/packages/playground/stories/Select.stories.ts new file mode 100644 index 0000000..7ed9872 --- /dev/null +++ b/packages/playground/stories/Select.stories.ts @@ -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 + +export default meta +type Story = StoryObj + +const RIVERS = ['Styx', 'Acheron', 'Cocytus', 'Phlegethon', 'Lethe'] + +export const Default: Story = { + render: () => ({ + components: { NychSelect }, + setup() { + const river = ref(null) + return { river, RIVERS } + }, + template: ``, + }), +} + +export const Selected: Story = { + render: () => ({ + components: { NychSelect }, + setup() { + const river = ref('Lethe') + return { river, RIVERS } + }, + template: ``, + }), +} + +export const Disabled: Story = { + render: () => ({ + components: { NychSelect }, + setup() { + const river = ref('Styx') + return { river, RIVERS } + }, + template: ``, + }), +} + +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: ``, + }), +} diff --git a/packages/playground/stories/Tag.stories.ts b/packages/playground/stories/Tag.stories.ts new file mode 100644 index 0000000..4d29b98 --- /dev/null +++ b/packages/playground/stories/Tag.stories.ts @@ -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 + +export default meta +type Story = StoryObj + +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: ` +
+ + + + + + + +
+ `, + }), +} diff --git a/packages/playground/stories/Textarea.stories.ts b/packages/playground/stories/Textarea.stories.ts new file mode 100644 index 0000000..ac6b27a --- /dev/null +++ b/packages/playground/stories/Textarea.stories.ts @@ -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 + +export default meta +type Story = StoryObj + +export const Default: Story = { + render: () => ({ + components: { NychTextarea }, + setup() { + const value = ref('') + return { value } + }, + template: ``, + }), +} + +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: ``, + }), +} + +export const Invalid: Story = { + render: () => ({ + components: { NychTextarea }, + setup() { + const value = ref('') + return { value } + }, + template: ``, + }), +} + +export const Disabled: Story = { + render: () => ({ + components: { NychTextarea }, + setup() { + const value = ref('This scroll is sealed.') + return { value } + }, + template: ``, + }), +} diff --git a/packages/playground/stories/ToggleSwitch.stories.ts b/packages/playground/stories/ToggleSwitch.stories.ts new file mode 100644 index 0000000..8374d17 --- /dev/null +++ b/packages/playground/stories/ToggleSwitch.stories.ts @@ -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 + +export default meta +type Story = StoryObj + +export const Default: Story = { + render: () => ({ + components: { NychToggleSwitch }, + setup() { + const on = ref(false) + return { on } + }, + template: ``, + }), +} + +export const On: Story = { + render: () => ({ + components: { NychToggleSwitch }, + setup() { + const on = ref(true) + return { on } + }, + template: ``, + }), +} + +export const Disabled: Story = { + render: () => ({ + components: { NychToggleSwitch }, + setup() { + return {} + }, + template: ` +
+ + +
+ `, + }), +} + +export const WithLabel: Story = { + render: () => ({ + components: { NychToggleSwitch }, + setup() { + const nocturnal = ref(true) + return { nocturnal } + }, + template: ` + + `, + }), +}