nanoui 0.4.0 → 0.6.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.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +51 -0
  3. data/README.md +353 -15
  4. data/lib/generators/nanoui/component_generator.rb +49 -14
  5. data/lib/generators/nanoui/templates/css/components/accordion.css +58 -58
  6. data/lib/generators/nanoui/templates/css/components/alert.css +56 -56
  7. data/lib/generators/nanoui/templates/css/components/avatar.css +88 -0
  8. data/lib/generators/nanoui/templates/css/components/badge.css +30 -30
  9. data/lib/generators/nanoui/templates/css/components/breadcrumb.css +54 -0
  10. data/lib/generators/nanoui/templates/css/components/button.css +136 -115
  11. data/lib/generators/nanoui/templates/css/components/card.css +35 -35
  12. data/lib/generators/nanoui/templates/css/components/checkbox.css +74 -72
  13. data/lib/generators/nanoui/templates/css/components/checklist.css +133 -0
  14. data/lib/generators/nanoui/templates/css/components/code.css +80 -0
  15. data/lib/generators/nanoui/templates/css/components/copy.css +71 -0
  16. data/lib/generators/nanoui/templates/css/components/dialog.css +103 -103
  17. data/lib/generators/nanoui/templates/css/components/dropdown.css +71 -71
  18. data/lib/generators/nanoui/templates/css/components/empty.css +68 -0
  19. data/lib/generators/nanoui/templates/css/components/input.css +72 -83
  20. data/lib/generators/nanoui/templates/css/components/label.css +11 -5
  21. data/lib/generators/nanoui/templates/css/components/navbar.css +128 -0
  22. data/lib/generators/nanoui/templates/css/components/progress.css +53 -47
  23. data/lib/generators/nanoui/templates/css/components/radio.css +73 -71
  24. data/lib/generators/nanoui/templates/css/components/select.css +49 -22
  25. data/lib/generators/nanoui/templates/css/components/sidebar.css +145 -0
  26. data/lib/generators/nanoui/templates/css/components/skeleton.css +49 -0
  27. data/lib/generators/nanoui/templates/css/components/stat.css +91 -0
  28. data/lib/generators/nanoui/templates/css/components/switch.css +25 -25
  29. data/lib/generators/nanoui/templates/css/components/table.css +169 -29
  30. data/lib/generators/nanoui/templates/css/components/tabs.css +20 -20
  31. data/lib/generators/nanoui/templates/css/components/timeline.css +141 -0
  32. data/lib/generators/nanoui/templates/css/components/toast.css +119 -68
  33. data/lib/generators/nanoui/templates/css/components/tooltip.css +79 -72
  34. data/lib/generators/nanoui/templates/css/components/upload.css +161 -0
  35. data/lib/generators/nanoui/templates/css/layout/container.css +6 -6
  36. data/lib/generators/nanoui/templates/js/controllers/copy_controller.js +64 -0
  37. data/lib/generators/nanoui/templates/js/controllers/data_table_controller.js +75 -0
  38. data/lib/generators/nanoui/templates/js/controllers/navbar_controller.js +70 -0
  39. data/lib/generators/nanoui/templates/js/controllers/sidebar_controller.js +27 -0
  40. data/lib/generators/nanoui/templates/js/controllers/upload_controller.js +137 -0
  41. data/lib/nanoui/version.rb +1 -1
  42. metadata +47 -2
@@ -1,87 +1,94 @@
1
1
  .nano-tooltip {
2
2
  position: relative;
3
3
  display: inline-flex;
4
- }
5
4
 
6
- .nano-tooltip__content {
7
- position: absolute;
8
- z-index: var(--z-tooltip);
9
- padding: var(--space-1-5) var(--space-3);
10
- font-size: var(--text-xs);
11
- line-height: var(--leading-normal);
12
- white-space: nowrap;
13
- color: hsl(var(--color-primary-foreground));
14
- background-color: hsl(var(--color-foreground));
15
- border-radius: var(--radius-md);
16
- box-shadow: var(--shadow-md);
17
- pointer-events: none;
18
- opacity: 0;
19
- transition: opacity var(--duration-fast) var(--ease-default);
20
- }
5
+ .nano-tooltip__content {
6
+ position: absolute;
7
+ z-index: var(--z-tooltip);
8
+ padding: var(--space-1-5) var(--space-3);
9
+ font-size: var(--text-xs);
10
+ line-height: var(--leading-normal);
11
+ white-space: nowrap;
12
+ color: hsl(var(--color-primary-foreground));
13
+ background-color: hsl(var(--color-foreground));
14
+ border-radius: var(--radius-md);
15
+ box-shadow: var(--shadow-md);
16
+ pointer-events: none;
17
+ opacity: 0;
18
+ transition: opacity var(--duration-fast) var(--ease-default);
21
19
 
22
- .nano-tooltip__content.is-open {
23
- opacity: 1;
24
- }
20
+ &.is-open {
21
+ opacity: 1;
22
+ }
25
23
 
26
- .nano-tooltip__content::after {
27
- content: "";
28
- position: absolute;
29
- width: 0.5rem;
30
- height: 0.5rem;
31
- background-color: hsl(var(--color-foreground));
32
- transform: rotate(45deg);
33
- }
24
+ &::after {
25
+ content: "";
26
+ position: absolute;
27
+ width: 0.5rem;
28
+ height: 0.5rem;
29
+ background-color: hsl(var(--color-foreground));
30
+ transform: rotate(45deg);
31
+ }
32
+ }
34
33
 
35
- .nano-tooltip--top .nano-tooltip__content,
36
- .nano-tooltip:not(.nano-tooltip--bottom):not(.nano-tooltip--left):not(.nano-tooltip--right) .nano-tooltip__content {
37
- bottom: 100%;
38
- left: 50%;
39
- transform: translateX(-50%);
40
- margin-bottom: var(--space-2);
41
- }
34
+ &--top,
35
+ &:not(.nano-tooltip--bottom):not(.nano-tooltip--left):not(.nano-tooltip--right) {
36
+ .nano-tooltip__content {
37
+ bottom: 100%;
38
+ left: 50%;
39
+ transform: translateX(-50%);
40
+ margin-bottom: var(--space-2);
42
41
 
43
- .nano-tooltip--top .nano-tooltip__content::after,
44
- .nano-tooltip:not(.nano-tooltip--bottom):not(.nano-tooltip--left):not(.nano-tooltip--right) .nano-tooltip__content::after {
45
- bottom: -0.25rem;
46
- left: 50%;
47
- transform: translateX(-50%) rotate(45deg);
48
- }
42
+ &::after {
43
+ bottom: -0.25rem;
44
+ left: 50%;
45
+ transform: translateX(-50%) rotate(45deg);
46
+ }
47
+ }
48
+ }
49
49
 
50
- .nano-tooltip--bottom .nano-tooltip__content {
51
- top: 100%;
52
- left: 50%;
53
- transform: translateX(-50%);
54
- margin-top: var(--space-2);
55
- }
50
+ &--bottom {
51
+ .nano-tooltip__content {
52
+ top: 100%;
53
+ left: 50%;
54
+ transform: translateX(-50%);
55
+ margin-top: var(--space-2);
56
56
 
57
- .nano-tooltip--bottom .nano-tooltip__content::after {
58
- top: -0.25rem;
59
- left: 50%;
60
- transform: translateX(-50%) rotate(45deg);
61
- }
57
+ &::after {
58
+ top: -0.25rem;
59
+ left: 50%;
60
+ transform: translateX(-50%) rotate(45deg);
61
+ }
62
+ }
63
+ }
62
64
 
63
- .nano-tooltip--left .nano-tooltip__content {
64
- right: 100%;
65
- top: 50%;
66
- transform: translateY(-50%);
67
- margin-right: var(--space-2);
68
- }
65
+ &--left {
66
+ .nano-tooltip__content {
67
+ right: 100%;
68
+ top: 50%;
69
+ transform: translateY(-50%);
70
+ margin-right: var(--space-2);
69
71
 
70
- .nano-tooltip--left .nano-tooltip__content::after {
71
- right: -0.25rem;
72
- top: 50%;
73
- transform: translateY(-50%) rotate(45deg);
74
- }
72
+ &::after {
73
+ right: -0.25rem;
74
+ top: 50%;
75
+ transform: translateY(-50%) rotate(45deg);
76
+ }
77
+ }
78
+ }
75
79
 
76
- .nano-tooltip--right .nano-tooltip__content {
77
- left: 100%;
78
- top: 50%;
79
- transform: translateY(-50%);
80
- margin-left: var(--space-2);
81
- }
80
+ &--right {
81
+ .nano-tooltip__content {
82
+ left: 100%;
83
+ top: 50%;
84
+ transform: translateY(-50%);
85
+ margin-left: var(--space-2);
82
86
 
83
- .nano-tooltip--right .nano-tooltip__content::after {
84
- left: -0.25rem;
85
- top: 50%;
86
- transform: translateY(-50%) rotate(45deg);
87
+ &::after {
88
+ left: -0.25rem;
89
+ top: 50%;
90
+ transform: translateY(-50%) rotate(45deg);
91
+ }
92
+ }
93
+ }
87
94
  }
@@ -0,0 +1,161 @@
1
+ .nano-upload {
2
+ display: flex;
3
+ flex-direction: column;
4
+ gap: var(--space-2);
5
+
6
+ .nano-upload__input {
7
+ position: absolute;
8
+ width: 1px;
9
+ height: 1px;
10
+ padding: 0;
11
+ margin: -1px;
12
+ overflow: hidden;
13
+ clip: rect(0, 0, 0, 0);
14
+ white-space: nowrap;
15
+ border: 0;
16
+ }
17
+
18
+ .nano-upload__dropzone {
19
+ display: flex;
20
+ flex-direction: column;
21
+ align-items: center;
22
+ justify-content: center;
23
+ gap: var(--space-2);
24
+ padding: var(--space-8) var(--space-4);
25
+ text-align: center;
26
+ background-color: hsl(var(--color-background));
27
+ border: 2px dashed hsl(var(--color-border));
28
+ border-radius: var(--radius-lg);
29
+ color: hsl(var(--color-muted-foreground));
30
+ cursor: pointer;
31
+ transition: border-color var(--duration-fast) var(--ease-default),
32
+ background-color var(--duration-fast) var(--ease-default),
33
+ color var(--duration-fast) var(--ease-default);
34
+
35
+ &:hover,
36
+ &:focus-visible {
37
+ border-color: hsl(var(--color-primary));
38
+ color: hsl(var(--color-foreground));
39
+ outline: none;
40
+ }
41
+ }
42
+
43
+ .nano-upload__icon {
44
+ width: 2rem;
45
+ height: 2rem;
46
+ color: hsl(var(--color-muted-foreground));
47
+
48
+ svg {
49
+ width: 100%;
50
+ height: 100%;
51
+ }
52
+ }
53
+
54
+ .nano-upload__prompt {
55
+ font-size: var(--text-sm);
56
+ color: hsl(var(--color-foreground));
57
+
58
+ strong {
59
+ font-weight: var(--font-semibold);
60
+ color: hsl(var(--color-primary));
61
+ }
62
+ }
63
+
64
+ .nano-upload__hint {
65
+ font-size: var(--text-xs);
66
+ color: hsl(var(--color-muted-foreground));
67
+ }
68
+
69
+ .nano-upload__preview {
70
+ display: flex;
71
+ align-items: center;
72
+ gap: var(--space-3);
73
+ padding: var(--space-3);
74
+ background-color: hsl(var(--color-muted));
75
+ border: 1px solid hsl(var(--color-border));
76
+ border-radius: var(--radius-md);
77
+ }
78
+
79
+ .nano-upload__thumbnail {
80
+ flex-shrink: 0;
81
+ width: 2.5rem;
82
+ height: 2.5rem;
83
+ border-radius: var(--radius-md);
84
+ object-fit: cover;
85
+ background-color: hsl(var(--color-background));
86
+ border: 1px solid hsl(var(--color-border));
87
+ }
88
+
89
+ .nano-upload__meta {
90
+ display: flex;
91
+ flex-direction: column;
92
+ gap: 0;
93
+ flex: 1;
94
+ min-width: 0;
95
+ }
96
+
97
+ .nano-upload__name {
98
+ font-size: var(--text-sm);
99
+ font-weight: var(--font-medium);
100
+ color: hsl(var(--color-foreground));
101
+ overflow: hidden;
102
+ text-overflow: ellipsis;
103
+ white-space: nowrap;
104
+ }
105
+
106
+ .nano-upload__size {
107
+ font-size: var(--text-xs);
108
+ color: hsl(var(--color-muted-foreground));
109
+ font-family: var(--font-mono);
110
+ }
111
+
112
+ .nano-upload__remove {
113
+ display: inline-flex;
114
+ align-items: center;
115
+ justify-content: center;
116
+ width: 2rem;
117
+ height: 2rem;
118
+ padding: 0;
119
+ background-color: transparent;
120
+ border: 0;
121
+ border-radius: var(--radius-md);
122
+ color: hsl(var(--color-muted-foreground));
123
+ cursor: pointer;
124
+ transition: background-color var(--duration-fast) var(--ease-default),
125
+ color var(--duration-fast) var(--ease-default);
126
+
127
+ svg {
128
+ width: 1rem;
129
+ height: 1rem;
130
+ }
131
+
132
+ &:hover {
133
+ background-color: hsl(var(--color-destructive) / 0.1);
134
+ color: hsl(var(--color-destructive));
135
+ }
136
+
137
+ &:focus-visible {
138
+ outline: 2px solid hsl(var(--color-ring));
139
+ outline-offset: 2px;
140
+ }
141
+ }
142
+
143
+ .nano-upload__error {
144
+ font-size: var(--text-sm);
145
+ color: hsl(var(--color-destructive));
146
+ }
147
+
148
+ &[data-state="dragover"] .nano-upload__dropzone {
149
+ border-color: hsl(var(--color-primary));
150
+ background-color: hsl(var(--color-primary) / 0.05);
151
+ color: hsl(var(--color-foreground));
152
+ }
153
+
154
+ &[data-state="filled"] .nano-upload__dropzone {
155
+ display: none;
156
+ }
157
+
158
+ &[data-state="empty"] .nano-upload__preview {
159
+ display: none;
160
+ }
161
+ }
@@ -3,12 +3,12 @@
3
3
  max-width: 1280px;
4
4
  margin-inline: auto;
5
5
  padding-inline: var(--space-6);
6
- }
7
6
 
8
- .nano-container--md {
9
- max-width: 1024px;
10
- }
7
+ &--md {
8
+ max-width: 1024px;
9
+ }
11
10
 
12
- .nano-container--sm {
13
- max-width: 640px;
11
+ &--sm {
12
+ max-width: 640px;
13
+ }
14
14
  }
@@ -0,0 +1,64 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["source", "button", "idle", "copied"]
5
+ static values = {
6
+ text: String,
7
+ resetMs: { type: Number, default: 2000 }
8
+ }
9
+
10
+ async copy(event) {
11
+ event?.preventDefault()
12
+ const text = this.#textToCopy()
13
+ if (!text) return
14
+
15
+ try {
16
+ await navigator.clipboard.writeText(text)
17
+ } catch (_err) {
18
+ this.#fallbackCopy(text)
19
+ }
20
+
21
+ this.#markCopied()
22
+ this.dispatch("copied", { detail: { text } })
23
+ }
24
+
25
+ #textToCopy() {
26
+ if (this.hasTextValue && this.textValue) return this.textValue
27
+ if (this.hasSourceTarget) {
28
+ return this.sourceTarget.value ?? this.sourceTarget.textContent ?? ""
29
+ }
30
+ return this.element.textContent?.trim() ?? ""
31
+ }
32
+
33
+ #fallbackCopy(text) {
34
+ const textarea = document.createElement("textarea")
35
+ textarea.value = text
36
+ textarea.setAttribute("readonly", "")
37
+ textarea.style.position = "absolute"
38
+ textarea.style.left = "-9999px"
39
+ document.body.appendChild(textarea)
40
+ textarea.select()
41
+ try { document.execCommand("copy") } catch (_err) { /* noop */ }
42
+ document.body.removeChild(textarea)
43
+ }
44
+
45
+ #markCopied() {
46
+ const button = this.hasButtonTarget ? this.buttonTarget : this.element
47
+ button.dataset.nanouiCopyCopied = "true"
48
+ if (this.hasIdleTarget) this.idleTarget.hidden = true
49
+ if (this.hasCopiedTarget) this.copiedTarget.hidden = false
50
+
51
+ clearTimeout(this._resetTimer)
52
+ this._resetTimer = setTimeout(() => this.#reset(button), this.resetMsValue)
53
+ }
54
+
55
+ #reset(button) {
56
+ delete button.dataset.nanouiCopyCopied
57
+ if (this.hasIdleTarget) this.idleTarget.hidden = false
58
+ if (this.hasCopiedTarget) this.copiedTarget.hidden = true
59
+ }
60
+
61
+ disconnect() {
62
+ clearTimeout(this._resetTimer)
63
+ }
64
+ }
@@ -0,0 +1,75 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["header", "body", "row"]
5
+ static values = {
6
+ server: { type: Boolean, default: false }
7
+ }
8
+
9
+ connect() {
10
+ this.headerTargets.forEach((header) => {
11
+ if (!header.hasAttribute("aria-sort")) {
12
+ header.setAttribute("aria-sort", "none")
13
+ }
14
+ })
15
+ }
16
+
17
+ sort(event) {
18
+ const header = event.currentTarget.closest("[data-nanoui-data-table-target='header']")
19
+ if (!header) return
20
+
21
+ const current = header.getAttribute("aria-sort") || "none"
22
+ const next = current === "ascending" ? "descending" : "ascending"
23
+
24
+ this.headerTargets.forEach((h) => {
25
+ if (h !== header) h.setAttribute("aria-sort", "none")
26
+ })
27
+ header.setAttribute("aria-sort", next)
28
+
29
+ this.dispatch("sort", {
30
+ detail: {
31
+ key: header.dataset.sortKey ?? header.textContent.trim(),
32
+ direction: next
33
+ }
34
+ })
35
+
36
+ if (this.serverValue) return
37
+
38
+ this.#sortLocally(header, next)
39
+ }
40
+
41
+ #sortLocally(header, direction) {
42
+ const index = this.headerTargets.indexOf(header)
43
+ if (index < 0 || !this.hasBodyTarget) return
44
+
45
+ const type = header.dataset.sortType || "string"
46
+ const rows = Array.from(this.rowTargets)
47
+ const factor = direction === "ascending" ? 1 : -1
48
+
49
+ rows.sort((a, b) => {
50
+ const av = this.#cellValue(a, index, type)
51
+ const bv = this.#cellValue(b, index, type)
52
+ if (av < bv) return -1 * factor
53
+ if (av > bv) return 1 * factor
54
+ return 0
55
+ })
56
+
57
+ rows.forEach((row) => this.bodyTarget.appendChild(row))
58
+ }
59
+
60
+ #cellValue(row, index, type) {
61
+ const cell = row.children[index]
62
+ if (!cell) return ""
63
+ const raw = cell.dataset.sortValue ?? cell.textContent.trim()
64
+
65
+ if (type === "number") {
66
+ const n = parseFloat(raw.replace(/[^0-9.\-]/g, ""))
67
+ return Number.isNaN(n) ? 0 : n
68
+ }
69
+ if (type === "date") {
70
+ const d = Date.parse(raw)
71
+ return Number.isNaN(d) ? 0 : d
72
+ }
73
+ return raw.toLowerCase()
74
+ }
75
+ }
@@ -0,0 +1,70 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["links", "toggle"]
5
+
6
+ connect() {
7
+ this.handleOutsideClick = this.handleOutsideClick.bind(this)
8
+ this.handleKeydown = this.handleKeydown.bind(this)
9
+ this.handleResize = this.handleResize.bind(this)
10
+ window.addEventListener("resize", this.handleResize)
11
+ }
12
+
13
+ toggle() {
14
+ if (this.linksTarget.classList.contains("is-open")) {
15
+ this.close()
16
+ } else {
17
+ this.open()
18
+ }
19
+ }
20
+
21
+ open() {
22
+ if (this.linksTarget.classList.contains("is-open")) return
23
+
24
+ this.linksTarget.classList.add("is-open")
25
+ this.syncToggleState(true)
26
+ document.addEventListener("click", this.handleOutsideClick)
27
+ document.addEventListener("keydown", this.handleKeydown)
28
+ this.dispatch("open")
29
+ }
30
+
31
+ close() {
32
+ if (!this.linksTarget.classList.contains("is-open")) return
33
+
34
+ this.linksTarget.classList.remove("is-open")
35
+ this.syncToggleState(false)
36
+ document.removeEventListener("click", this.handleOutsideClick)
37
+ document.removeEventListener("keydown", this.handleKeydown)
38
+ this.dispatch("close")
39
+ }
40
+
41
+ handleOutsideClick(event) {
42
+ if (!this.element.contains(event.target)) {
43
+ this.close()
44
+ }
45
+ }
46
+
47
+ handleKeydown(event) {
48
+ if (event.key === "Escape") {
49
+ this.close()
50
+ if (this.hasToggleTarget) this.toggleTarget.focus()
51
+ }
52
+ }
53
+
54
+ handleResize() {
55
+ if (window.innerWidth > 768) {
56
+ this.close()
57
+ }
58
+ }
59
+
60
+ disconnect() {
61
+ document.removeEventListener("click", this.handleOutsideClick)
62
+ document.removeEventListener("keydown", this.handleKeydown)
63
+ window.removeEventListener("resize", this.handleResize)
64
+ }
65
+
66
+ syncToggleState(expanded) {
67
+ if (!this.hasToggleTarget) return
68
+ this.toggleTarget.setAttribute("aria-expanded", String(expanded))
69
+ }
70
+ }
@@ -0,0 +1,27 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["toggle"]
5
+ static values = { collapsed: { type: Boolean, default: false } }
6
+
7
+ connect() {
8
+ if (this.collapsedValue) {
9
+ this.element.classList.add("nano-sidebar--collapsed")
10
+ this.#updateToggle(true)
11
+ }
12
+ }
13
+
14
+ toggle() {
15
+ const willCollapse = !this.element.classList.contains("nano-sidebar--collapsed")
16
+ this.element.classList.toggle("nano-sidebar--collapsed")
17
+ this.collapsedValue = willCollapse
18
+ this.#updateToggle(willCollapse)
19
+ this.dispatch(willCollapse ? "collapse" : "expand")
20
+ }
21
+
22
+ #updateToggle(collapsed) {
23
+ if (this.hasToggleTarget) {
24
+ this.toggleTarget.setAttribute("aria-expanded", String(!collapsed))
25
+ }
26
+ }
27
+ }