@deepfuture/dui-components 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/all.d.ts CHANGED
@@ -44,6 +44,7 @@ import "./textarea/index.js";
44
44
  import "./toggle/index.js";
45
45
  import "./toolbar/index.js";
46
46
  import "./tooltip/index.js";
47
+ import "./tree/index.js";
47
48
  import "./trunc/index.js";
48
49
  export { DuiAccordion } from "./accordion/index.js";
49
50
  export { DuiAccordionItem } from "./accordion/index.js";
@@ -133,6 +134,8 @@ export { DuiToolbar } from "./toolbar/index.js";
133
134
  export { DuiTooltip } from "./tooltip/index.js";
134
135
  export { DuiTooltipPopup } from "./tooltip/index.js";
135
136
  export { DuiTooltipTrigger } from "./tooltip/index.js";
137
+ export { DuiTree } from "./tree/index.js";
138
+ export { DuiTreeItem } from "./tree/index.js";
136
139
  export { DuiTrunc } from "./trunc/index.js";
137
140
  export type { AccordionContext } from "@deepfuture/dui-primitives/accordion";
138
141
  export type { AlertDialogOpenChangeDetail } from "@deepfuture/dui-primitives/alert-dialog";
@@ -159,3 +162,4 @@ export type { TextareaResize } from "@deepfuture/dui-primitives/textarea";
159
162
  export type { ToggleGroupContext } from "@deepfuture/dui-primitives/toggle";
160
163
  export type { TooltipOpenChangeDetail } from "@deepfuture/dui-primitives/tooltip";
161
164
  export type { TooltipContext, TooltipSide } from "@deepfuture/dui-primitives/tooltip";
165
+ export type { SelectionMode, TreeContext } from "@deepfuture/dui-primitives/tree";
package/all.js CHANGED
@@ -44,6 +44,7 @@ import "./textarea/index.js";
44
44
  import "./toggle/index.js";
45
45
  import "./toolbar/index.js";
46
46
  import "./tooltip/index.js";
47
+ import "./tree/index.js";
47
48
  import "./trunc/index.js";
48
49
  export { DuiAccordion } from "./accordion/index.js";
49
50
  export { DuiAccordionItem } from "./accordion/index.js";
@@ -133,4 +134,6 @@ export { DuiToolbar } from "./toolbar/index.js";
133
134
  export { DuiTooltip } from "./tooltip/index.js";
134
135
  export { DuiTooltipPopup } from "./tooltip/index.js";
135
136
  export { DuiTooltipTrigger } from "./tooltip/index.js";
137
+ export { DuiTree } from "./tree/index.js";
138
+ export { DuiTreeItem } from "./tree/index.js";
136
139
  export { DuiTrunc } from "./trunc/index.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deepfuture/dui-components",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "DUI styled web components — extends dui-primitives with design tokens and variant CSS",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -170,6 +170,10 @@
170
170
  "import": "./textarea/index.js",
171
171
  "types": "./textarea/index.d.ts"
172
172
  },
173
+ "./theme": {
174
+ "import": "./theme/index.js",
175
+ "types": "./theme/index.d.ts"
176
+ },
173
177
  "./toggle": {
174
178
  "import": "./toggle/index.js",
175
179
  "types": "./toggle/index.d.ts"
@@ -182,6 +186,10 @@
182
186
  "import": "./tooltip/index.js",
183
187
  "types": "./tooltip/index.d.ts"
184
188
  },
189
+ "./tree": {
190
+ "import": "./tree/index.js",
191
+ "types": "./tree/index.d.ts"
192
+ },
185
193
  "./tokens": {
186
194
  "import": "./tokens/tokens.js",
187
195
  "types": "./tokens/tokens.d.ts"
@@ -206,7 +214,7 @@
206
214
  "README.md"
207
215
  ],
208
216
  "dependencies": {
209
- "@deepfuture/dui-primitives": "1.1.0",
217
+ "@deepfuture/dui-primitives": "1.3.0",
210
218
  "lit": "^3.3.2",
211
219
  "@lit/context": "^1.1.3"
212
220
  },
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Theme configuration API — bridges DESIGN.md intent to DUI runtime tokens.
3
+ *
4
+ * Usage:
5
+ * import { applyTheme } from "@deepfuture/dui-components/theme";
6
+ *
7
+ * applyTheme({
8
+ * light: { accent: "oklch(0.55 0.25 160)" },
9
+ * dark: { accent: "oklch(0.75 0.18 160)" },
10
+ * fonts: { sans: "Inter", mono: "Geist Mono" },
11
+ * radius: "0.5rem",
12
+ * });
13
+ *
14
+ * Call after importing any DUI component. Appends an adopted stylesheet
15
+ * that overrides DUI's defaults in the correct cascade position.
16
+ */
17
+ export type ThemePrimitives = {
18
+ background?: string;
19
+ foreground?: string;
20
+ accent?: string;
21
+ destructive?: string;
22
+ };
23
+ export type ThemeFonts = {
24
+ sans?: string;
25
+ mono?: string;
26
+ serif?: string;
27
+ };
28
+ export type ThemeConfig = {
29
+ /** Light mode color primitives. */
30
+ light?: ThemePrimitives;
31
+ /** Dark mode color primitives. If omitted, dark mode is derived from light. */
32
+ dark?: ThemePrimitives;
33
+ /** Font family overrides. Values are family names (e.g. "Inter"), not full stacks. */
34
+ fonts?: ThemeFonts;
35
+ /** Base border-radius (e.g. "0.5rem" or "8px"). The full scale is derived from this value. */
36
+ radius?: string;
37
+ };
38
+ export declare function applyTheme(config: ThemeConfig): void;
package/theme/index.js ADDED
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Theme configuration API — bridges DESIGN.md intent to DUI runtime tokens.
3
+ *
4
+ * Usage:
5
+ * import { applyTheme } from "@deepfuture/dui-components/theme";
6
+ *
7
+ * applyTheme({
8
+ * light: { accent: "oklch(0.55 0.25 160)" },
9
+ * dark: { accent: "oklch(0.75 0.18 160)" },
10
+ * fonts: { sans: "Inter", mono: "Geist Mono" },
11
+ * radius: "0.5rem",
12
+ * });
13
+ *
14
+ * Call after importing any DUI component. Appends an adopted stylesheet
15
+ * that overrides DUI's defaults in the correct cascade position.
16
+ */
17
+ /**
18
+ * Apply a theme configuration to the document.
19
+ *
20
+ * Creates a CSSStyleSheet and appends it to `document.adoptedStyleSheets`
21
+ * after DUI's token sheet, so overrides cascade correctly.
22
+ *
23
+ * Safe to call multiple times — each call replaces the previous theme sheet.
24
+ */
25
+ let themeSheet = null;
26
+ export function applyTheme(config) {
27
+ const rules = [];
28
+ // --- Color primitives ---
29
+ const lightVars = buildColorVars(config.light);
30
+ const darkVars = buildColorVars(config.dark ?? deriveDark(config.light));
31
+ if (lightVars) {
32
+ rules.push(`:root:not([data-theme="dark"]) { ${lightVars} }`);
33
+ }
34
+ if (darkVars) {
35
+ rules.push(`:root[data-theme="dark"] { ${darkVars} }`);
36
+ }
37
+ // --- Fonts and radius (theme-independent, go on :root) ---
38
+ const rootVars = buildRootVars(config);
39
+ if (rootVars) {
40
+ rules.push(`:root { ${rootVars} }`);
41
+ }
42
+ const css = rules.join("\n");
43
+ if (!css)
44
+ return;
45
+ // Replace previous theme sheet if one exists
46
+ if (themeSheet) {
47
+ const idx = document.adoptedStyleSheets.indexOf(themeSheet);
48
+ if (idx !== -1) {
49
+ document.adoptedStyleSheets = document.adoptedStyleSheets.filter((s) => s !== themeSheet);
50
+ }
51
+ }
52
+ themeSheet = new CSSStyleSheet();
53
+ themeSheet.replaceSync(css);
54
+ document.adoptedStyleSheets = [...document.adoptedStyleSheets, themeSheet];
55
+ }
56
+ // --- Internals ---
57
+ function buildColorVars(primitives) {
58
+ if (!primitives)
59
+ return "";
60
+ const entries = [];
61
+ if (primitives.background)
62
+ entries.push(`--background: ${primitives.background};`);
63
+ if (primitives.foreground)
64
+ entries.push(`--foreground: ${primitives.foreground};`);
65
+ if (primitives.accent)
66
+ entries.push(`--accent: ${primitives.accent};`);
67
+ if (primitives.destructive)
68
+ entries.push(`--destructive: ${primitives.destructive};`);
69
+ return entries.join(" ");
70
+ }
71
+ function buildRootVars(config) {
72
+ const entries = [];
73
+ // Fonts
74
+ if (config.fonts?.sans) {
75
+ entries.push(`--font-sans: '${config.fonts.sans}', system-ui, -apple-system, sans-serif;`);
76
+ }
77
+ if (config.fonts?.mono) {
78
+ entries.push(`--font-mono: '${config.fonts.mono}', ui-monospace, SFMono-Regular, monospace;`);
79
+ }
80
+ if (config.fonts?.serif) {
81
+ entries.push(`--font-serif: '${config.fonts.serif}', ui-serif, Georgia, serif;`);
82
+ }
83
+ // Radius scale derived from base
84
+ if (config.radius) {
85
+ const base = parseRadiusToRem(config.radius);
86
+ if (base !== null) {
87
+ entries.push(`--radius-xs: ${rem(Math.max(base * 0.25, 0))};`);
88
+ entries.push(`--radius-sm: ${rem(Math.max(base * 0.5, 0))};`);
89
+ entries.push(`--radius-md: ${rem(base)};`);
90
+ entries.push(`--radius-lg: ${rem(base * 2)};`);
91
+ entries.push(`--radius-xl: ${rem(base * 3)};`);
92
+ entries.push(`--radius-2xl: ${rem(base * 4)};`);
93
+ }
94
+ }
95
+ return entries.join(" ");
96
+ }
97
+ /**
98
+ * Derive dark mode primitives from light mode values.
99
+ *
100
+ * Strategy: parse OKLCH values and adjust lightness.
101
+ * - background: invert L (0.97 → 0.15), add slight chroma from accent hue
102
+ * - foreground: invert L (0.15 → 0.93)
103
+ * - accent: boost L (+0.20), reduce C slightly
104
+ * - destructive: boost L (+0.15), reduce C slightly
105
+ *
106
+ * Falls back to DUI's built-in dark defaults if parsing fails.
107
+ */
108
+ function deriveDark(light) {
109
+ if (!light)
110
+ return undefined;
111
+ const dark = {};
112
+ if (light.background) {
113
+ const parsed = parseOklch(light.background);
114
+ if (parsed) {
115
+ // Invert: light bg → dark bg. Add a hint of accent hue chroma.
116
+ const accentParsed = light.accent ? parseOklch(light.accent) : null;
117
+ const hue = accentParsed?.h ?? 0;
118
+ dark.background = `oklch(${(1 - parsed.l).toFixed(2)} 0.015 ${hue})`;
119
+ }
120
+ }
121
+ if (light.foreground) {
122
+ const parsed = parseOklch(light.foreground);
123
+ if (parsed) {
124
+ dark.foreground = `oklch(${(1 - parsed.l + 0.08).toFixed(2)} 0 0)`;
125
+ }
126
+ }
127
+ if (light.accent) {
128
+ const parsed = parseOklch(light.accent);
129
+ if (parsed) {
130
+ dark.accent = `oklch(${Math.min(parsed.l + 0.20, 0.90).toFixed(2)} ${Math.max(parsed.c - 0.07, 0.05).toFixed(2)} ${parsed.h})`;
131
+ }
132
+ }
133
+ if (light.destructive) {
134
+ const parsed = parseOklch(light.destructive);
135
+ if (parsed) {
136
+ dark.destructive = `oklch(${Math.min(parsed.l + 0.15, 0.85).toFixed(2)} ${Math.max(parsed.c - 0.04, 0.05).toFixed(2)} ${parsed.h})`;
137
+ }
138
+ }
139
+ return Object.keys(dark).length > 0 ? dark : undefined;
140
+ }
141
+ /** Parse an oklch(...) string into { l, c, h } numbers. */
142
+ function parseOklch(value) {
143
+ const match = value.match(/oklch\(\s*([\d.]+)\s+([\d.]+)\s+([\d.]+)\s*\)/);
144
+ if (!match)
145
+ return null;
146
+ return {
147
+ l: parseFloat(match[1]),
148
+ c: parseFloat(match[2]),
149
+ h: parseFloat(match[3]),
150
+ };
151
+ }
152
+ /** Parse a radius value (rem or px) to rem number. */
153
+ function parseRadiusToRem(value) {
154
+ const remMatch = value.match(/^([\d.]+)\s*rem$/);
155
+ if (remMatch)
156
+ return parseFloat(remMatch[1]);
157
+ const pxMatch = value.match(/^([\d.]+)\s*px$/);
158
+ if (pxMatch)
159
+ return parseFloat(pxMatch[1]) / 16;
160
+ return null;
161
+ }
162
+ /** Format a number as a rem string. */
163
+ function rem(value) {
164
+ return value === 0 ? "0" : `${Number(value.toFixed(4))}rem`;
165
+ }
@@ -0,0 +1,6 @@
1
+ import "./tree.js";
2
+ import "./tree-item.js";
3
+ export { DuiTree } from "./tree.js";
4
+ export { DuiTreeItem } from "./tree-item.js";
5
+ export type { SelectionMode, TreeContext } from "@deepfuture/dui-primitives/tree";
6
+ export { actionEvent, expandedChangeEvent, loadChildrenEvent, selectionChangeEvent, } from "@deepfuture/dui-primitives/tree";
package/tree/index.js ADDED
@@ -0,0 +1,5 @@
1
+ import "./tree.js";
2
+ import "./tree-item.js";
3
+ export { DuiTree } from "./tree.js";
4
+ export { DuiTreeItem } from "./tree-item.js";
5
+ export { actionEvent, expandedChangeEvent, loadChildrenEvent, selectionChangeEvent, } from "@deepfuture/dui-primitives/tree";
@@ -0,0 +1,5 @@
1
+ import { DuiTreeItemPrimitive } from "@deepfuture/dui-primitives/tree";
2
+ import "../_install.js";
3
+ export declare class DuiTreeItem extends DuiTreeItemPrimitive {
4
+ static styles: import("lit").CSSResult[];
5
+ }
@@ -0,0 +1,144 @@
1
+ import { css, unsafeCSS } from "lit";
2
+ import { DuiTreeItemPrimitive } from "@deepfuture/dui-primitives/tree";
3
+ import "../_install.js";
4
+ // Lucide chevron-right, encoded for use as a CSS mask
5
+ const chevronMask = unsafeCSS(`url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m9 18 6-6-6-6'/%3E%3C/svg%3E")`);
6
+ // Lucide loader-2 (rotating arc), used as a spinner mask while loading
7
+ const spinnerMask = unsafeCSS(`url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M21 12a9 9 0 1 1-6.219-8.56'/%3E%3C/svg%3E")`);
8
+ const styles = css `
9
+ /* ── Row container ─────────────────────────────────────────────────── */
10
+
11
+ /*
12
+ * IMPORTANT: All visual styling lives on [part="content"], NOT [part="root"].
13
+ * [part="root"] wraps both the row and the children group, so :hover on it
14
+ * would bleed onto descendant rows. See the hover-bleed regression demo.
15
+ */
16
+ [part="content"] {
17
+ height: var(--dui-tree-row-height);
18
+ padding-inline-start: calc(
19
+ var(--_tree-row-px) +
20
+ (var(--dui-tree-level, 1) - 1) * var(--dui-tree-indent)
21
+ );
22
+ padding-inline-end: var(--_tree-row-px);
23
+ margin-block: calc(var(--dui-tree-row-spacing) / 2);
24
+ margin-inline: var(--space-1);
25
+ border-radius: var(--dui-tree-row-radius);
26
+ gap: var(--_tree-inline-gap);
27
+ color: var(--text-1);
28
+ font-family: var(--font-sans);
29
+ font-size: var(--_tree-label-font-size);
30
+ line-height: var(--_tree-label-line-height);
31
+ cursor: pointer;
32
+ transition-property: background, box-shadow, color;
33
+ transition-duration: var(--duration-faster);
34
+ transition-timing-function: var(--ease-out-3);
35
+ /* Allow children to truncate */
36
+ min-width: 0;
37
+ }
38
+
39
+ @media (hover: hover) {
40
+ [part="content"]:hover {
41
+ background: var(--dui-tree-hover-bg);
42
+ }
43
+ }
44
+
45
+ :host([data-selected]) [part="content"] {
46
+ background: var(--dui-tree-selected-bg);
47
+ }
48
+
49
+ :host([data-disabled]) [part="content"] {
50
+ opacity: 0.4;
51
+ cursor: not-allowed;
52
+ }
53
+
54
+ /* ── Focus ring ─────────────────────────────────────────────────────── */
55
+
56
+ [part="root"]:focus-visible {
57
+ outline: none;
58
+ }
59
+
60
+ [part="root"]:focus-visible [part="content"] {
61
+ box-shadow:
62
+ 0 0 0 var(--focus-ring-offset) var(--background),
63
+ 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width))
64
+ var(--focus-ring-color);
65
+ /* Keep the highlighted row above siblings so the ring isn't clipped. */
66
+ position: relative;
67
+ z-index: 1;
68
+ }
69
+
70
+ /* ── Indicator (chevron / spinner / leaf placeholder) ─────────────── */
71
+
72
+ [part="indicator"] {
73
+ width: var(--_tree-indicator-size);
74
+ height: var(--_tree-indicator-size);
75
+ color: var(--text-2);
76
+ }
77
+
78
+ /* Branch chevron */
79
+ :host([data-branch]) [part="indicator"]::before {
80
+ content: "";
81
+ display: block;
82
+ width: 100%;
83
+ height: 100%;
84
+ background: currentColor;
85
+ -webkit-mask: ${chevronMask} center / contain no-repeat;
86
+ mask: ${chevronMask} center / contain no-repeat;
87
+ transition-property: transform;
88
+ transition-duration: var(--duration-fast);
89
+ transition-timing-function: var(--ease-out-3);
90
+ }
91
+
92
+ :host([data-expanded]) [part="indicator"]::before {
93
+ transform: rotate(90deg);
94
+ }
95
+
96
+ /* Loading spinner replaces the chevron in-place */
97
+ :host([data-loading]) [part="indicator"]::before {
98
+ -webkit-mask: ${spinnerMask} center / contain no-repeat;
99
+ mask: ${spinnerMask} center / contain no-repeat;
100
+ transform: none;
101
+ animation: dui-tree-spin 0.9s linear infinite;
102
+ }
103
+
104
+ @keyframes dui-tree-spin {
105
+ from {
106
+ transform: rotate(0deg);
107
+ }
108
+ to {
109
+ transform: rotate(360deg);
110
+ }
111
+ }
112
+
113
+ /* ── Slotted content ────────────────────────────────────────────────── */
114
+
115
+ ::slotted([slot="label"]) {
116
+ min-width: 0;
117
+ overflow: hidden;
118
+ text-overflow: ellipsis;
119
+ white-space: nowrap;
120
+ }
121
+
122
+ ::slotted([slot="end"]) {
123
+ flex-shrink: 0;
124
+ color: var(--text-2);
125
+ font-size: var(--_tree-end-font-size);
126
+ line-height: var(--_tree-end-line-height);
127
+ }
128
+
129
+ /* ── Reduced motion ────────────────────────────────────────────────── */
130
+
131
+ @media (prefers-reduced-motion: reduce) {
132
+ [part="content"],
133
+ :host([data-branch]) [part="indicator"]::before {
134
+ transition-duration: 0s;
135
+ }
136
+ :host([data-loading]) [part="indicator"]::before {
137
+ animation: none;
138
+ }
139
+ }
140
+ `;
141
+ export class DuiTreeItem extends DuiTreeItemPrimitive {
142
+ static styles = [...DuiTreeItemPrimitive.styles, styles];
143
+ }
144
+ customElements.define(DuiTreeItem.tagName, DuiTreeItem);
package/tree/tree.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ import { DuiTreePrimitive } from "@deepfuture/dui-primitives/tree";
2
+ import "../_install.js";
3
+ export declare class DuiTree extends DuiTreePrimitive {
4
+ static styles: import("lit").CSSResult[];
5
+ }
package/tree/tree.js ADDED
@@ -0,0 +1,51 @@
1
+ import { css } from "lit";
2
+ import { DuiTreePrimitive } from "@deepfuture/dui-primitives/tree";
3
+ import "../_install.js";
4
+ const styles = css `
5
+ /* ── Sizes — write to public tokens on :host so consumer overrides win ── */
6
+
7
+ :host {
8
+ /* Default = sm */
9
+ --dui-tree-row-height: var(--component-height-sm);
10
+ --dui-tree-indent: var(--space-4);
11
+ --dui-tree-row-spacing: 0;
12
+ --dui-tree-row-radius: var(--radius-sm);
13
+ --dui-tree-hover-bg: oklch(from var(--foreground) l c h / 0.05);
14
+ --dui-tree-selected-bg: oklch(from var(--foreground) l c h / 0.10);
15
+
16
+ /* Internal-only (not part of public token surface) */
17
+ --_tree-label-font-size: var(--text-xs);
18
+ --_tree-label-line-height: var(--text-xs--line-height);
19
+ --_tree-end-font-size: var(--text-2xs);
20
+ --_tree-end-line-height: var(--text-2xs--line-height);
21
+ --_tree-indicator-size: 14px;
22
+ --_tree-row-px: var(--space-2);
23
+ --_tree-inline-gap: var(--space-2);
24
+ }
25
+
26
+ :host([size="md"]) {
27
+ --dui-tree-row-height: var(--component-height-md);
28
+ --dui-tree-indent: var(--space-5);
29
+ --_tree-label-font-size: var(--text-sm);
30
+ --_tree-label-line-height: var(--text-sm--line-height);
31
+ --_tree-end-font-size: var(--text-xs);
32
+ --_tree-end-line-height: var(--text-xs--line-height);
33
+ --_tree-indicator-size: var(--space-4);
34
+ --_tree-inline-gap: var(--space-2);
35
+ }
36
+
37
+ :host([size="lg"]) {
38
+ --dui-tree-row-height: var(--component-height-lg);
39
+ --dui-tree-indent: var(--space-6);
40
+ --_tree-label-font-size: var(--text-sm);
41
+ --_tree-label-line-height: var(--text-sm--line-height);
42
+ --_tree-end-font-size: var(--text-xs);
43
+ --_tree-end-line-height: var(--text-xs--line-height);
44
+ --_tree-indicator-size: var(--space-4);
45
+ --_tree-inline-gap: var(--space-2_5);
46
+ }
47
+ `;
48
+ export class DuiTree extends DuiTreePrimitive {
49
+ static styles = [...DuiTreePrimitive.styles, styles];
50
+ }
51
+ customElements.define(DuiTree.tagName, DuiTree);