navtastic 0.0.1

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.
@@ -0,0 +1,127 @@
1
+ module Navtastic
2
+ # Stores items generated by a definition block
3
+ class Menu
4
+ include Enumerable
5
+
6
+ # @return [Array<Item>] the items in this menu
7
+ attr_reader :items
8
+
9
+ # @return [Menu,nil] this parent of this menu
10
+ attr_reader :parent
11
+
12
+ # Create a new empty menu
13
+ #
14
+ # @param root [Menu] the root menu of this is a submenu
15
+ def initialize(parent = nil)
16
+ @parent = parent
17
+
18
+ @current_item = nil
19
+ @items = []
20
+ @items_by_url = {}
21
+ end
22
+
23
+ # @return [true] if this menu is the root menu
24
+ # @return [false] if this menu is a submenu
25
+ def root?
26
+ @parent.nil?
27
+ end
28
+
29
+ # The depth of this menu
30
+ #
31
+ # The root menu always has depth 0.
32
+ #
33
+ # @return [Integer] the depth of this menu
34
+ def depth
35
+ if @parent
36
+ @parent.depth + 1
37
+ else
38
+ 0
39
+ end
40
+ end
41
+
42
+ # Add a new item at the end of the menu
43
+ #
44
+ # @param name [String]the name to display in the menu
45
+ # @param url [String] the url to link to, if the item is a link
46
+ #
47
+ # @yield [submenu] block to generate a sub menu
48
+ # @yieldparam submenu [Menu] the menu to be initialized
49
+ def item(name, url = nil)
50
+ item = Item.new(self, name, url)
51
+
52
+ if block_given?
53
+ submenu = Menu.new(self)
54
+ yield submenu
55
+ item.submenu = submenu
56
+ end
57
+
58
+ @items << item
59
+ register_item(item)
60
+
61
+ item
62
+ end
63
+
64
+ def each
65
+ @items.each do |item|
66
+ yield item
67
+ end
68
+ end
69
+
70
+ # Find an item in this menu matching the url
71
+ #
72
+ # @param url [String] the url of the item
73
+ #
74
+ # @return [Item] if an item with that url exists
75
+ # @return [nil] if the item doens't exist
76
+ def [](url)
77
+ @items_by_url[url]
78
+ end
79
+
80
+ # Sets the current active item by url
81
+ #
82
+ # @private
83
+ #
84
+ # @param current_url [String] the url of the current page
85
+ def current_url=(current_url)
86
+ @current_item = nil
87
+ return if current_url.nil?
88
+
89
+ # Sort urls from longest to shortest and find the first matching substring
90
+ matching_item = @items_by_url
91
+ .sort_by { |url, _item| -url.length }.to_h
92
+ .find { |url, _item| current_url.start_with? url }
93
+
94
+ @current_item = matching_item[1] if matching_item
95
+ end
96
+
97
+ # @see file:README.md#Current_item documentation on how the current item is
98
+ # selected
99
+ #
100
+ # @return [Item,nil] the current active item
101
+ def current_item
102
+ if root?
103
+ @current_item
104
+ else
105
+ @parent.current_item
106
+ end
107
+ end
108
+
109
+ protected
110
+
111
+ # Register a newly added item
112
+ #
113
+ # @param item [Item] the new item to register
114
+ def register_item(item)
115
+ return unless item.url
116
+
117
+ @items_by_url[item.url] = item
118
+
119
+ if root?
120
+ # The first item with a url is the default current item
121
+ @current_item = item if @current_item.nil? && item.url
122
+ else
123
+ @parent.register_item(item)
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,87 @@
1
+ require 'arbre'
2
+
3
+ module Navtastic
4
+ # Generate HTML based on a menu
5
+ #
6
+ # The actual HTML generation is done using the
7
+ # [Arbre](https://github.com/activeadmin/arbre) gem.
8
+ #
9
+ # Calling `#to_s` will return the HTML.
10
+ class Renderer < Arbre::Context
11
+ # Create a new renderer
12
+ #
13
+ # @param menu [Menu]
14
+ #
15
+ # @return [Renderer]
16
+ def self.render(menu)
17
+ new(menu: menu) do
18
+ menu(menu)
19
+ end
20
+ end
21
+
22
+ # Starting a new root menu or submenu (e.g. `<ul>` tag)
23
+ #
24
+ # @param menu [Menu]
25
+ # @return [Arbre::HTML::Tag]
26
+ def menu(menu)
27
+ ul do
28
+ menu.each do |item|
29
+ item_container item
30
+ end
31
+ end
32
+ end
33
+
34
+ # The container for every menu item (e.g. `<li>` tags)
35
+ #
36
+ # @param item [Item]
37
+ # @return [Arbre::HTML::Tag]
38
+ def item_container(item)
39
+ li(class: css_classes_string(item, :item_container)) do
40
+ item_content item
41
+
42
+ menu(item.submenu) if item.submenu?
43
+ end
44
+ end
45
+
46
+ # The item itself (e.g. `<a>` tag for links)
47
+ #
48
+ # @param item [Item]
49
+ # @return [Arbre::HTML::Tag]
50
+ def item_content(item)
51
+ if item.url
52
+ a(href: item.url) { item.name }
53
+ else
54
+ span item.name
55
+ end
56
+ end
57
+
58
+ # Decide which css classes are needed for this item
59
+ #
60
+ # For example, the {#item_container} uses this to retrieve the css class for
61
+ # the current active item.
62
+ #
63
+ # @param item [Item] the current item that is rendered
64
+ # @param context [Symbol] which method is asking for the css classes
65
+ #
66
+ # @return [Array<String>] list of css classes to apply to the HTML element
67
+ def css_classes(item, context)
68
+ classes = []
69
+
70
+ case context
71
+ when :item_container
72
+ classes << 'current' if item.current?
73
+ end
74
+
75
+ classes
76
+ end
77
+
78
+ # Same as {css_classes} method, but joins classes together in a string
79
+ #
80
+ # @see css_classes
81
+ #
82
+ # @return [String]
83
+ def css_classes_string(item, context)
84
+ css_classes(item, context).join ' '
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,3 @@
1
+ module Navtastic
2
+ VERSION = '0.0.1'.freeze
3
+ end
@@ -0,0 +1,25 @@
1
+ $LOAD_PATH.push File.expand_path('../lib', __FILE__)
2
+ require 'navtastic/version'
3
+
4
+ Gem::Specification.new do |s|
5
+ s.name = 'navtastic'
6
+ s.version = Navtastic::VERSION
7
+ s.summary = "Define and render complex navigation menus"
8
+ s.authors = ["Aram Visser"]
9
+ s.email = "hello@aramvisser.com"
10
+ s.homepage = "http://github.com/aramvisser/navtastic"
11
+ s.license = 'MIT'
12
+
13
+ s.files = `git ls-files -z`.split("\x0")
14
+ s.test_files = s.files.grep(%r{^(test|spec|features)/})
15
+ s.require_paths = ['lib']
16
+ s.extra_rdoc_files = ['README.md']
17
+
18
+ s.add_dependency 'arbre', '~> 1.1'
19
+
20
+ s.add_development_dependency 'redcarpet', '~> 3.4'
21
+ s.add_development_dependency 'rspec', '~> 3.6'
22
+ s.add_development_dependency 'rubocop', '~> 0.48'
23
+ s.add_development_dependency 'rubocop-rspec', '~> 1.15'
24
+ s.add_development_dependency 'yard', '~> 0.9'
25
+ end
@@ -0,0 +1,29 @@
1
+ <%
2
+ Navtastic.define :main_menu do |menu|
3
+ menu.item "Home", '/' do |submenu|
4
+ submenu.item "Posts", '/posts'
5
+ submenu.item "About", '/about'
6
+ end
7
+
8
+ menu.item "Settings" do |submenu|
9
+ submenu.item "General", '/settings'
10
+ submenu.item "Profile", '/settings/profile'
11
+ end
12
+ end
13
+ %>
14
+
15
+ <html>
16
+ <head>
17
+ <title>Navtastic Demo Server</title>
18
+ <style type="text/css">
19
+ .current { font-weight: bold }
20
+ .current ul { font-weight: normal }
21
+ </style>
22
+ </head>
23
+
24
+ <body>
25
+ <%= Navtastic.render :main_menu, current_url %>
26
+
27
+ <pre>current_url: <%= current_url %></pre>
28
+ </body>
29
+ </html>
@@ -0,0 +1,54 @@
1
+ $LOAD_PATH.push File.expand_path('../../../lib', __FILE__)
2
+
3
+ require 'navtastic'
4
+ require 'erb'
5
+ require 'webrick'
6
+
7
+ class DemoServer
8
+ def initialize(port)
9
+ @port = port
10
+ end
11
+
12
+ def start
13
+ s = WEBrick::HTTPServer.new Port: @port, DocumentRoot: 'demo/', RequestCallback: -> (req, res) do
14
+ # A poor man's hot reload. Just reload everything on every request.
15
+ Object.send(:remove_const, :Navtastic) if Object.constants.include?(:Navtastic)
16
+
17
+ Dir.glob('lib/**/*.rb').each do |file|
18
+ load file
19
+ end
20
+ end
21
+
22
+ # Add a mime type for *.rhtml files
23
+ WEBrick::HTTPUtils::DefaultMimeTypes.store('rhtml', 'text/html')
24
+
25
+ # Mount servlets
26
+ #s.mount('/', HTTPServlet::FileHandler, Dir.pwd)
27
+ s.mount '/', MenuServlet
28
+
29
+ # Trap signals so as to shutdown cleanly.
30
+ ['TERM', 'INT'].each do |signal|
31
+ trap(signal){ s.shutdown }
32
+ end
33
+
34
+ # Start the server and block on input.
35
+ s.start
36
+ end
37
+ end
38
+
39
+ class MenuServlet < WEBrick::HTTPServlet::AbstractServlet
40
+ def do_GET(request, response)
41
+ response.status = 200
42
+ response['Content-Type'] = 'text/html'
43
+ response.body = render_page request
44
+ end
45
+
46
+ def render_page(request)
47
+ template_file = File.expand_path('../index.rhtml', __FILE__)
48
+ template_string = File.read template_file
49
+
50
+ current_url = request.path
51
+
52
+ ERB.new(template_string).result(binding)
53
+ end
54
+ end
@@ -0,0 +1,14 @@
1
+ require 'spec_helper'
2
+
3
+ RSpec.describe Navtastic::Item do
4
+ describe '#current?' do
5
+ let(:menu) { Navtastic::Menu.new }
6
+ let!(:item) { menu.item "Home", '/' }
7
+
8
+ it "calls the Menu#current_item method" do
9
+ allow(menu).to receive :current_item
10
+ item.current?
11
+ expect(menu).to have_received :current_item
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,203 @@
1
+ require 'spec_helper'
2
+ require 'support/matchers/current_item.rb'
3
+
4
+ RSpec.describe Navtastic::Menu do
5
+ subject(:menu) do
6
+ menu = described_class.new
7
+
8
+ menu.item "Home" do |submenu|
9
+ submenu.item "Posts", '/posts'
10
+ submenu.item "Featured", '/posts/featured'
11
+ end
12
+
13
+ menu.item "About", '/about'
14
+
15
+ menu
16
+ end
17
+
18
+ describe '.new' do
19
+ subject(:menu) { described_class.new }
20
+
21
+ context "when the menu has no parent" do
22
+ specify { expect(menu.items).to be_empty }
23
+ specify { expect(menu).not_to have_current_item }
24
+
25
+ it "has a depth of 0" do
26
+ expect(menu.depth).to eq 0
27
+ end
28
+ end
29
+
30
+ context "when the menu has a parent" do
31
+ subject(:submenu) { described_class.new(menu) }
32
+
33
+ specify { expect(submenu.items).to be_empty }
34
+ specify { expect(submenu).not_to have_current_item }
35
+
36
+ it "has a depth of parent.depth + 1" do
37
+ expect(submenu.depth).to eq(menu.depth + 1)
38
+ end
39
+ end
40
+ end
41
+
42
+ describe '#item' do
43
+ specify { expect { menu.item "Test" }. to change { menu.items.size }.by 1 }
44
+
45
+ it "returns the inserted item" do
46
+ expect(menu.item("Test")).to eq menu.items.last
47
+ end
48
+
49
+ context "with 1 argument" do
50
+ before { menu.item name }
51
+
52
+ let(:name) { "Test" }
53
+ let(:item) { menu.items.last }
54
+
55
+ it "sets the name" do
56
+ expect(item.name).to eq name
57
+ end
58
+
59
+ it "leaves the url empty" do
60
+ expect(item.url).to eq nil
61
+ end
62
+ end
63
+
64
+ context "with 2 arguments" do
65
+ before { menu.item name, url }
66
+
67
+ let(:name) { "Test" }
68
+ let(:url) { "/url" }
69
+ let(:item) { menu.items.last }
70
+
71
+ it "sets the name" do
72
+ expect(item.name).to eq name
73
+ end
74
+
75
+ it "sets the url" do
76
+ expect(item.url).to eq url
77
+ end
78
+ end
79
+
80
+ context "when the item has a submenu" do
81
+ let(:item) { menu.items.first }
82
+
83
+ it "adds the menu to the itme" do
84
+ expect(item.submenu).not_to eq nil
85
+ end
86
+ end
87
+ end
88
+
89
+ describe '#items' do
90
+ it "includes every item defined in this menu" do
91
+ expect(menu.items.count).to eq 2
92
+ end
93
+ end
94
+
95
+ describe '#each' do
96
+ specify { expect { |b| menu.each(&b) }.to yield_control.exactly(2).times }
97
+ specify { expect(menu).to all be_a Navtastic::Item }
98
+ end
99
+
100
+ describe '#[]' do
101
+ it "returns the item for the given url in this menu" do
102
+ expect(menu['/about'].name).to eql 'About'
103
+ end
104
+
105
+ it "returns the item for the given url in a submenu" do
106
+ expect(menu['/posts/featured'].name).to eql 'Featured'
107
+ end
108
+
109
+ it "returns nil when the url doesn't exist" do
110
+ expect(menu['/foo']).to be nil
111
+ end
112
+ end
113
+
114
+ describe '#current_item' do
115
+ subject(:current_item) { menu.current_item }
116
+
117
+ context "when the menu has no items" do
118
+ let(:menu) { described_class.new }
119
+
120
+ it { is_expected.to eq nil }
121
+ end
122
+
123
+ context "when the current url was not set" do
124
+ it "points to the first item with a url" do
125
+ expect(current_item).to eq menu['/posts']
126
+ end
127
+ end
128
+
129
+ context "when the current url was set" do
130
+ before { menu.current_url = url }
131
+
132
+ let(:url) { '/about' }
133
+
134
+ it "points to the item matching the url" do
135
+ expect(current_item).to eq menu[url]
136
+ end
137
+ end
138
+
139
+ context "when the current url matches an item in a submenu" do
140
+ before { menu.current_url = url }
141
+
142
+ let(:url) { '/posts' }
143
+
144
+ it "points to the item in the submenu" do
145
+ expect(current_item).to eq menu[url]
146
+ end
147
+ end
148
+
149
+ context "when the current menu has a parent" do
150
+ before { menu.current_url = url }
151
+
152
+ subject(:submenu) { menu.items.first.submenu }
153
+
154
+ let(:url) { '/about' }
155
+
156
+ it "asks the parent menu for the current item" do
157
+ allow(menu).to receive :current_item
158
+ submenu.current_item
159
+ expect(menu).to have_received :current_item
160
+ end
161
+ end
162
+ end
163
+
164
+ describe '#current_url=' do
165
+ before { menu.current_url = current_url }
166
+
167
+ context "when the url matches an item directly" do
168
+ let(:current_url) { '/posts' }
169
+
170
+ it "sets the item matching the url as current" do
171
+ expect(menu).to have_current_item(menu[current_url])
172
+ end
173
+ end
174
+
175
+ context "when the url matches the beginning of an item" do
176
+ let(:current_url) { '/posts/featured/2' }
177
+
178
+ it "sets the item with the longest matching url as current" do
179
+ expect(menu).to have_current_item(menu['/posts/featured'])
180
+ end
181
+ end
182
+
183
+ context "when the url matches nothing without a root item" do
184
+ let(:current_url) { '/foo/bar' }
185
+
186
+ it { is_expected.not_to have_current_item }
187
+ end
188
+
189
+ context "when the url matches nothing with a root item" do
190
+ subject(:menu) do
191
+ menu = described_class.new
192
+ menu.item "Home", '/'
193
+ menu
194
+ end
195
+
196
+ let(:current_url) { '/foo/bar' }
197
+
198
+ it "sets the root item as current" do
199
+ expect(menu).to have_current_item(menu['/'])
200
+ end
201
+ end
202
+ end
203
+ end