Make application shell components package-native
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nodedc/tokens",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.0",
|
||||
"type": "module",
|
||||
"files": ["tokens.json", "tokens.css", "themes.css"],
|
||||
"exports": {
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
--nodedc-glass-panel-bg: rgba(28, 28, 30, 0.72);
|
||||
--nodedc-glass-panel-bg-strong: rgba(18, 18, 21, 0.88);
|
||||
--nodedc-glass-panel-bg-soft: rgba(42, 42, 46, 0.5);
|
||||
--nodedc-header-pill-bg: rgba(64, 64, 64, 0.48);
|
||||
--nodedc-glass-panel-surface: linear-gradient(180deg, rgba(255, 255, 255, 0.055), rgba(255, 255, 255, 0.014)), var(--nodedc-glass-panel-bg);
|
||||
--nodedc-glass-panel-surface-strong: linear-gradient(180deg, rgba(255, 255, 255, 0.068), rgba(255, 255, 255, 0.018)), var(--nodedc-glass-panel-bg-strong);
|
||||
--nodedc-glass-control-bg: rgba(110, 110, 110, 0.07);
|
||||
@@ -50,6 +51,7 @@
|
||||
--nodedc-glass-panel-bg: rgba(255, 255, 255, 0.48);
|
||||
--nodedc-glass-panel-bg-strong: rgba(255, 255, 255, 0.72);
|
||||
--nodedc-glass-panel-bg-soft: rgba(255, 255, 255, 0.34);
|
||||
--nodedc-header-pill-bg: rgba(255, 255, 255, 0.56);
|
||||
--nodedc-glass-panel-surface: linear-gradient(180deg, rgba(255, 255, 255, 0.58), rgba(255, 255, 255, 0.22)), var(--nodedc-glass-panel-bg);
|
||||
--nodedc-glass-panel-surface-strong: linear-gradient(180deg, rgba(255, 255, 255, 0.76), rgba(255, 255, 255, 0.34)), var(--nodedc-glass-panel-bg-strong);
|
||||
--nodedc-glass-control-bg: linear-gradient(180deg, rgba(255, 255, 255, 0.54), rgba(255, 255, 255, 0.22)), rgba(255, 255, 255, 0.38);
|
||||
@@ -64,4 +66,3 @@
|
||||
--nodedc-glass-dropdown-shadow: 0 22px 60px rgba(24, 32, 29, 0.2), 0 6px 18px rgba(24, 32, 29, 0.1);
|
||||
--nodedc-focus-surface: rgba(var(--nodedc-accent-rgb), 0.1);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
--nodedc-icon-button-size: 2.875rem;
|
||||
--nodedc-header-row-height: 3rem;
|
||||
--nodedc-header-pill-height: 3.45rem;
|
||||
--nodedc-header-control-height: 2.78rem;
|
||||
--nodedc-header-logo-width: 7.25rem;
|
||||
--nodedc-header-logo-height: 1.79rem;
|
||||
--nodedc-inspector-width: 24.375rem;
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"iconButton": { "$type": "dimension", "$value": "46px" },
|
||||
"headerRow": { "$type": "dimension", "$value": "48px" },
|
||||
"headerPill": { "$type": "dimension", "$value": "55.2px" },
|
||||
"headerControl": { "$type": "dimension", "$value": "44.48px" },
|
||||
"logoWidth": { "$type": "dimension", "$value": "116px" },
|
||||
"logoHeight": { "$type": "dimension", "$value": "28.64px" },
|
||||
"inspectorWidth": { "$type": "dimension", "$value": "390px" },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nodedc/ui-core",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.0",
|
||||
"type": "module",
|
||||
"files": ["dist", "styles.css"],
|
||||
"main": "./dist/index.js",
|
||||
@@ -17,6 +17,6 @@
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nodedc/tokens": "0.3.0"
|
||||
"@nodedc/tokens": "0.4.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export * from "./accent";
|
||||
export * from "./floating";
|
||||
export * from "./theme";
|
||||
export * from "./accent.js";
|
||||
export * from "./floating.js";
|
||||
export * from "./theme.js";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createAccentVariables, type RgbTuple } from "./accent";
|
||||
import { createAccentVariables, type RgbTuple } from "./accent.js";
|
||||
|
||||
export type NodedcTheme = "dark" | "light";
|
||||
|
||||
|
||||
+354
-11
@@ -277,6 +277,299 @@ textarea.nodedc-field__control {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.nodedc-media-field {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0.42rem;
|
||||
}
|
||||
|
||||
.nodedc-media-field__label-row {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--nodedc-space-2);
|
||||
padding-inline: 0.15rem;
|
||||
}
|
||||
|
||||
.nodedc-media-field__label,
|
||||
.nodedc-media-field__kind {
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: var(--nodedc-font-size-sm);
|
||||
font-weight: var(--nodedc-font-weight-medium);
|
||||
}
|
||||
|
||||
.nodedc-media-field__kind {
|
||||
border-radius: var(--nodedc-radius-circle);
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
padding: 0.16rem 0.56rem;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.62rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.05em;
|
||||
line-height: 1;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.nodedc-media-control {
|
||||
display: grid;
|
||||
min-height: 3.35rem;
|
||||
min-width: 0;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
align-items: center;
|
||||
gap: 0.22rem;
|
||||
overflow: hidden;
|
||||
border-radius: var(--nodedc-radius-circle);
|
||||
background: var(--nodedc-field-bg);
|
||||
padding: 0.25rem;
|
||||
box-shadow: var(--nodedc-glass-control-shadow);
|
||||
}
|
||||
|
||||
.nodedc-media-file {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
padding-left: 0.15rem;
|
||||
}
|
||||
|
||||
.nodedc-media-file[hidden],
|
||||
.nodedc-media-url[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nodedc-media-file input {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nodedc-media-file__button {
|
||||
display: inline-flex;
|
||||
min-height: 2.78rem;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--nodedc-radius-circle);
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
color: rgba(8, 8, 10, 0.96);
|
||||
padding: 0 1rem;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 850;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.nodedc-media-file__button:hover {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.nodedc-media-file__name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 650;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nodedc-media-url {
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
border-radius: var(--nodedc-radius-circle);
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
color: var(--nodedc-text-primary);
|
||||
padding: 0 0.82rem;
|
||||
font: inherit;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.nodedc-media-url::placeholder {
|
||||
color: var(--nodedc-text-muted);
|
||||
}
|
||||
|
||||
.nodedc-media-source-switch {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.12rem;
|
||||
}
|
||||
|
||||
.nodedc-media-source-button {
|
||||
display: grid;
|
||||
width: 2.78rem;
|
||||
height: 2.78rem;
|
||||
place-items: center;
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
border-radius: var(--nodedc-radius-circle);
|
||||
background: transparent;
|
||||
color: var(--nodedc-text-secondary);
|
||||
font: inherit;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.nodedc-media-source-button:hover {
|
||||
background: var(--nodedc-glass-control-hover);
|
||||
color: var(--nodedc-text-primary);
|
||||
}
|
||||
|
||||
.nodedc-media-source-button[data-active="true"] {
|
||||
border-color: rgba(255, 255, 255, 0.92);
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
color: rgba(8, 8, 10, 0.96);
|
||||
}
|
||||
|
||||
.nodedc-media-preview {
|
||||
position: relative;
|
||||
display: grid;
|
||||
width: 2.78rem;
|
||||
min-width: 2.78rem;
|
||||
height: 2.78rem;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
border-radius: var(--nodedc-radius-circle);
|
||||
background:
|
||||
linear-gradient(45deg, rgba(255, 255, 255, 0.16) 25%, transparent 25% 75%, rgba(255, 255, 255, 0.16) 75%),
|
||||
linear-gradient(45deg, rgba(255, 255, 255, 0.16) 25%, transparent 25% 75%, rgba(255, 255, 255, 0.16) 75%),
|
||||
rgba(18, 18, 20, 0.92);
|
||||
background-position: 0 0, 6px 6px, 0 0;
|
||||
background-size: 12px 12px, 12px 12px, auto;
|
||||
color: var(--nodedc-text-secondary);
|
||||
}
|
||||
|
||||
.nodedc-media-preview img,
|
||||
.nodedc-media-preview video {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.nodedc-media-field__path,
|
||||
.nodedc-media-field__hint,
|
||||
.nodedc-media-field__error {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: var(--nodedc-font-size-xs);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.nodedc-media-field__error {
|
||||
color: rgb(var(--nodedc-danger-rgb));
|
||||
}
|
||||
|
||||
.nodedc-settings-card {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 1rem;
|
||||
border-radius: var(--nodedc-radius-card);
|
||||
background: var(--nodedc-glass-control-bg);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.nodedc-settings-card__head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.nodedc-settings-card__titles {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0.22rem;
|
||||
}
|
||||
|
||||
.nodedc-settings-card__titles > span {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.66rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.07em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.nodedc-settings-card__titles h2,
|
||||
.nodedc-settings-card__titles p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.nodedc-settings-card__titles h2 {
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 1rem;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.nodedc-settings-card__titles p {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.nodedc-settings-card__actions {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.nodedc-settings-card__body {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.nodedc-switch {
|
||||
display: inline-flex;
|
||||
width: max-content;
|
||||
align-items: center;
|
||||
gap: 0.48rem;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--nodedc-text-secondary);
|
||||
font: inherit;
|
||||
font-size: var(--nodedc-font-size-sm);
|
||||
font-weight: var(--nodedc-font-weight-strong);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.nodedc-switch__track {
|
||||
display: flex;
|
||||
width: 2rem;
|
||||
height: 1.12rem;
|
||||
align-items: center;
|
||||
border-radius: var(--nodedc-radius-circle);
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
padding: 0.14rem;
|
||||
transition: background var(--nodedc-duration-normal) var(--nodedc-ease-standard);
|
||||
}
|
||||
|
||||
.nodedc-switch__track > span {
|
||||
width: 0.84rem;
|
||||
height: 0.84rem;
|
||||
border-radius: var(--nodedc-radius-circle);
|
||||
background: var(--nodedc-text-primary);
|
||||
transition: transform var(--nodedc-duration-normal) var(--nodedc-ease-standard);
|
||||
}
|
||||
|
||||
.nodedc-switch[aria-checked="true"] .nodedc-switch__track {
|
||||
background: rgb(var(--nodedc-accent-rgb));
|
||||
}
|
||||
|
||||
.nodedc-switch[aria-checked="true"] .nodedc-switch__track > span {
|
||||
transform: translateX(0.88rem);
|
||||
}
|
||||
|
||||
.nodedc-checker {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
@@ -541,8 +834,16 @@ textarea.nodedc-field__control {
|
||||
}
|
||||
|
||||
.nodedc-header {
|
||||
display: grid;
|
||||
display: flex;
|
||||
min-height: 4.25rem;
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.nodedc-header__row {
|
||||
display: grid;
|
||||
min-height: var(--nodedc-header-row-height);
|
||||
width: 100%;
|
||||
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: var(--nodedc-space-4);
|
||||
@@ -589,26 +890,27 @@ textarea.nodedc-field__control {
|
||||
|
||||
.nodedc-header__profile {
|
||||
display: inline-flex;
|
||||
height: var(--nodedc-header-pill-height);
|
||||
min-height: var(--nodedc-header-pill-height);
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
border-radius: var(--nodedc-radius-circle);
|
||||
background: var(--nodedc-glass-panel-bg-soft);
|
||||
background: var(--nodedc-header-pill-bg);
|
||||
padding: 0.32rem;
|
||||
box-shadow: var(--nodedc-glass-control-shadow);
|
||||
}
|
||||
|
||||
.nodedc-header__profile .nodedc-icon-button {
|
||||
width: var(--nodedc-header-row-height);
|
||||
height: var(--nodedc-header-row-height);
|
||||
flex-basis: var(--nodedc-header-row-height);
|
||||
width: var(--nodedc-header-control-height);
|
||||
height: var(--nodedc-header-control-height);
|
||||
flex-basis: var(--nodedc-header-control-height);
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.nodedc-header__profile-button {
|
||||
display: inline-flex;
|
||||
min-height: calc(var(--nodedc-header-pill-height) - 0.64rem);
|
||||
min-height: var(--nodedc-header-control-height);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.45rem;
|
||||
@@ -639,8 +941,8 @@ textarea.nodedc-field__control {
|
||||
overflow: hidden;
|
||||
border-radius: var(--nodedc-radius-circle);
|
||||
background:
|
||||
radial-gradient(circle at 70% 20%, rgba(255, 255, 255, 0.72), transparent 24%),
|
||||
linear-gradient(135deg, rgb(166, 194, 109), rgb(142, 123, 139));
|
||||
radial-gradient(circle at 70% 18%, rgba(255, 255, 255, 0.8), transparent 24%),
|
||||
linear-gradient(135deg, #c8c9cb, #8d8f96 55%, #f1f1f1);
|
||||
color: rgba(8, 8, 10, 0.96);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 800;
|
||||
@@ -660,15 +962,23 @@ textarea.nodedc-field__control {
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
border-radius: var(--nodedc-radius-circle);
|
||||
background: var(--nodedc-canvas);
|
||||
background: transparent;
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: var(--nodedc-font-size-sm);
|
||||
font-weight: var(--nodedc-font-weight-strong);
|
||||
}
|
||||
|
||||
.nodedc-header__workspace img {
|
||||
display: block;
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.nodedc-header__workspace[data-kind="avatar"] img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
@@ -676,9 +986,20 @@ textarea.nodedc-field__control {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.nodedc-header-navigation {
|
||||
display: inline-flex;
|
||||
min-height: var(--nodedc-header-pill-height);
|
||||
align-items: center;
|
||||
gap: 0.18rem;
|
||||
border-radius: var(--nodedc-radius-circle);
|
||||
background: var(--nodedc-header-pill-bg);
|
||||
padding: 0.32rem;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.nodedc-header__nav-item {
|
||||
display: inline-flex;
|
||||
min-height: calc(var(--nodedc-header-pill-height) - 0.64rem);
|
||||
min-height: var(--nodedc-header-control-height);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--nodedc-radius-circle);
|
||||
@@ -1741,6 +2062,10 @@ textarea.nodedc-field__control {
|
||||
|
||||
.nodedc-header {
|
||||
min-height: 8.6rem;
|
||||
}
|
||||
|
||||
.nodedc-header__row {
|
||||
min-height: 8.6rem;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-content: start;
|
||||
row-gap: 0.65rem;
|
||||
@@ -1753,7 +2078,8 @@ textarea.nodedc-field__control {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.nodedc-header__center .nodedc-segmented {
|
||||
.nodedc-header__center .nodedc-segmented,
|
||||
.nodedc-header__center .nodedc-header-navigation {
|
||||
min-width: max-content;
|
||||
}
|
||||
|
||||
@@ -1823,6 +2149,21 @@ textarea.nodedc-field__control {
|
||||
align-items: stretch;
|
||||
gap: var(--nodedc-space-2);
|
||||
}
|
||||
|
||||
.nodedc-media-control {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
border-radius: var(--nodedc-radius-card);
|
||||
}
|
||||
|
||||
.nodedc-media-file,
|
||||
.nodedc-media-url {
|
||||
grid-column: 1 / -1;
|
||||
min-height: 2.78rem;
|
||||
}
|
||||
|
||||
.nodedc-media-source-switch {
|
||||
justify-self: start;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
@@ -1837,6 +2178,8 @@ textarea.nodedc-field__control {
|
||||
.nodedc-button,
|
||||
.nodedc-icon-button,
|
||||
.nodedc-checker,
|
||||
.nodedc-media-source-button,
|
||||
.nodedc-switch,
|
||||
.nodedc-select-trigger,
|
||||
.nodedc-segmented__item {
|
||||
transition: none;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nodedc/ui-dom",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.0",
|
||||
"type": "module",
|
||||
"files": ["dist"],
|
||||
"main": "./dist/index.js",
|
||||
@@ -16,6 +16,6 @@
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nodedc/ui-core": "0.3.0"
|
||||
"@nodedc/ui-core": "0.4.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
export interface ApplicationWorkspaceSnapshot<ViewId extends string = string> {
|
||||
navigationOpen: boolean;
|
||||
activeView: ViewId | null;
|
||||
contentOpen: boolean;
|
||||
contentExpanded: boolean;
|
||||
}
|
||||
|
||||
export interface ApplicationWorkspaceController<ViewId extends string = string> {
|
||||
getState: () => ApplicationWorkspaceSnapshot<ViewId>;
|
||||
openNavigation: () => void;
|
||||
toggleNavigation: () => void;
|
||||
closeNavigation: () => void;
|
||||
openView: (view: ViewId) => void;
|
||||
closeView: () => void;
|
||||
setContentExpanded: (expanded: boolean) => void;
|
||||
}
|
||||
|
||||
export interface ApplicationWorkspaceOptions<ViewId extends string = string> {
|
||||
root?: HTMLElement;
|
||||
navigationOpen?: boolean;
|
||||
activeView?: ViewId | null;
|
||||
contentExpanded?: boolean;
|
||||
onChange?: (state: ApplicationWorkspaceSnapshot<ViewId>) => void;
|
||||
}
|
||||
|
||||
export function createApplicationWorkspaceController<ViewId extends string = string>({
|
||||
root,
|
||||
navigationOpen = false,
|
||||
activeView = null,
|
||||
contentExpanded = true,
|
||||
onChange,
|
||||
}: ApplicationWorkspaceOptions<ViewId> = {}): ApplicationWorkspaceController<ViewId> {
|
||||
let state: ApplicationWorkspaceSnapshot<ViewId> = {
|
||||
navigationOpen: navigationOpen || activeView !== null,
|
||||
activeView,
|
||||
contentOpen: activeView !== null,
|
||||
contentExpanded,
|
||||
};
|
||||
|
||||
const commit = (patch: Partial<ApplicationWorkspaceSnapshot<ViewId>>) => {
|
||||
state = { ...state, ...patch };
|
||||
state.contentOpen = state.activeView !== null;
|
||||
root?.toggleAttribute("data-navigation-open", state.navigationOpen);
|
||||
root?.toggleAttribute("data-content-open", state.contentOpen);
|
||||
root?.toggleAttribute("data-content-expanded", state.contentOpen && state.contentExpanded);
|
||||
onChange?.({ ...state });
|
||||
};
|
||||
|
||||
commit({});
|
||||
|
||||
return {
|
||||
getState: () => ({ ...state }),
|
||||
openNavigation: () => commit({ navigationOpen: true }),
|
||||
toggleNavigation: () => commit({ navigationOpen: !state.navigationOpen, activeView: state.navigationOpen ? null : state.activeView }),
|
||||
closeNavigation: () => commit({ navigationOpen: false, activeView: null }),
|
||||
openView: (view) => commit({ navigationOpen: true, activeView: view, contentExpanded: true }),
|
||||
closeView: () => commit({ activeView: null }),
|
||||
setContentExpanded: (expanded) => commit({ contentExpanded: expanded }),
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
export { applyNodedcTheme, clearNodedcAccent, createAccentVariables, readableTextRgb } from "@nodedc/ui-core";
|
||||
export * from "./floating";
|
||||
export * from "./modal";
|
||||
export * from "./select";
|
||||
|
||||
export * from "./applicationWorkspace.js";
|
||||
export * from "./floating.js";
|
||||
export * from "./mediaSource.js";
|
||||
export * from "./modal.js";
|
||||
export * from "./select.js";
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
export type MediaSource = "file" | "url";
|
||||
|
||||
export interface MediaSourceController {
|
||||
getSource: () => MediaSource;
|
||||
setSource: (source: MediaSource) => void;
|
||||
destroy: () => void;
|
||||
}
|
||||
|
||||
export interface MediaSourceControllerOptions {
|
||||
root: HTMLElement;
|
||||
source?: MediaSource;
|
||||
onChange?: (source: MediaSource) => void;
|
||||
}
|
||||
|
||||
export function createMediaSourceController({ root, source = "file", onChange }: MediaSourceControllerOptions): MediaSourceController {
|
||||
const options = Array.from(root.querySelectorAll<HTMLElement>("[data-nodedc-media-source-option]"));
|
||||
const panels = Array.from(root.querySelectorAll<HTMLElement>("[data-nodedc-media-source-panel]"));
|
||||
let current = source;
|
||||
|
||||
const render = () => {
|
||||
root.dataset.source = current;
|
||||
options.forEach((option) => {
|
||||
const active = option.dataset.nodedcMediaSourceOption === current;
|
||||
option.dataset.active = active ? "true" : "false";
|
||||
option.setAttribute("aria-pressed", String(active));
|
||||
});
|
||||
panels.forEach((panel) => {
|
||||
panel.hidden = panel.dataset.nodedcMediaSourcePanel !== current;
|
||||
});
|
||||
};
|
||||
|
||||
const setSource = (next: MediaSource) => {
|
||||
if (next === current) return;
|
||||
current = next;
|
||||
render();
|
||||
onChange?.(current);
|
||||
};
|
||||
|
||||
const handlers = options.map((option) => {
|
||||
const handler = () => {
|
||||
const next = option.dataset.nodedcMediaSourceOption;
|
||||
if (next === "file" || next === "url") setSource(next);
|
||||
};
|
||||
option.addEventListener("click", handler);
|
||||
return { option, handler };
|
||||
});
|
||||
|
||||
render();
|
||||
|
||||
return {
|
||||
getSource: () => current,
|
||||
setSource,
|
||||
destroy: () => handlers.forEach(({ option, handler }) => option.removeEventListener("click", handler)),
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createFloatingLayer, type FloatingLayerController, type FloatingLayerOptions } from "./floating";
|
||||
import { createFloatingLayer, type FloatingLayerController, type FloatingLayerOptions } from "./floating.js";
|
||||
|
||||
export interface SelectControllerOptions<T extends string> extends Omit<FloatingLayerOptions, "closeOnSelect"> {
|
||||
value: T;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nodedc/ui-react",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.0",
|
||||
"type": "module",
|
||||
"files": ["dist"],
|
||||
"main": "./dist/index.js",
|
||||
@@ -16,7 +16,7 @@
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nodedc/ui-core": "0.3.0",
|
||||
"@nodedc/ui-core": "0.4.0",
|
||||
"lucide-react": "^0.468.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { HTMLAttributes, ReactNode } from "react";
|
||||
import { cn } from "./cn";
|
||||
import { cn } from "./cn.js";
|
||||
|
||||
export interface AdminNavigationContext {
|
||||
id: string;
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import type { ButtonHTMLAttributes, HTMLAttributes, ReactNode } from "react";
|
||||
import { cn } from "./cn";
|
||||
import type { ButtonHTMLAttributes, ReactNode } from "react";
|
||||
|
||||
export interface AppHeaderProps extends HTMLAttributes<HTMLElement> {
|
||||
export interface AppHeaderProps {
|
||||
brand: ReactNode;
|
||||
brandHref?: string;
|
||||
brandLabel?: string;
|
||||
fixed?: boolean;
|
||||
left?: ReactNode;
|
||||
center?: ReactNode;
|
||||
right?: ReactNode;
|
||||
@@ -15,12 +13,9 @@ export function AppHeader({
|
||||
brand,
|
||||
brandHref,
|
||||
brandLabel = "NODE.DC",
|
||||
fixed = true,
|
||||
left,
|
||||
center,
|
||||
right,
|
||||
className,
|
||||
...props
|
||||
}: AppHeaderProps) {
|
||||
const brandNode = brandHref ? (
|
||||
<a className="nodedc-header__brand" href={brandHref} aria-label={brandLabel}>{brand}</a>
|
||||
@@ -29,45 +24,87 @@ export function AppHeader({
|
||||
);
|
||||
|
||||
return (
|
||||
<header className={cn("nodedc-header-shell", className)} data-fixed={fixed ? "true" : undefined} {...props}>
|
||||
<header className="nodedc-header-shell" data-fixed="true" data-preset="launcher">
|
||||
<div className="nodedc-header">
|
||||
<div className="nodedc-header__left">{brandNode}{left}</div>
|
||||
<div className="nodedc-header__center">{center}</div>
|
||||
<div className="nodedc-header__right">{right}</div>
|
||||
<div className="nodedc-header__row">
|
||||
<div className="nodedc-header__left">{brandNode}{left}</div>
|
||||
<div className="nodedc-header__center">{center}</div>
|
||||
<div className="nodedc-header__right">{right}</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export function HeaderProfile({ children, className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("nodedc-header__profile", className)} {...props}>{children}</div>;
|
||||
export interface HeaderNavigationItem<T extends string> {
|
||||
value: T;
|
||||
label: ReactNode;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function HeaderProfileButton({ className, children, type = "button", ...props }: ButtonHTMLAttributes<HTMLButtonElement>) {
|
||||
return <button type={type} className={cn("nodedc-header__profile-button", className)} {...props}>{children}</button>;
|
||||
export interface HeaderNavigationProps<T extends string> {
|
||||
label: string;
|
||||
value?: T;
|
||||
items: readonly HeaderNavigationItem<T>[];
|
||||
onChange: (value: T) => void;
|
||||
}
|
||||
|
||||
export interface HeaderAvatarProps extends HTMLAttributes<HTMLSpanElement> {
|
||||
export function HeaderNavigation<T extends string>({ label, value, items, onChange }: HeaderNavigationProps<T>) {
|
||||
return (
|
||||
<nav className="nodedc-header-navigation" aria-label={label}>
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={item.value}
|
||||
type="button"
|
||||
className="nodedc-header__nav-item"
|
||||
data-active={item.value === value ? "true" : undefined}
|
||||
aria-current={item.value === value ? "page" : undefined}
|
||||
disabled={item.disabled}
|
||||
onClick={() => onChange(item.value)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
export interface HeaderProfileProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function HeaderProfile({ children }: HeaderProfileProps) {
|
||||
return <div className="nodedc-header__profile">{children}</div>;
|
||||
}
|
||||
|
||||
export interface HeaderProfileButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "className" | "style"> {}
|
||||
|
||||
export function HeaderProfileButton({ children, type = "button", ...props }: HeaderProfileButtonProps) {
|
||||
return <button type={type} className="nodedc-header__profile-button" {...props}>{children}</button>;
|
||||
}
|
||||
|
||||
export interface HeaderAvatarProps {
|
||||
label: string;
|
||||
imageUrl?: string;
|
||||
}
|
||||
|
||||
export function HeaderAvatar({ label, imageUrl, className, ...props }: HeaderAvatarProps) {
|
||||
export function HeaderAvatar({ label, imageUrl }: HeaderAvatarProps) {
|
||||
return (
|
||||
<span className={cn("nodedc-header__avatar", className)} title={label} {...props}>
|
||||
<span className="nodedc-header__avatar" title={label}>
|
||||
{imageUrl ? <img src={imageUrl} alt="" /> : label.slice(0, 2).toUpperCase()}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export interface HeaderWorkspaceProps extends HTMLAttributes<HTMLSpanElement> {
|
||||
export interface HeaderWorkspaceProps {
|
||||
label: string;
|
||||
imageUrl?: string;
|
||||
kind?: "mark" | "avatar";
|
||||
}
|
||||
|
||||
export function HeaderWorkspace({ label, imageUrl, className, ...props }: HeaderWorkspaceProps) {
|
||||
export function HeaderWorkspace({ label, imageUrl, kind = "mark" }: HeaderWorkspaceProps) {
|
||||
return (
|
||||
<span className={cn("nodedc-header__workspace", className)} title={label} {...props}>
|
||||
<span className="nodedc-header__workspace" data-kind={kind} title={label}>
|
||||
{imageUrl ? <img src={imageUrl} alt="" /> : label.slice(0, 2).toUpperCase()}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { HTMLAttributes, ReactNode } from "react";
|
||||
import { cn } from "./cn";
|
||||
import { Icon } from "./Icon";
|
||||
import { cn } from "./cn.js";
|
||||
import { Icon } from "./Icon.js";
|
||||
|
||||
export interface ApplicationShellProps extends Omit<HTMLAttributes<HTMLDivElement>, "content"> {
|
||||
header: ReactNode;
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
export interface ApplicationWorkspaceState<ViewId extends string> {
|
||||
navigationOpen: boolean;
|
||||
activeView: ViewId | null;
|
||||
contentOpen: boolean;
|
||||
contentExpanded: boolean;
|
||||
}
|
||||
|
||||
export interface ApplicationWorkspaceController<ViewId extends string> extends ApplicationWorkspaceState<ViewId> {
|
||||
openNavigation: () => void;
|
||||
toggleNavigation: () => void;
|
||||
closeNavigation: () => void;
|
||||
openView: (view: ViewId) => void;
|
||||
closeView: () => void;
|
||||
setContentExpanded: (expanded: boolean) => void;
|
||||
}
|
||||
|
||||
export interface ApplicationWorkspaceInitialState<ViewId extends string> {
|
||||
navigationOpen?: boolean;
|
||||
activeView?: ViewId | null;
|
||||
contentExpanded?: boolean;
|
||||
}
|
||||
|
||||
/** Owns the Launcher-derived navigation/content-window state machine. */
|
||||
export function useApplicationWorkspace<ViewId extends string>({
|
||||
navigationOpen: initialNavigationOpen = false,
|
||||
activeView: initialActiveView = null,
|
||||
contentExpanded: initialContentExpanded = true,
|
||||
}: ApplicationWorkspaceInitialState<ViewId> = {}): ApplicationWorkspaceController<ViewId> {
|
||||
const [navigationOpen, setNavigationOpen] = useState(initialNavigationOpen || initialActiveView !== null);
|
||||
const [activeView, setActiveView] = useState<ViewId | null>(initialActiveView);
|
||||
const [contentExpanded, setContentExpanded] = useState(initialContentExpanded);
|
||||
|
||||
const openNavigation = useCallback(() => setNavigationOpen(true), []);
|
||||
const closeView = useCallback(() => setActiveView(null), []);
|
||||
const closeNavigation = useCallback(() => {
|
||||
setNavigationOpen(false);
|
||||
setActiveView(null);
|
||||
}, []);
|
||||
const toggleNavigation = useCallback(() => {
|
||||
if (navigationOpen) {
|
||||
setNavigationOpen(false);
|
||||
setActiveView(null);
|
||||
} else {
|
||||
setNavigationOpen(true);
|
||||
}
|
||||
}, [navigationOpen]);
|
||||
const openView = useCallback((view: ViewId) => {
|
||||
setNavigationOpen(true);
|
||||
setActiveView(view);
|
||||
setContentExpanded(true);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
navigationOpen,
|
||||
activeView,
|
||||
contentOpen: activeView !== null,
|
||||
contentExpanded,
|
||||
openNavigation,
|
||||
toggleNavigation,
|
||||
closeNavigation,
|
||||
openView,
|
||||
closeView,
|
||||
setContentExpanded,
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from "react";
|
||||
import { createAccentVariables, type RgbTuple } from "@nodedc/ui-core";
|
||||
import { cn } from "./cn";
|
||||
import { cn } from "./cn.js";
|
||||
|
||||
export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger" | "accent";
|
||||
export type ButtonSize = "default" | "compact";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ButtonHTMLAttributes } from "react";
|
||||
import { cn } from "./cn";
|
||||
import { cn } from "./cn.js";
|
||||
|
||||
export interface CheckerProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "onChange"> {
|
||||
checked: boolean;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { Button } from "./Button";
|
||||
import { Window, WindowFooterActions } from "./Window";
|
||||
import { Button } from "./Button.js";
|
||||
import { Window, WindowFooterActions } from "./Window.js";
|
||||
|
||||
export interface ConfirmationModalProps {
|
||||
open: boolean;
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { computeFloatingPosition, type FloatingPlacement } from "@nodedc/ui-core";
|
||||
import { cn } from "./cn";
|
||||
import { cn } from "./cn.js";
|
||||
|
||||
export interface DropdownTriggerApi {
|
||||
open: boolean;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useId, type InputHTMLAttributes, type ReactNode, type TextareaHTMLAttributes } from "react";
|
||||
import { cn } from "./cn";
|
||||
import { cn } from "./cn.js";
|
||||
|
||||
export interface FieldFrameProps {
|
||||
label: string;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { HTMLAttributes, ReactNode } from "react";
|
||||
import { cn } from "./cn";
|
||||
import { cn } from "./cn.js";
|
||||
|
||||
export type GlassTone = "default" | "strong" | "soft";
|
||||
export type GlassRadius = "card" | "panel" | "modal";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo, useState, type ReactNode } from "react";
|
||||
import { cn } from "./cn";
|
||||
import { cn } from "./cn.js";
|
||||
|
||||
export interface InspectorSectionSpec {
|
||||
id: string;
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useId, type ChangeEvent, type ReactNode } from "react";
|
||||
import { Icon } from "./Icon.js";
|
||||
|
||||
export type MediaSource = "file" | "url";
|
||||
export type MediaPreviewKind = "image" | "gif" | "video";
|
||||
|
||||
export interface MediaSourceFieldProps {
|
||||
label: string;
|
||||
kindLabel?: string;
|
||||
source: MediaSource;
|
||||
url: string;
|
||||
fileName?: string | null;
|
||||
uploading?: boolean;
|
||||
previewSrc?: string | null;
|
||||
previewKind?: MediaPreviewKind | null;
|
||||
accept?: string;
|
||||
fileButtonLabel?: string;
|
||||
emptyFileLabel?: string;
|
||||
path?: ReactNode;
|
||||
hint?: ReactNode;
|
||||
error?: ReactNode;
|
||||
onSourceChange: (source: MediaSource) => void;
|
||||
onUrlChange: (url: string) => void;
|
||||
onFileChange: (file?: File) => void | Promise<void>;
|
||||
}
|
||||
|
||||
function inferredPreviewKind(src: string): MediaPreviewKind {
|
||||
if (/\.(mp4|webm|mov|m4v|avi|mkv)(\?.*)?$/i.test(src)) return "video";
|
||||
if (/\.gif(\?.*)?$/i.test(src)) return "gif";
|
||||
return "image";
|
||||
}
|
||||
|
||||
function MediaPreview({ src, kind }: { src: string; kind?: MediaPreviewKind | null }) {
|
||||
const resolvedKind = kind ?? inferredPreviewKind(src);
|
||||
if (resolvedKind === "video") return <video src={src} autoPlay loop muted playsInline />;
|
||||
return <img src={src} alt="" />;
|
||||
}
|
||||
|
||||
export function MediaSourceField({
|
||||
label,
|
||||
kindLabel = "media",
|
||||
source,
|
||||
url,
|
||||
fileName,
|
||||
uploading = false,
|
||||
previewSrc,
|
||||
previewKind,
|
||||
accept = "image/*,video/*",
|
||||
fileButtonLabel = "Выберите файл",
|
||||
emptyFileLabel = "Файл не выбран",
|
||||
path,
|
||||
hint,
|
||||
error,
|
||||
onSourceChange,
|
||||
onUrlChange,
|
||||
onFileChange,
|
||||
}: MediaSourceFieldProps) {
|
||||
const inputId = useId();
|
||||
const displayFileName = uploading ? "Сохраняем в storage..." : (fileName || emptyFileLabel);
|
||||
|
||||
const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
void onFileChange(event.currentTarget.files?.[0]);
|
||||
event.currentTarget.value = "";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="nodedc-media-field">
|
||||
<div className="nodedc-media-field__label-row">
|
||||
<span className="nodedc-media-field__label">{label}</span>
|
||||
<span className="nodedc-media-field__kind">{kindLabel}</span>
|
||||
</div>
|
||||
<div className="nodedc-media-control" data-source={source}>
|
||||
<div className="nodedc-media-file" hidden={source !== "file"} data-nodedc-media-source-panel="file">
|
||||
<label className="nodedc-media-file__button" htmlFor={inputId}>{fileButtonLabel}</label>
|
||||
<span className="nodedc-media-file__name" title={displayFileName}>{displayFileName}</span>
|
||||
<input id={inputId} type="file" accept={accept} disabled={uploading} onChange={handleFileChange} />
|
||||
</div>
|
||||
<input
|
||||
className="nodedc-media-url"
|
||||
type="url"
|
||||
value={url}
|
||||
hidden={source !== "url"}
|
||||
data-nodedc-media-source-panel="url"
|
||||
placeholder="https://..."
|
||||
autoComplete="off"
|
||||
aria-label={`${label}: внешняя ссылка`}
|
||||
onChange={(event) => onUrlChange(event.target.value)}
|
||||
/>
|
||||
<div className="nodedc-media-source-switch" aria-label={`${label}: источник`}>
|
||||
<button
|
||||
type="button"
|
||||
className="nodedc-media-source-button"
|
||||
data-active={source === "file" ? "true" : undefined}
|
||||
data-nodedc-media-source-option="file"
|
||||
aria-label="Файл с диска"
|
||||
aria-pressed={source === "file"}
|
||||
onClick={() => onSourceChange("file")}
|
||||
>HD</button>
|
||||
<button
|
||||
type="button"
|
||||
className="nodedc-media-source-button"
|
||||
data-active={source === "url" ? "true" : undefined}
|
||||
data-nodedc-media-source-option="url"
|
||||
aria-label="Внешняя ссылка"
|
||||
aria-pressed={source === "url"}
|
||||
onClick={() => onSourceChange("url")}
|
||||
>URL</button>
|
||||
</div>
|
||||
<div className="nodedc-media-preview" aria-hidden="true">
|
||||
{previewSrc ? <MediaPreview src={previewSrc} kind={previewKind} /> : <Icon name="image" size={16} />}
|
||||
</div>
|
||||
</div>
|
||||
{path ? <span className="nodedc-media-field__path">{path}</span> : null}
|
||||
{hint ? <span className="nodedc-media-field__hint">{hint}</span> : null}
|
||||
{error ? <span className="nodedc-media-field__error" role="alert">{error}</span> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { CSSProperties, InputHTMLAttributes } from "react";
|
||||
import { cn } from "./cn";
|
||||
import { cn } from "./cn.js";
|
||||
|
||||
export interface RangeControlProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "value" | "onChange"> {
|
||||
label: string;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "./cn";
|
||||
import { cn } from "./cn.js";
|
||||
|
||||
export interface SegmentedItem<T extends string> {
|
||||
value: T;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMemo, useState, type KeyboardEvent, type ReactNode } from "react";
|
||||
import type { FloatingPlacement } from "@nodedc/ui-core";
|
||||
import { Dropdown } from "./Dropdown";
|
||||
import { cn } from "./cn";
|
||||
import { Dropdown } from "./Dropdown.js";
|
||||
import { cn } from "./cn.js";
|
||||
|
||||
export interface SelectOption<T extends string> {
|
||||
value: T;
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { HTMLAttributes, ReactNode } from "react";
|
||||
import { cn } from "./cn.js";
|
||||
|
||||
export interface SettingsCardProps extends Omit<HTMLAttributes<HTMLElement>, "title"> {
|
||||
eyebrow?: ReactNode;
|
||||
title: ReactNode;
|
||||
description?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
}
|
||||
|
||||
export function SettingsCard({ eyebrow, title, description, actions, children, className, ...props }: SettingsCardProps) {
|
||||
return (
|
||||
<section className={cn("nodedc-settings-card", className)} {...props}>
|
||||
<header className="nodedc-settings-card__head">
|
||||
<div className="nodedc-settings-card__titles">
|
||||
{eyebrow ? <span>{eyebrow}</span> : null}
|
||||
<h2>{title}</h2>
|
||||
{description ? <p>{description}</p> : null}
|
||||
</div>
|
||||
{actions ? <div className="nodedc-settings-card__actions">{actions}</div> : null}
|
||||
</header>
|
||||
<div className="nodedc-settings-card__body">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export interface SwitchProps {
|
||||
checked: boolean;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
}
|
||||
|
||||
export function Switch({ checked, label, disabled = false, onChange }: SwitchProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="nodedc-switch"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(!checked)}
|
||||
>
|
||||
<span className="nodedc-switch__track" aria-hidden="true"><span /></span>
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { HTMLAttributes } from "react";
|
||||
import { cn } from "./cn";
|
||||
import { cn } from "./cn.js";
|
||||
|
||||
export type StatusTone = "neutral" | "success" | "warning" | "danger" | "accent";
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { cn } from "./cn";
|
||||
import { cn } from "./cn.js";
|
||||
|
||||
const focusableSelector = [
|
||||
"a[href]",
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
export * from "./AppHeader";
|
||||
export * from "./AdminNavigationPanel";
|
||||
export * from "./ApplicationShell";
|
||||
export * from "./Button";
|
||||
export * from "./Checker";
|
||||
export * from "./ConfirmationModal";
|
||||
export * from "./Dropdown";
|
||||
export * from "./Field";
|
||||
export * from "./Glass";
|
||||
export * from "./Inspector";
|
||||
export * from "./Icon";
|
||||
export * from "./RangeControl";
|
||||
export * from "./SegmentedControl";
|
||||
export * from "./Select";
|
||||
export * from "./StatusBadge";
|
||||
export * from "./Window";
|
||||
export * from "./AppHeader.js";
|
||||
export * from "./AdminNavigationPanel.js";
|
||||
export * from "./ApplicationShell.js";
|
||||
export * from "./ApplicationWorkspace.js";
|
||||
export * from "./Button.js";
|
||||
export * from "./Checker.js";
|
||||
export * from "./ConfirmationModal.js";
|
||||
export * from "./Dropdown.js";
|
||||
export * from "./Field.js";
|
||||
export * from "./Glass.js";
|
||||
export * from "./Inspector.js";
|
||||
export * from "./Icon.js";
|
||||
export * from "./MediaSourceField.js";
|
||||
export * from "./RangeControl.js";
|
||||
export * from "./SegmentedControl.js";
|
||||
export * from "./Select.js";
|
||||
export * from "./StatusBadge.js";
|
||||
export * from "./Settings.js";
|
||||
export * from "./Window.js";
|
||||
|
||||
Reference in New Issue
Block a user