navtastic 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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