tina4ruby 0.4.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 (74) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +80 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +768 -0
  5. data/exe/tina4 +4 -0
  6. data/lib/tina4/api.rb +152 -0
  7. data/lib/tina4/auth.rb +139 -0
  8. data/lib/tina4/cli.rb +349 -0
  9. data/lib/tina4/crud.rb +124 -0
  10. data/lib/tina4/database.rb +135 -0
  11. data/lib/tina4/database_result.rb +89 -0
  12. data/lib/tina4/debug.rb +83 -0
  13. data/lib/tina4/dev.rb +15 -0
  14. data/lib/tina4/dev_reload.rb +68 -0
  15. data/lib/tina4/drivers/firebird_driver.rb +94 -0
  16. data/lib/tina4/drivers/mssql_driver.rb +112 -0
  17. data/lib/tina4/drivers/mysql_driver.rb +90 -0
  18. data/lib/tina4/drivers/postgres_driver.rb +99 -0
  19. data/lib/tina4/drivers/sqlite_driver.rb +85 -0
  20. data/lib/tina4/env.rb +55 -0
  21. data/lib/tina4/field_types.rb +84 -0
  22. data/lib/tina4/graphql.rb +837 -0
  23. data/lib/tina4/localization.rb +100 -0
  24. data/lib/tina4/middleware.rb +59 -0
  25. data/lib/tina4/migration.rb +124 -0
  26. data/lib/tina4/orm.rb +168 -0
  27. data/lib/tina4/public/css/tina4.css +2286 -0
  28. data/lib/tina4/public/css/tina4.min.css +2 -0
  29. data/lib/tina4/public/js/tina4.js +134 -0
  30. data/lib/tina4/public/js/tina4helper.js +387 -0
  31. data/lib/tina4/queue.rb +117 -0
  32. data/lib/tina4/queue_backends/kafka_backend.rb +80 -0
  33. data/lib/tina4/queue_backends/lite_backend.rb +79 -0
  34. data/lib/tina4/queue_backends/rabbitmq_backend.rb +73 -0
  35. data/lib/tina4/rack_app.rb +150 -0
  36. data/lib/tina4/request.rb +158 -0
  37. data/lib/tina4/response.rb +172 -0
  38. data/lib/tina4/router.rb +148 -0
  39. data/lib/tina4/scss/tina4css/_alerts.scss +34 -0
  40. data/lib/tina4/scss/tina4css/_badges.scss +22 -0
  41. data/lib/tina4/scss/tina4css/_buttons.scss +69 -0
  42. data/lib/tina4/scss/tina4css/_cards.scss +49 -0
  43. data/lib/tina4/scss/tina4css/_forms.scss +156 -0
  44. data/lib/tina4/scss/tina4css/_grid.scss +81 -0
  45. data/lib/tina4/scss/tina4css/_modals.scss +84 -0
  46. data/lib/tina4/scss/tina4css/_nav.scss +149 -0
  47. data/lib/tina4/scss/tina4css/_reset.scss +94 -0
  48. data/lib/tina4/scss/tina4css/_tables.scss +54 -0
  49. data/lib/tina4/scss/tina4css/_typography.scss +55 -0
  50. data/lib/tina4/scss/tina4css/_utilities.scss +197 -0
  51. data/lib/tina4/scss/tina4css/_variables.scss +117 -0
  52. data/lib/tina4/scss/tina4css/base.scss +1 -0
  53. data/lib/tina4/scss/tina4css/colors.scss +48 -0
  54. data/lib/tina4/scss/tina4css/tina4.scss +17 -0
  55. data/lib/tina4/scss_compiler.rb +131 -0
  56. data/lib/tina4/seeder.rb +529 -0
  57. data/lib/tina4/session.rb +145 -0
  58. data/lib/tina4/session_handlers/file_handler.rb +55 -0
  59. data/lib/tina4/session_handlers/mongo_handler.rb +49 -0
  60. data/lib/tina4/session_handlers/redis_handler.rb +43 -0
  61. data/lib/tina4/swagger.rb +123 -0
  62. data/lib/tina4/template.rb +478 -0
  63. data/lib/tina4/templates/base.twig +26 -0
  64. data/lib/tina4/templates/errors/403.twig +22 -0
  65. data/lib/tina4/templates/errors/404.twig +22 -0
  66. data/lib/tina4/templates/errors/500.twig +22 -0
  67. data/lib/tina4/testing.rb +213 -0
  68. data/lib/tina4/version.rb +5 -0
  69. data/lib/tina4/webserver.rb +101 -0
  70. data/lib/tina4/websocket.rb +167 -0
  71. data/lib/tina4/wsdl.rb +164 -0
  72. data/lib/tina4.rb +259 -0
  73. data/lib/tina4ruby.rb +4 -0
  74. metadata +324 -0
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+ require "json"
3
+ require "uri"
4
+
5
+ module Tina4
6
+ class Response
7
+ MIME_TYPES = {
8
+ ".html" => "text/html", ".htm" => "text/html",
9
+ ".css" => "text/css", ".js" => "application/javascript",
10
+ ".json" => "application/json", ".xml" => "application/xml",
11
+ ".txt" => "text/plain", ".csv" => "text/csv",
12
+ ".png" => "image/png", ".jpg" => "image/jpeg",
13
+ ".jpeg" => "image/jpeg", ".gif" => "image/gif",
14
+ ".svg" => "image/svg+xml", ".ico" => "image/x-icon",
15
+ ".webp" => "image/webp", ".pdf" => "application/pdf",
16
+ ".zip" => "application/zip", ".woff" => "font/woff",
17
+ ".woff2" => "font/woff2", ".ttf" => "font/ttf",
18
+ ".eot" => "application/vnd.ms-fontobject",
19
+ ".mp3" => "audio/mpeg", ".mp4" => "video/mp4",
20
+ ".webm" => "video/webm"
21
+ }.freeze
22
+
23
+ # Pre-frozen header values
24
+ JSON_CONTENT_TYPE = "application/json; charset=utf-8"
25
+ HTML_CONTENT_TYPE = "text/html; charset=utf-8"
26
+ TEXT_CONTENT_TYPE = "text/plain; charset=utf-8"
27
+ XML_CONTENT_TYPE = "application/xml; charset=utf-8"
28
+
29
+ attr_accessor :status, :headers, :body, :cookies
30
+
31
+ def initialize
32
+ @status = 200
33
+ @headers = { "content-type" => HTML_CONTENT_TYPE }
34
+ @body = ""
35
+ @cookies = nil # Lazy — most responses have no cookies
36
+ end
37
+
38
+ def json(data, status_or_opts = nil, status: nil)
39
+ @status = status || (status_or_opts.is_a?(Integer) ? status_or_opts : 200)
40
+ @headers["content-type"] = JSON_CONTENT_TYPE
41
+ @body = data.is_a?(String) ? data : JSON.generate(data)
42
+ self
43
+ end
44
+
45
+ def html(content, status_or_opts = nil, status: nil)
46
+ @status = status || (status_or_opts.is_a?(Integer) ? status_or_opts : 200)
47
+ @headers["content-type"] = HTML_CONTENT_TYPE
48
+ @body = content.to_s
49
+ self
50
+ end
51
+
52
+ def text(content, status_or_opts = nil, status: nil)
53
+ @status = status || (status_or_opts.is_a?(Integer) ? status_or_opts : 200)
54
+ @headers["content-type"] = TEXT_CONTENT_TYPE
55
+ @body = content.to_s
56
+ self
57
+ end
58
+
59
+ def xml(content, status: 200)
60
+ @status = status
61
+ @headers["content-type"] = XML_CONTENT_TYPE
62
+ @body = content.to_s
63
+ self
64
+ end
65
+
66
+ def csv(content, filename: "export.csv", status: 200)
67
+ @status = status
68
+ @headers["content-type"] = "text/csv"
69
+ @headers["content-disposition"] = "attachment; filename=\"#{filename}\""
70
+ @body = content.to_s
71
+ self
72
+ end
73
+
74
+ def redirect(url, status: 302)
75
+ @status = status
76
+ @headers["location"] = url
77
+ @body = ""
78
+ self
79
+ end
80
+
81
+ def file(path, content_type: nil, download: false)
82
+ unless ::File.exist?(path)
83
+ @status = 404
84
+ @body = "File not found"
85
+ return self
86
+ end
87
+ ext = ::File.extname(path).downcase
88
+ @headers["content-type"] = content_type || MIME_TYPES[ext] || "application/octet-stream"
89
+ if download
90
+ @headers["content-disposition"] = "attachment; filename=\"#{::File.basename(path)}\""
91
+ end
92
+ @body = ::File.binread(path)
93
+ self
94
+ end
95
+
96
+ def render(template_path, data = {}, status: 200)
97
+ @status = status
98
+ @headers["content-type"] = HTML_CONTENT_TYPE
99
+ @body = Tina4::Template.render(template_path, data)
100
+ self
101
+ end
102
+
103
+ def set_cookie(name, value, opts = {})
104
+ cookie = "#{name}=#{URI.encode_www_form_component(value)}"
105
+ cookie += "; Path=#{opts[:path] || '/'}"
106
+ cookie += "; HttpOnly" if opts.fetch(:http_only, true)
107
+ cookie += "; Secure" if opts[:secure]
108
+ cookie += "; SameSite=#{opts[:same_site] || 'Lax'}"
109
+ cookie += "; Max-Age=#{opts[:max_age]}" if opts[:max_age]
110
+ cookie += "; Expires=#{opts[:expires].httpdate}" if opts[:expires]
111
+ @cookies ||= []
112
+ @cookies << cookie
113
+ self
114
+ end
115
+
116
+ def delete_cookie(name, path: "/")
117
+ set_cookie(name, "", max_age: 0, path: path)
118
+ end
119
+
120
+ def add_header(key, value)
121
+ @headers[key] = value
122
+ self
123
+ end
124
+
125
+ def add_cors_headers(origin: "*", methods: "GET, POST, PUT, PATCH, DELETE, OPTIONS",
126
+ headers_list: "Content-Type, Authorization, Accept", credentials: false)
127
+ @headers["access-control-allow-origin"] = origin
128
+ @headers["access-control-allow-methods"] = methods
129
+ @headers["access-control-allow-headers"] = headers_list
130
+ @headers["access-control-allow-credentials"] = "true" if credentials
131
+ @headers["access-control-max-age"] = "86400"
132
+ self
133
+ end
134
+
135
+ def to_rack
136
+ # Fast path: no cookies (99% of API responses)
137
+ if @cookies.nil? || @cookies.empty?
138
+ return [@status, @headers, [@body.to_s]]
139
+ end
140
+
141
+ # Cookie path
142
+ final_headers = @headers.dup
143
+ final_headers["set-cookie"] = @cookies.join("\n")
144
+ [@status, final_headers, [@body.to_s]]
145
+ end
146
+
147
+ def self.auto_detect(result, response)
148
+ case result
149
+ when Tina4::Response
150
+ result
151
+ when Hash, Array
152
+ response.json(result)
153
+ when String
154
+ if result.start_with?("<")
155
+ response.html(result)
156
+ else
157
+ response.text(result)
158
+ end
159
+ when Integer
160
+ response.status = result
161
+ response.body = ""
162
+ response
163
+ when NilClass
164
+ response.status = 204
165
+ response.body = ""
166
+ response
167
+ else
168
+ response.json(result.respond_to?(:to_hash) ? result.to_hash : { data: result.to_s })
169
+ end
170
+ end
171
+ end
172
+ end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tina4
4
+ class Route
5
+ attr_reader :method, :path, :handler, :auth_handler, :swagger_meta, :path_regex, :param_names
6
+
7
+ def initialize(method, path, handler, auth_handler: nil, swagger_meta: {})
8
+ @method = method.to_s.upcase.freeze
9
+ @path = normalize_path(path).freeze
10
+ @handler = handler
11
+ @auth_handler = auth_handler
12
+ @swagger_meta = swagger_meta
13
+ @param_names = []
14
+ @path_regex = compile_pattern(@path)
15
+ @param_names.freeze
16
+ end
17
+
18
+ # Returns params hash if matched, false otherwise
19
+ def match?(request_path, request_method = nil)
20
+ return false if request_method && @method != request_method.to_s.upcase
21
+ match_path(request_path)
22
+ end
23
+
24
+ # Returns params hash if matched, false otherwise
25
+ def match_path(request_path)
26
+ match = @path_regex.match(request_path)
27
+ return false unless match
28
+
29
+ if @param_names.empty?
30
+ # Static route — no params to extract
31
+ {}
32
+ else
33
+ params = {}
34
+ @param_names.each_with_index do |param_def, i|
35
+ raw_value = match[i + 1]
36
+ params[param_def[:name]] = cast_param(raw_value, param_def[:type])
37
+ end
38
+ params
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ def normalize_path(path)
45
+ p = path.to_s.gsub("\\", "/")
46
+ p = "/#{p}" unless p.start_with?("/")
47
+ p = p.chomp("/") unless p == "/"
48
+ p
49
+ end
50
+
51
+ def compile_pattern(path)
52
+ return Regexp.new("\\A/\\z") if path == "/"
53
+
54
+ parts = path.split("/").reject(&:empty?)
55
+ regex_parts = parts.map do |part|
56
+ if part =~ /\A\{(\w+)(?::(\w+))?\}\z/
57
+ name = Regexp.last_match(1)
58
+ type = Regexp.last_match(2) || "string"
59
+ @param_names << { name: name.to_sym, type: type }
60
+ case type
61
+ when "int", "integer"
62
+ '(\d+)'
63
+ when "float", "number"
64
+ '([\d.]+)'
65
+ when "path"
66
+ '(.+)'
67
+ else
68
+ '([^/]+)'
69
+ end
70
+ else
71
+ Regexp.escape(part)
72
+ end
73
+ end
74
+ Regexp.new("\\A/#{regex_parts.join("/")}\\z")
75
+ end
76
+
77
+ def cast_param(value, type)
78
+ case type
79
+ when "int", "integer"
80
+ value.to_i
81
+ when "float", "number"
82
+ value.to_f
83
+ else
84
+ value
85
+ end
86
+ end
87
+ end
88
+
89
+ module Router
90
+ class << self
91
+ def routes
92
+ @routes ||= []
93
+ end
94
+
95
+ # Routes indexed by HTTP method for O(1) method lookup
96
+ def method_index
97
+ @method_index ||= Hash.new { |h, k| h[k] = [] }
98
+ end
99
+
100
+ def add_route(method, path, handler, auth_handler: nil, swagger_meta: {})
101
+ route = Route.new(method, path, handler, auth_handler: auth_handler, swagger_meta: swagger_meta)
102
+ routes << route
103
+ method_index[route.method] << route
104
+ Tina4::Debug.debug("Route registered: #{method.upcase} #{path}")
105
+ route
106
+ end
107
+
108
+ def find_route(path, method)
109
+ normalized_method = method.upcase
110
+ # Normalize path once (not per-route)
111
+ normalized_path = path.gsub("\\", "/")
112
+ normalized_path = "/#{normalized_path}" unless normalized_path.start_with?("/")
113
+ normalized_path = normalized_path.chomp("/") unless normalized_path == "/"
114
+
115
+ # Only scan routes matching this HTTP method
116
+ candidates = method_index[normalized_method]
117
+ candidates.each do |route|
118
+ params = route.match_path(normalized_path)
119
+ return [route, params] if params
120
+ end
121
+ nil
122
+ end
123
+
124
+ def clear!
125
+ @routes = []
126
+ @method_index = Hash.new { |h, k| h[k] = [] }
127
+ end
128
+
129
+ def group(prefix, auth_handler: nil, &block)
130
+ GroupContext.new(prefix, auth_handler).instance_eval(&block)
131
+ end
132
+ end
133
+
134
+ class GroupContext
135
+ def initialize(prefix, auth_handler = nil)
136
+ @prefix = prefix.chomp("/")
137
+ @auth_handler = auth_handler
138
+ end
139
+
140
+ %w[get post put patch delete any].each do |m|
141
+ define_method(m) do |path, swagger_meta: {}, &handler|
142
+ full_path = "#{@prefix}#{path}"
143
+ Tina4::Router.add_route(m, full_path, handler, auth_handler: @auth_handler, swagger_meta: swagger_meta)
144
+ end
145
+ end
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,34 @@
1
+ // Tina4 CSS Framework - Alerts
2
+ // -------------------------------
3
+
4
+ .alert {
5
+ position: relative;
6
+ padding: 0.75rem 1rem;
7
+ margin-bottom: 1rem;
8
+ border: 1px solid transparent;
9
+ border-radius: $border-radius;
10
+ }
11
+
12
+ @each $name, $color in $theme-colors {
13
+ .alert-#{$name} {
14
+ color: darken($color, 25%);
15
+ background-color: lighten($color, 35%);
16
+ border-color: lighten($color, 25%);
17
+ }
18
+ }
19
+
20
+ .alert-dismissible {
21
+ padding-right: 3rem;
22
+ .btn-close {
23
+ position: absolute;
24
+ top: 0;
25
+ right: 0;
26
+ padding: 0.9375rem 1rem;
27
+ background: transparent;
28
+ border: 0;
29
+ cursor: pointer;
30
+ color: inherit;
31
+ opacity: 0.5;
32
+ &:hover { opacity: 0.75; }
33
+ }
34
+ }
@@ -0,0 +1,22 @@
1
+ // Tina4 CSS Framework - Badges
2
+ // -------------------------------
3
+
4
+ .badge {
5
+ display: inline-block;
6
+ padding: 0.25em 0.65em;
7
+ font-size: 0.75em;
8
+ font-weight: $font-weight-bold;
9
+ line-height: 1;
10
+ color: $white;
11
+ text-align: center;
12
+ white-space: nowrap;
13
+ vertical-align: baseline;
14
+ border-radius: $border-radius-pill;
15
+ }
16
+
17
+ @each $name, $color in $theme-colors {
18
+ .badge-#{$name} {
19
+ color: if($name == "light" or $name == "warning", $dark, $white);
20
+ background-color: $color;
21
+ }
22
+ }
@@ -0,0 +1,69 @@
1
+ // Tina4 CSS Framework - Buttons
2
+ // --------------------------------
3
+
4
+ .btn {
5
+ display: inline-block;
6
+ font-weight: $font-weight-normal;
7
+ line-height: $line-height-base;
8
+ color: $body-color;
9
+ text-align: center;
10
+ vertical-align: middle;
11
+ cursor: pointer;
12
+ user-select: none;
13
+ background-color: transparent;
14
+ border: 1px solid transparent;
15
+ padding: 0.375rem 0.75rem;
16
+ font-size: $font-size-base;
17
+ border-radius: $border-radius;
18
+ transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
19
+ text-decoration: none;
20
+
21
+ &:disabled, &.disabled {
22
+ pointer-events: none;
23
+ opacity: 0.65;
24
+ }
25
+ }
26
+
27
+ // Solid buttons
28
+ @each $name, $color in $theme-colors {
29
+ $text: if($name == "light" or $name == "warning", $dark, $white);
30
+ .btn-#{$name} {
31
+ color: $text;
32
+ background-color: $color;
33
+ border-color: $color;
34
+ &:hover {
35
+ background-color: darken($color, 8%);
36
+ border-color: darken($color, 10%);
37
+ }
38
+ }
39
+ }
40
+
41
+ // Outline buttons
42
+ @each $name, $color in $theme-colors {
43
+ .btn-outline-#{$name} {
44
+ color: $color;
45
+ border-color: $color;
46
+ background-color: transparent;
47
+ &:hover {
48
+ color: if($name == "light" or $name == "warning", $dark, $white);
49
+ background-color: $color;
50
+ }
51
+ }
52
+ }
53
+
54
+ .btn-sm {
55
+ padding: 0.25rem 0.5rem;
56
+ font-size: $font-size-sm;
57
+ border-radius: $border-radius-sm;
58
+ }
59
+
60
+ .btn-lg {
61
+ padding: 0.5rem 1rem;
62
+ font-size: $font-size-lg;
63
+ border-radius: $border-radius-lg;
64
+ }
65
+
66
+ .btn-block {
67
+ display: block;
68
+ width: 100%;
69
+ }
@@ -0,0 +1,49 @@
1
+ // Tina4 CSS Framework - Cards
2
+ // ------------------------------
3
+
4
+ .card {
5
+ position: relative;
6
+ display: flex;
7
+ flex-direction: column;
8
+ min-width: 0;
9
+ word-wrap: break-word;
10
+ background-color: $white;
11
+ background-clip: border-box;
12
+ border: 1px solid rgba($black, 0.125);
13
+ border-radius: $border-radius-lg;
14
+ }
15
+
16
+ .card-header {
17
+ padding: 0.75rem 1rem;
18
+ margin-bottom: 0;
19
+ background-color: rgba($black, 0.03);
20
+ border-bottom: 1px solid rgba($black, 0.125);
21
+ &:first-child { border-radius: calc($border-radius-lg - 1px) calc($border-radius-lg - 1px) 0 0; }
22
+ }
23
+
24
+ .card-body {
25
+ flex: 1 1 auto;
26
+ padding: 1rem;
27
+ }
28
+
29
+ .card-footer {
30
+ padding: 0.75rem 1rem;
31
+ background-color: rgba($black, 0.03);
32
+ border-top: 1px solid rgba($black, 0.125);
33
+ &:last-child { border-radius: 0 0 calc($border-radius-lg - 1px) calc($border-radius-lg - 1px); }
34
+ }
35
+
36
+ .card-title {
37
+ margin-bottom: 0.5rem;
38
+ font-weight: $font-weight-bold;
39
+ }
40
+
41
+ .card-text:last-child {
42
+ margin-bottom: 0;
43
+ }
44
+
45
+ .card-img-top {
46
+ width: 100%;
47
+ border-top-left-radius: calc($border-radius-lg - 1px);
48
+ border-top-right-radius: calc($border-radius-lg - 1px);
49
+ }
@@ -0,0 +1,156 @@
1
+ // Tina4 CSS Framework - Forms
2
+ // ------------------------------
3
+
4
+ .form-group {
5
+ margin-bottom: 1rem;
6
+ }
7
+
8
+ .form-label {
9
+ display: inline-block;
10
+ margin-bottom: 0.5rem;
11
+ font-weight: $font-weight-bold;
12
+ }
13
+
14
+ .form-control {
15
+ display: block;
16
+ width: 100%;
17
+ padding: 0.375rem 0.75rem;
18
+ font-size: $font-size-base;
19
+ font-weight: $font-weight-normal;
20
+ line-height: $line-height-base;
21
+ color: $body-color;
22
+ background-color: $white;
23
+ background-clip: padding-box;
24
+ border: 1px solid #ced4da;
25
+ border-radius: $border-radius;
26
+ transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
27
+ appearance: none;
28
+
29
+ &:focus {
30
+ color: $body-color;
31
+ background-color: $white;
32
+ border-color: lighten($primary, 25%);
33
+ outline: 0;
34
+ box-shadow: 0 0 0 0.2rem rgba($primary, 0.25);
35
+ }
36
+
37
+ &::placeholder {
38
+ color: $muted;
39
+ opacity: 1;
40
+ }
41
+
42
+ &:disabled {
43
+ background-color: $light;
44
+ opacity: 1;
45
+ }
46
+ }
47
+
48
+ textarea.form-control {
49
+ min-height: calc(1.5em + 0.75rem + 2px);
50
+ }
51
+
52
+ .form-select {
53
+ display: block;
54
+ width: 100%;
55
+ padding: 0.375rem 2.25rem 0.375rem 0.75rem;
56
+ font-size: $font-size-base;
57
+ font-weight: $font-weight-normal;
58
+ line-height: $line-height-base;
59
+ color: $body-color;
60
+ background-color: $white;
61
+ border: 1px solid #ced4da;
62
+ border-radius: $border-radius;
63
+ transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
64
+ appearance: none;
65
+
66
+ &:focus {
67
+ border-color: lighten($primary, 25%);
68
+ outline: 0;
69
+ box-shadow: 0 0 0 0.2rem rgba($primary, 0.25);
70
+ }
71
+ }
72
+
73
+ // Checkboxes and radios
74
+ .form-check {
75
+ display: block;
76
+ min-height: $line-height-base * 1rem;
77
+ padding-left: 1.5em;
78
+ margin-bottom: 0.125rem;
79
+ }
80
+
81
+ .form-check-input {
82
+ float: left;
83
+ width: 1em;
84
+ height: 1em;
85
+ margin-left: -1.5em;
86
+ margin-top: 0.25em;
87
+ appearance: none;
88
+ background-color: $white;
89
+ border: 1px solid rgba($black, 0.25);
90
+ &[type="checkbox"] { border-radius: 0.25em; }
91
+ &[type="radio"] { border-radius: 50%; }
92
+ &:checked { background-color: $primary; border-color: $primary; }
93
+ &:focus { border-color: lighten($primary, 25%); outline: 0; box-shadow: 0 0 0 0.2rem rgba($primary, 0.25); }
94
+ }
95
+
96
+ .form-check-label { cursor: pointer; }
97
+
98
+ // Validation
99
+ .is-valid {
100
+ border-color: $success !important;
101
+ &:focus {
102
+ box-shadow: 0 0 0 0.2rem rgba($success, 0.25) !important;
103
+ }
104
+ }
105
+
106
+ .is-invalid {
107
+ border-color: $danger !important;
108
+ &:focus {
109
+ box-shadow: 0 0 0 0.2rem rgba($danger, 0.25) !important;
110
+ }
111
+ }
112
+
113
+ .valid-feedback,
114
+ .invalid-feedback {
115
+ display: none;
116
+ width: 100%;
117
+ margin-top: 0.25rem;
118
+ font-size: 0.875em;
119
+ }
120
+ .valid-feedback { color: $success; }
121
+ .invalid-feedback { color: $danger; }
122
+
123
+ .is-valid ~ .valid-feedback,
124
+ .is-invalid ~ .invalid-feedback {
125
+ display: block;
126
+ }
127
+
128
+ // Input group
129
+ .input-group {
130
+ position: relative;
131
+ display: flex;
132
+ flex-wrap: wrap;
133
+ align-items: stretch;
134
+ width: 100%;
135
+ > .form-control,
136
+ > .form-select {
137
+ position: relative;
138
+ flex: 1 1 auto;
139
+ width: 1%;
140
+ min-width: 0;
141
+ }
142
+ }
143
+
144
+ .input-group-text {
145
+ display: flex;
146
+ align-items: center;
147
+ padding: 0.375rem 0.75rem;
148
+ font-size: $font-size-base;
149
+ line-height: $line-height-base;
150
+ color: $body-color;
151
+ text-align: center;
152
+ white-space: nowrap;
153
+ background-color: $light;
154
+ border: 1px solid #ced4da;
155
+ border-radius: $border-radius;
156
+ }